From 7f3735362e21da187fac0563b687d6b9f9b9e651 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Sun, 30 Aug 2026 20:50:06 +0500 Subject: [PATCH 1/5] talentpool filter workds --- backend/employment_agent/decorators.py | 15 +- backend/employment_agent/prompt.py | 1 + backend/job/app.py | 66 +++- backend/job/candidate/models.py | 139 ++++++++- backend/job/candidate/serializers.py | 22 ++ backend/job/candidate/views.py | 53 +++- backend/job/job_post/serializers.py | 6 +- frontend/src/api/candidates.js | 28 ++ frontend/src/app/routes.js | 2 +- frontend/src/app/useShell.js | 15 +- frontend/src/lib/queryKeys.js | 2 + frontend/src/screens/CvImport.jsx | 10 +- frontend/src/screens/Matching.jsx | 413 +++++++------------------ frontend/src/screens/TalentPool.jsx | 39 ++- 14 files changed, 467 insertions(+), 344 deletions(-) diff --git a/backend/employment_agent/decorators.py b/backend/employment_agent/decorators.py index 00d8336..563b5fd 100644 --- a/backend/employment_agent/decorators.py +++ b/backend/employment_agent/decorators.py @@ -60,6 +60,7 @@ def clamp_in_resume(key,sentinel): def _clean_linkedin(value,resume_text): + """Keep a LinkedIn URL only when the CV evidences it. Sentinel / invented → None.""" url=(value or "").strip() if not url or url.lower() in (NO_LINKEDIN.lower(),"none","null","n/a","-"): return None @@ -70,7 +71,19 @@ def _clean_linkedin(value,resume_text): return None if not lowered.startswith("http://") and not lowered.startswith("https://"): url="https://"+url.lstrip("/") - return url + text=(resume_text or "").strip() + if not text: + return url + from linkedin_utils import slug_from_url,slugs_from_text + agent_slug=slug_from_url(url) + if agent_slug: + return url if agent_slug in slugs_from_text(text) else None + if "lnkd.in" in lowered: + from linkedin_utils import profile_url_from_text + evidenced=profile_url_from_text(text) + if evidenced and "lnkd.in" in evidenced.lower(): + return evidenced + return None def _clean_phone(value,resume_text): diff --git a/backend/employment_agent/prompt.py b/backend/employment_agent/prompt.py index 15f56c6..b453aff 100644 --- a/backend/employment_agent/prompt.py +++ b/backend/employment_agent/prompt.py @@ -39,6 +39,7 @@ linkedin_url (its own key — extract this separately from the other fields): - Copy the full slug. Never drop a trailing path segment. - Do not return a company page (linkedin.com/company/...), a search URL, or a URL that is not LinkedIn. - Do not invent a profile. If none is mentioned, return exactly: {NO_LINKEDIN} +- Never guess a slug or construct linkedin.com/in/ from the candidate's name. The stored value will be null when this sentinel is returned. phone (its own key — extract this separately; copy EVERY digit): - Return the candidate's own mobile / phone exactly as written, including country code when present. diff --git a/backend/job/app.py b/backend/job/app.py index d271172..1d46b48 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -2,7 +2,7 @@ from fastapi import APIRouter,Depends,Query,Response from fastapi.responses import FileResponse,JSONResponse from fastapi import HTTPException from db_setup import get_session -from job.candidate.views import CandidateScoring,FileRead,CandidateView +from job.candidate.views import CandidateScoring,FileRead,CandidateView,parse_linkedin_url_from_cv from job.interviews.views import Interview from job.notes.views import Note from job.activity.views import ActivityLog @@ -33,6 +33,11 @@ logger = logging.getLogger(__name__) router = APIRouter() +class MatchingAssign(BaseModel): + id: UUID + job_post_id: UUID | None = None + + class CandidateUpdate(BaseModel): favorite: bool | None = None rating: float | None = None @@ -297,6 +302,7 @@ async def cv_bank_upload( parsed=await reader.injest_manual_upload() # 400 on unreadable/empty PDF text=parsed.get("text") or "" detected,_=extract_candidate_email(text) + parsed_linkedin=await parse_linkedin_url_from_cv(text) # Basename against both separator styles — a Windows client sends # C:\Users\x\cv.pdf whose PosixPath name is the whole string. original=PurePosixPath(PureWindowsPath(file.filename or "resume.pdf").name).name or "resume.pdf" @@ -308,6 +314,7 @@ async def cv_bank_upload( file_name=original, created_by=current_user.get("id"), pdf_bytes=content, + linkedin_url=parsed_linkedin, ) try: uploaded=S3().upload_for_record( @@ -332,6 +339,7 @@ async def cv_bank_upload( "file_name":row.file_name, "file_path":row.file_path or None, "candidate_email":row.candidate_email or None, + "linkedin_url":row.linkedin_url or None, "created_at":row.created_at.isoformat() if row.created_at else None, },"status_code":200}) except HTTPException: @@ -359,6 +367,7 @@ async def cv_bank_fetch( "file_path":(r.file_path or "").strip() or None, "candidate_email":r.candidate_email or None, "candidate_name":r.candidate_name or None, + "linkedin_url":r.linkedin_url or None, "created_at":r.created_at.isoformat() if r.created_at else None, } for r in rows] return JSONResponse(content={"data":data,"total":total,"status_code":200}) @@ -423,6 +432,61 @@ async def cv_bank_delete( raise HTTPException(status_code=500,detail=str(e)) +@router.get("/candidate/matching/fetch") +async def matching_fetch( + top: int = Query(10, ge=1, le=500), + skip: int = Query(0, ge=0), + assigned: bool | None = Query(default=None), + search: str | None = Query(default=None), + current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)), + session: AsyncSession = Depends(get_session), +): + """Job Matching queue: CV Import 'No job' rows (apply_via=cv_bank).""" + try: + service=CandidateView(session=session) + data,total=await service.list_matching( + assigned=assigned,search=search,limit=top,offset=skip, + ) + 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/matching/fetch_by_id") +async def matching_fetch_by_id( + id: str = Query(...), + current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)), + session: AsyncSession = Depends(get_session), +): + try: + service=CandidateView(session=session) + data=await service.get_matching(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("/candidate/matching/assign") +async def matching_assign( + payload: MatchingAssign, + current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)), + session: AsyncSession = Depends(get_session), +): + """Set job_post_id on a CV-bank row — it then joins like any manual upload.""" + try: + service=CandidateView(session=session) + data=await service.assign_matching(payload.id,payload.job_post_id,current_user.get("id")) + return JSONResponse(content={"data":data,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + @router.post("/candidate/inbox-match") async def candidate_inbox_match( inbox_message_id: str = Query(...), diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index b599971..120e07b 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -380,25 +380,27 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): return out # ---- CV bank ----------------------------------------------------------- - # apply_via="cv_bank" rows are a private store of CVs with NO job, NO user - # account and NO inbox entry — deliberately invisible to Candidates, - # Pipeline (whose list inner-joins Users/JobPosts) and the Inbox. They wait - # until a recruiter picks them up; email is captured only when the CV - # contains one. + # apply_via="cv_bank" marks origin: the CV Import "No job" tab. Unassigned + # rows (job_post_id IS NULL) are the bank; Job Matching assigns a job_post_id + # (and a user account) so get_all's Users/JobPosts inner joins pick them up + # as normal applications. apply_via stays "cv_bank" so Matching can still + # list them. No inbox entry. @classmethod async def insert_bank_cv(cls, session: AsyncSession, *, candidate_email, candidate_name, full_text, file_name, created_by, pdf_bytes, - content_type="application/pdf"): + content_type="application/pdf", + linkedin_url=None): """Bank a CV: metadata row + its bytes (cv_bank_files) in one commit. file_path is filled after S3 upload under Temp/{id}/ (see cv_bank_upload). - When the CV carries an email, the candidate ACCOUNT is created/reused - (same pattern as create_manual_upload_candidate) so the person shows - up on the Candidates screen; unlike an application there is still no - inbox entry, no scoring, and no setup email. A CV with no detectable - email banks fine and simply stays account-less.""" + linkedin_url is the employment-agent extraction (None when the CV has + none — never a constructed slug). When the CV carries an email, the + candidate ACCOUNT is created/reused so the person shows up on the + Candidates screen; unlike an application there is still no inbox entry, + no scoring, and no setup email. A CV with no detectable email banks + fine and simply stays account-less.""" import os from role.models import EnumRoles, Roles @@ -427,12 +429,21 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): user.is_active = True session.add(user) + url = (linkedin_url or "").strip() or None + if url: + linkedin_slug = slug_from_url(url) or NO_SLUG + else: + linkedin_slug = primary_slug_from_text(full_text or "") + if user and url: + await Users.set_linkedin_url_if_empty(session, user_id=user.id, url=url) + row = cls( candidate_email=email, candidate_name=(candidate_name or "").strip() or (email or ""), job_post_id=None, full_text=full_text or "", - linkedin_slug=primary_slug_from_text(full_text or ""), + linkedin_slug=linkedin_slug, + linkedin_url=url, apply_via="cv_bank", user_id=user.id if user else None, created_by=cls._as_uuid(created_by), @@ -455,14 +466,16 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): @classmethod async def list_bank(cls, session: AsyncSession, limit=100, offset=0): + """Unassigned No-job CVs only — assigned rows leave the bank for Matching.""" + bank = (cls.apply_via == "cv_bank", cls.job_post_id.is_(None)) total = ( await session.execute( - select(func.count()).select_from(cls).where(cls.apply_via == "cv_bank") + select(func.count()).select_from(cls).where(*bank) ) ).scalar() or 0 result = await session.execute( select(cls) - .where(cls.apply_via == "cv_bank") + .where(*bank) .order_by(cls.created_at.desc(), cls.id.desc()) .limit(limit) .offset(offset) @@ -470,12 +483,104 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): return list(result.scalars().all()), total @classmethod - async def delete_bank_cv(cls, session: AsyncSession, record_id): - """Hard delete, bank rows only — never reachable for application rows. - The cv_bank_files row goes with it via ON DELETE CASCADE.""" + async def list_matching(cls, session: AsyncSession, *, assigned=None, + search=None, limit=100, offset=0): + """No-job-tab origin (`apply_via=cv_bank`). `assigned` is tri-valued: + None = all, False = still in the bank, True = job_post_id set.""" + filters = [cls.apply_via == "cv_bank"] + if assigned is True: + filters.append(cls.job_post_id.is_not(None)) + elif assigned is False: + filters.append(cls.job_post_id.is_(None)) + if search and str(search).strip(): + like = f"%{str(search).strip()}%" + filters.append(or_( + cls.candidate_name.ilike(like), + cls.candidate_email.ilike(like), + cls.file_name.ilike(like), + )) + total = ( + await session.execute( + select(func.count()).select_from(cls).where(*filters) + ) + ).scalar() or 0 + result = await session.execute( + select(cls) + .where(*filters) + .order_by(cls.created_at.desc(), cls.id.desc()) + .limit(limit) + .offset(offset) + ) + return list(result.scalars().all()), total + + @classmethod + async def _ensure_bank_user(cls, session: AsyncSession, row): + """Candidate USER so get_all / Candidates can see the row after assign. + insert_user commits; caller must reload `row` afterwards.""" + if row.user_id: + return None + import os + + from role.models import EnumRoles, Roles + from users.models import Users + from users.plugins import hash_password + + email = (row.candidate_email or "").strip().lower() + if not email: + email = f"cvbank-{row.id.hex}@no-email.local" + user = await Users.get_user_by_email(session, email) + if not user: + role = await Roles.get_role_by_name(session, EnumRoles.CANDIDATE.value) + name = (row.candidate_name or "").strip() or (row.file_name or "").strip() or email + user = await Users.insert_user(session, { + "name": name, + "email": email, + "role_id": role.id if role else 8, + "password": hash_password(os.getenv("DEFAULT_CANDIDATE_PASSWORD", "Utopia!@#")), + "is_active": True, + "is_approved": True, + "is_deleted": False, + }) + elif user.is_deleted or not user.is_active: + user.is_deleted = False + user.is_active = True + session.add(user) + return user + + @classmethod + async def assign_job_post(cls, session: AsyncSession, record_id, job_post_id): + """Set job_post_id on a CV-bank row. None unassigns (back to the bank). + Origin apply_via stays cv_bank. Creates/reuses a candidate user so the + row joins like any other manual_upload_candidate application.""" row = await cls.get_by_id(session, record_id) if not row or row.apply_via != "cv_bank": return None + jid = cls._as_uuid(job_post_id) if job_post_id not in (None, "") else None + user = await cls._ensure_bank_user(session, row) + row = await cls.get_by_id(session, record_id) + if not row: + return None + if user is not None: + row.user_id = user.id + if not (row.candidate_email or "").strip() and user.email: + row.candidate_email = user.email + if not (row.candidate_name or "").strip() and user.name: + row.candidate_name = user.name + row.job_post_id = jid + row.status = "PENDING" if jid else "BANKED" + row.updated_at = _now() + session.add(row) + await session.commit() + await session.refresh(row) + return row + + @classmethod + async def delete_bank_cv(cls, session: AsyncSession, record_id): + """Hard delete unassigned bank rows only — assigned rows are applications. + The cv_bank_files row goes with it via ON DELETE CASCADE.""" + row = await cls.get_by_id(session, record_id) + if not row or row.apply_via != "cv_bank" or row.job_post_id is not None: + return None file_row = await CvBankFiles.get(session, row.id) if file_row: await session.delete(file_row) diff --git a/backend/job/candidate/serializers.py b/backend/job/candidate/serializers.py index b84cb03..2c47e40 100644 --- a/backend/job/candidate/serializers.py +++ b/backend/job/candidate/serializers.py @@ -41,6 +41,28 @@ def serialize_candidate(row) -> dict: } +def serialize_matching_candidate(row, job_post=None) -> Dict[str,Any]: + """CV-bank origin row for Job Matching. assigned_job_post_id is job_posts.id.""" + name=(row.candidate_name or "").strip() or (row.candidate_email or "").strip() or (row.file_name or "").strip() or "Unknown" + job_payload=serialize_job_post(job_post) if job_post else None + return { + "id":str(row.id), + "name":name, + "email":(row.candidate_email or "").strip() or None, + "file_name":(row.file_name or "").strip() or None, + "file_path":(row.file_path or "").strip() or None, + "resume_text":row.full_text or None, + "linkedin_url":row.linkedin_url or None, + "apply_via":row.apply_via, + "status":row.status or None, + "user_id":str(row.user_id) if row.user_id else None, + "assigned_job_post_id":str(row.job_post_id) if row.job_post_id else None, + "assigned_job_post":job_payload, + "created_at":row.created_at.isoformat() if row.created_at else None, + "updated_at":row.updated_at.isoformat() if row.updated_at else None, + } + + def serialize_manual_upload_candidate(row) -> Dict[str,Any]: return { "id":str(row.id) if row.id else None, diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index db4031b..6fdddd5 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -26,7 +26,7 @@ from job.candidate.plugins import ( get_scoring_settings, normalize_spaced_text, ) -from job.candidate.serializers import serialize_candidate,serialize_candidate_profile,serialize_manual_candidate_profile,serialize_manual_upload_candidate +from job.candidate.serializers import serialize_candidate,serialize_candidate_profile,serialize_manual_candidate_profile,serialize_manual_upload_candidate,serialize_matching_candidate from job.job_post.models import JobPosts from job.job_post.serializers import serialize_job_post from job.candidate.models import Notes,Manual_UPLOAD_CANDIDATE @@ -46,7 +46,7 @@ MANUAL_UPLOAD_TO_ADDRESS=os.getenv( async def parse_linkedin_url_from_cv(resume_text) -> str | None: - """Employment-agent `linkedin_url` key from parsed CV text. None if absent or the call fails.""" + """Employment-agent `linkedin_url` from CV text. None if absent, sentinel, invented, or the call fails.""" text=(resume_text or "").strip() if not text: return None @@ -1088,3 +1088,52 @@ class CandidateView: raise HTTPException(status_code=404,detail="Not found") name=(entry.get("name") or path.name).strip() or path.name return path,name + + async def list_matching(self,assigned=None,search=None,limit=10,offset=0): + rows,total=await Manual_UPLOAD_CANDIDATE.list_matching( + self.session,assigned=assigned,search=search,limit=limit,offset=offset, + ) + job_ids=[str(r.job_post_id) for r in rows if r.job_post_id] + posts=await JobPosts.get_by_ids(self.session,job_ids,active_only=False) if job_ids else [] + by_id={str(p.id):p for p in posts} + data=[ + serialize_matching_candidate(r,by_id.get(str(r.job_post_id)) if r.job_post_id else None) + for r in rows + ] + return data,total + + async def get_matching(self,record_id): + row=await Manual_UPLOAD_CANDIDATE.get_by_id(self.session,record_id) + if not row or row.apply_via!="cv_bank": + raise HTTPException(status_code=404,detail="CV not found") + job_post=None + if row.job_post_id: + job_post=await JobPosts.get_job_post_by_id(self.session,str(row.job_post_id)) + return serialize_matching_candidate(row,job_post) + + async def assign_matching(self,record_id,job_post_id,current_user=None): + row=await Manual_UPLOAD_CANDIDATE.get_by_id(self.session,record_id) + if not row or row.apply_via!="cv_bank": + raise HTTPException(status_code=404,detail="CV not found") + job_post=None + if job_post_id not in (None,""): + job_post=await JobPosts.get_job_post_by_id(self.session,str(job_post_id)) + if not job_post or job_post.is_deleted: + raise HTTPException(status_code=404,detail="Job post not found") + was_unassigned=row.job_post_id is None + row=await Manual_UPLOAD_CANDIDATE.assign_job_post(self.session,record_id,job_post_id) + if not row: + raise HTTPException(status_code=404,detail="CV not found") + if job_post is None and row.job_post_id: + job_post=await JobPosts.get_job_post_by_id(self.session,str(row.job_post_id)) + if was_unassigned and row.job_post_id and row.user_id: + title=(job_post.title if job_post else "") or str(row.job_post_id) + await HistoryRecorder(self.session).record( + HistoryEvent.CANDIDATE_CREATED.value, + actor_id=current_user,user_id=row.user_id, + manual_upload_candidate_id=row.id, + entity_type="manual_upload_candidate",entity_id=row.id, + to_value=title, + description="cv_bank",commit=True, + ) + return serialize_matching_candidate(row,job_post) diff --git a/backend/job/job_post/serializers.py b/backend/job/job_post/serializers.py index 52b223f..92c6e61 100644 --- a/backend/job/job_post/serializers.py +++ b/backend/job/job_post/serializers.py @@ -2,6 +2,8 @@ def serialize_job_post(row) -> dict: return { "id": str(row.id), "title": row.title, + # Talent Pool / candidate filters key off attached job_posts.department. + "department": row.department or None, "employment_type": row.employment_type, "location": row.location, "experience_min": row.experience_min, @@ -31,8 +33,8 @@ def serialize_job_row(row, *, recruiter_name=None, applicant_count=0) -> dict: """Requisition view of a job post, for the Jobs screen. Deliberately separate from serialize_job_post: that payload is shared by the - inbox, candidate and matching paths, and widening it would change five - response shapes at once. + inbox, candidate and matching paths. department is the one shared field — + talent-pool filters key off it on attached job_posts. """ return { "id": str(row.id), diff --git a/frontend/src/api/candidates.js b/frontend/src/api/candidates.js index bb997cb..86f0df2 100644 --- a/frontend/src/api/candidates.js +++ b/frontend/src/api/candidates.js @@ -92,6 +92,34 @@ export function viewCvBankCv(id) { }) } +/** + * Job Matching queue — CV Import "No job" rows (`apply_via=cv_bank`). + * Needs candidates.view. `assigned` is tri-valued: omit for all, false for + * still in the bank, true for rows that already have a job_post_id. + */ +export function listMatching({ search, top = 10, skip = 0, assigned } = {}) { + return request('/candidate/matching/fetch', { + params: { search, top, skip, assigned }, + }) +} + +/** One matching row by manual_upload_candidate id. Needs candidates.view. */ +export function getMatching(id) { + return request('/candidate/matching/fetch_by_id', { params: { id } }) +} + +/** + * Link (or unlink) a job post on a CV-bank row. Needs candidates.edit. + * After assign the row has job_post_id + user_id and Pipeline/Candidates + * fetch it like any other manual_upload_candidate. + */ +export function assignMatchingJob(id, jobPostId) { + return request('/candidate/matching/assign', { + method: 'POST', + body: { id, job_post_id: jobPostId }, + }) +} + /** * Score the decoded attachments of inbox messages against a job post. * Needs candidates.create. messageIds are inbox_messages PK uuids (the `id` diff --git a/frontend/src/app/routes.js b/frontend/src/app/routes.js index e2e81e0..315fd46 100644 --- a/frontend/src/app/routes.js +++ b/frontend/src/app/routes.js @@ -18,7 +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: 'matching', title: 'Job Matching', icon: 'target', group: 'Workspace', permission: 'candidates.view', badge: 'matching' }, { path: 'jobs', title: 'Jobs', icon: 'briefcase', group: 'Workspace', permission: 'jobs.view', badge: 'jobs' }, { path: 'candidates', title: 'Candidates', icon: 'users', group: 'Workspace', permission: 'candidates.view' }, { path: 'talentpool', title: 'Talent Pool', icon: 'talent', group: 'Workspace', permission: 'candidates.view' }, diff --git a/frontend/src/app/useShell.js b/frontend/src/app/useShell.js index cbcc6be..ba517d5 100644 --- a/frontend/src/app/useShell.js +++ b/frontend/src/app/useShell.js @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useQuery } from '@tanstack/react-query' import { qk } from '../lib/queryKeys' import * as inboxApi from '../api/inbox' +import * as candidatesApi from '../api/candidates' import * as tasksApi from '../api/tasks' import * as jobsApi from '../api/jobs' import * as notificationsApi from '../api/notifications' @@ -94,15 +95,19 @@ export function useHotkeys({ onEscape }) { * 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. + * `matching` is the unassigned CV-bank queue — CVs imported with No job that + * still have no job_post_id. Job Matching exists to drive that number to zero. */ export function useBadges() { const { data: matchingTotal = 0 } = useQuery({ - queryKey: qk.mailbox.assignments({ assigned: false }), + queryKey: qk.candidates.matching({ assigned: false, count: true }), queryFn: async () => { - const res = await inboxApi.listApplications({ assigned: false, top: 1 }) - return res?.total ?? 0 + try { + const res = await candidatesApi.listMatching({ assigned: false, top: 1 }) + return res?.total ?? 0 + } catch { + return 0 + } }, }) const { data: tasksTotal = 0 } = useQuery({ diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index 641f3eb..a8907f1 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -92,6 +92,8 @@ export const qk = { count: (p = {}) => ['candidates', 'count', p], detail: (id) => ['candidates', 'detail', id], history: (id, p = {}) => ['candidates', 'history', id, p], + matching: (p = {}) => ['candidates', 'matching', p], + matchingDetail: (id) => ['candidates', 'matching', 'detail', id], }, // Board rows come from the same endpoint as qk.candidates.list but are cached // MAPPED (kanban cards, not the raw envelope), so they need their own key — diff --git a/frontend/src/screens/CvImport.jsx b/frontend/src/screens/CvImport.jsx index 07c9699..c0772f3 100644 --- a/frontend/src/screens/CvImport.jsx +++ b/frontend/src/screens/CvImport.jsx @@ -349,9 +349,8 @@ export default function CvImport() { ) } -/* The stored-CV bank — a private store with no job, account or inbox entry. - This list is the bank's home: browse, download, or remove; picking a CV up - for a job later is a future action. */ + /* The stored-CV bank — CVs imported with No job. Unassigned rows live here; + assigning a job in Job Matching sets job_post_id and they leave this list. */ function CvBank() { const { toast } = useToast() const qc = useQueryClient() @@ -432,6 +431,11 @@ function CvBank() { {r.candidate_email || 'No email detected'} {r.created_at ? ` · added ${new Date(r.created_at).toLocaleDateString()}` : ''} + {r.linkedin_url ? ( +
+ {r.linkedin_url} +
+ ) : null} {r.file_path ? (
{r.file_path} diff --git a/frontend/src/screens/Matching.jsx b/frontend/src/screens/Matching.jsx index 54a877d..70b3a5f 100644 --- a/frontend/src/screens/Matching.jsx +++ b/frontend/src/screens/Matching.jsx @@ -1,18 +1,16 @@ /* ============================================================ - Job Matching — assign each inbound application to exactly one job post. + Job Matching — assign a job post to CVs stored with "No job" + (CV Import → CV bank, apply_via=cv_bank on manual_upload_candidate). - Queue layout mirrors Inbox (Tabs over a .split). Page size defaults to 10; - skip = (page-1)*limit, same as Inbox. 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. + Needs assignment: job_post_id is null. Assign writes job_post_id (and a + candidate user) so Pipeline / Candidates fetch the row like any other + manual upload. Assigned tab is the same origin with a job already linked. ============================================================ */ import { useEffect, useMemo, useState } from 'react' import { useSearchParams } from 'react-router-dom' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import EmailBody, { looksLikeHtml } from '../ui/EmailBody' import OpenResumeButton from '../ui/OpenResumeButton' import PageHeader from '../ui/PageHeader' import { Tabs } from '../ui/Tabs' @@ -23,34 +21,26 @@ 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 candidatesApi from '../api/candidates' import * as jobPostsApi from '../api/jobPosts' import * as s3Api from '../api/s3' -import { - avatarColor, fmtDate, initials as initialsOf, inboxSources, sourceMeta, -} from '../data/seed' +import { avatarColor, fmtDate, initials as initialsOf } 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: { assigned: false, noSuggestions: true }, all: {}, } -/** Same cap as GET /inbox/all-applications `top`. */ const PAGE_SIZE_MAX = 500 -const RESUME_STATUS = { - processing: 'Parsing', matched: 'Parsed', no_text: 'Failed', - failed: 'Failed', dlq: 'Failed', skipped: 'Pending', -} +const CV_BANK_META = { icon: 'file', color: 'var(--c2)', channel: 'Upload' } function parseDate(value) { if (!value) return null @@ -58,13 +48,39 @@ function parseDate(value) { return Number.isNaN(d.getTime()) ? null : d } -function sourceFrom(messageTo) { - const raw = typeof messageTo === 'string' ? messageTo.trim() : String(messageTo ?? '').trim() - if (!raw || raw === '[object Object]') 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 mapRow(row) { + const name = row.name || row.email || row.file_name || 'Unknown' + return { + id: String(row.id), + name, + initials: initialsOf(name), + color: avatarColor(name), + email: row.email || '', + position: row.file_name || 'CV bank', + source: 'CV bank', + sourceMeta: CV_BANK_META, + received: parseDate(row.created_at), + resumeText: row.resume_text || '', + filePath: row.file_path || '', + assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null, + assignedPost: row.assigned_job_post || null, + status: row.status || null, + userId: row.user_id || null, + linkedinUrl: row.linkedin_url || null, + } +} + +async function fetchQueue(params) { + const res = await candidatesApi.listMatching(params) + const rows = Array.isArray(res?.data) ? res.data : [] + return { rows: rows.map(mapRow), total: res?.total ?? rows.length } +} + +async function fetchDetail(recordId) { + const res = await candidatesApi.getMatching(recordId) + const row = res?.data + if (!row) return null + return mapRow(row) } function SourceChip({ item }) { @@ -76,106 +92,18 @@ function SourceChip({ item }) { ) } -function htmlToText(value) { - const raw = (value || '').trim() - if (!raw) return '' - if (!/<[a-z!/]/i.test(raw)) return raw - const withBreaks = raw - .replace(//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() -} - -function mapApplication(row) { - const name = row.name || row.email || 'Unknown' - 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 || '', - filePath: row.file_path || '', - suggestedIds: Array.isArray(row.suggested_job_post_ids) - ? row.suggested_job_post_ids.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)', - // Same value as `position`, kept under its own name: the email panel renders - // it as a mail header, not as the candidate's role. - subject: row.subject || '', - ...sourceFrom(row.message_to), - body: htmlToText(row.body), - // Kept raw for the HTML viewer; `body` stays as the plain-text fallback for - // mail that never had markup. EmailBody sanitises before rendering. - bodyHtml: row.body || '', - resumeText: row.resume_text || '', - files: Array.isArray(row.files) ? row.files : [], - filePath: row.file_path || '', - 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 Matching… - } if (item.assignedId) { - const title = titleById.get(item.assignedId) || 'Assigned' + const title = titleById.get(item.assignedId) || item.assignedPost?.title || 'Assigned' return {title} } - const n = item.suggestedIds.length - if (n > 0) return {n} suggested - return No match + return No job } export default function Matching() { const { toast } = useToast() const { can } = useAuth() - const canEdit = can('inbox.edit') + const canEdit = can('candidates.edit') const qc = useQueryClient() const [searchParams, setSearchParams] = useSearchParams() const deepLink = searchParams.get('record') @@ -188,7 +116,6 @@ export default function Matching() { 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 listParams = useMemo(() => ({ @@ -199,25 +126,21 @@ export default function Matching() { }), [tabFilter, page, pageSize, q]) const listQuery = useQuery({ - queryKey: qk.mailbox.assignments({ ...listParams, tab }), - queryFn: () => fetchApplications(listParams), + queryKey: qk.candidates.matching({ ...listParams, tab }), + queryFn: () => fetchQueue(listParams), }) const needsCount = useQuery({ - queryKey: qk.mailbox.assignments({ assigned: false, count: true }), - queryFn: async () => (await inboxApi.listApplications({ assigned: false, top: 1 }))?.total ?? 0, + queryKey: qk.candidates.matching({ assigned: false, count: true }), + queryFn: async () => (await candidatesApi.listMatching({ assigned: false, top: 1 }))?.total ?? 0, }) const assignedCount = useQuery({ - queryKey: qk.mailbox.assignments({ assigned: true, count: true }), - queryFn: async () => (await inboxApi.listApplications({ assigned: true, top: 1 }))?.total ?? 0, + queryKey: qk.candidates.matching({ assigned: true, count: true }), + queryFn: async () => (await candidatesApi.listMatching({ assigned: true, top: 1 }))?.total ?? 0, }) const allCount = useQuery({ - queryKey: qk.mailbox.assignments({ count: true }), - queryFn: async () => (await inboxApi.listApplications({ top: 1 }))?.total ?? 0, - }) - const noneCountQuery = useQuery({ - queryKey: qk.mailbox.assignments({ kind: 'none', count: true }), - queryFn: async () => (await inboxApi.listApplications({ assigned: false, noSuggestions: true, top: 1 }))?.total ?? 0, + queryKey: qk.candidates.matching({ count: true }), + queryFn: async () => (await candidatesApi.listMatching({ top: 1 }))?.total ?? 0, }) const rows = listQuery.data?.rows ?? [] @@ -226,13 +149,10 @@ export default function Matching() { const pages = Math.max(1, Math.ceil(total / pageSize)) const currentPage = Math.min(page, pages) - const noneCount = noneCountQuery.data ?? 0 - useEffect(() => { if (listQuery.isSuccess && page > pages) setPage(pages) }, [listQuery.isSuccess, page, pages]) - // Preselect deep link once, then clear the query so refresh doesn't re-pin. useEffect(() => { if (!deepLink) return undefined setSelectedId(deepLink) @@ -241,7 +161,7 @@ export default function Matching() { }, [deepLink, setSearchParams]) const detailQuery = useQuery({ - queryKey: qk.mailbox.message(selectedId), + queryKey: qk.candidates.matchingDetail(selectedId), queryFn: () => fetchDetail(selectedId), enabled: Boolean(selectedId), }) @@ -249,12 +169,10 @@ export default function Matching() { 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]) @@ -275,24 +193,11 @@ export default function Matching() { 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]) + }, [detail?.id, detail?.assignedId]) const selectedPost = useMemo(() => { if (!selection) return null @@ -300,17 +205,11 @@ export default function Matching() { 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]) + return null + }, [selection, manualPost, detail]) 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 } - }, + mutationFn: ({ recordId, jobPostId }) => candidatesApi.assignMatchingJob(recordId, jobPostId), onError: (err) => { toast(friendlyAuthError(err, 'Could not assign job post.'), 'error') }, @@ -321,12 +220,11 @@ export default function Matching() { 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() }) + await qc.invalidateQueries({ queryKey: qk.cvBank.all() }) + await qc.invalidateQueries({ queryKey: qk.pipeline.all() }) + await qc.invalidateQueries({ queryKey: qk.candidates.matchingDetail(vars.recordId) }) - // 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 @@ -335,17 +233,6 @@ export default function Matching() { }, }) - 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 @@ -360,9 +247,6 @@ export default function Matching() { 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 }) @@ -373,27 +257,25 @@ export default function Matching() { } document.addEventListener('keydown', onKey) return () => document.removeEventListener('keydown', onKey) - }, [filtered, selectedId, suggestionCards, canEdit, selection, detail, assignMutation]) + }, [filtered, selectedId, 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 resumeKey = s3Api.resumeKeyFrom(detail) || s3Api.resumeKeyFrom(listRow) - const matchFailed = ['failed', 'no_text', 'dlq'].includes(detail?.matchStatus) + const resumeText = detail?.resumeText || listRow?.resumeText || '' return (
- + {!canEdit && (
- Your account does not hold inbox.edit, which the server requires to - assign, unassign, or retry a match. Controls below stay disabled. + Your account does not hold candidates.edit, which the server requires to + assign or unassign a role. Controls below stay disabled.
)} @@ -425,7 +307,7 @@ export default function Matching() {
{listQuery.isPending && ( - Fetching applications. + Fetching CVs from the bank. )} {listQuery.isError && ( @@ -435,14 +317,14 @@ export default function Matching() { {listQuery.isSuccess && filtered.length === 0 && ( {tab === 'needs' - ? 'Every application in this view has a role.' + ? 'Every CV from the No job tab has a role, or the bank is empty.' : 'Nothing matches this filter.'} )} {filtered.map((i) => (
setSelectedId(i.id)} > @@ -480,13 +362,13 @@ export default function Matching() {
{!selectedId ? (
- - Choose an item from the list to review suggestions and assign a role. + + Choose an item from the list to assign a job post.
) : detailQuery.isError ? (
- + {friendlyAuthError(detailQuery.error, 'Request failed')}
@@ -497,15 +379,9 @@ export default function Matching() { loading={detailQuery.isPending} canEdit={canEdit} selection={selection} - setSelection={setSelection} - manualPost={manualPost} - suggestionCards={suggestionCards} selectedPost={selectedPost} resumeText={resumeText} resumeKey={resumeKey} - whyOpen={whyOpen} - setWhyOpen={setWhyOpen} - matchFailed={matchFailed} onPickManual={() => setShowPicker(true)} onSkip={() => { const idx = filtered.findIndex((r) => r.id === selectedId) @@ -521,9 +397,7 @@ export default function Matching() { assignMutation.mutate({ recordId: selectedId, jobPostId: null }) }} onChange={() => setShowPicker(true)} - onRematch={() => rematchMutation.mutate(selectedId)} assigning={assignMutation.isPending} - rematching={rematchMutation.isPending} /> )}
@@ -549,23 +423,15 @@ function MatchingWorkspace({ loading, canEdit, selection, - setSelection, - manualPost, - suggestionCards, selectedPost, resumeText, resumeKey, - whyOpen, - setWhyOpen, - matchFailed, onPickManual, onSkip, onAssign, onUnassign, onChange, - onRematch, assigning, - rematching, }) { const i = { name: detail?.name || listRow?.name || '…', @@ -574,8 +440,9 @@ function MatchingWorkspace({ 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, + email: detail?.email || listRow?.email || '', + received: detail?.received || listRow?.received, + linkedinUrl: detail?.linkedinUrl || listRow?.linkedinUrl || null, } const assigned = detail?.assignedPost @@ -588,20 +455,28 @@ function MatchingWorkspace({
{i.name}
-
{i.position}
+
{i.email || i.position}
{' '} - - {i.resumeStatus} - + {i.received && Added {fmtDate(i.received)}} {loading && Loading details…}
- {s3Api.canOpen(resumeKey) && ( + {(s3Api.canOpen(resumeKey) || i.linkedinUrl) && (
- + {s3Api.canOpen(resumeKey) && } + {i.linkedinUrl && ( + + LinkedIn + + )}
)} @@ -626,10 +501,10 @@ function MatchingWorkspace({
- -
@@ -646,86 +521,29 @@ function MatchingWorkspace({ }} >
- {matchFailed ? ( -
-
{detail?.matchError || 'Matching failed for this application.'}
- -
- ) : ( -
-
AI verdict
-

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

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

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

- )} -
- )} - - {/* Email first: it is the application itself, and the resume is its - attachment. Reading order follows that. */} - {(detail?.subject || detail?.body) && ( -
-
Email
-
Subject: {detail.subject || '(no subject)'}
- {looksLikeHtml(detail.bodyHtml) ? ( - - ) : ( -
-                  {detail.body || 'No email body.'}
-                
- )} -
- )} -
CV
{s3Api.canOpen(resumeKey) ? ( ) : ( -

No CV file stored in S3 for this application.

+

No CV file stored in S3 for this record.

)} + {resumeText ? ( +
+              {resumeText}
+            
+ ) : null}
-
-
Suggested roles
- {suggestionCards.length === 0 && !manualPost ? ( - -

No job post was suggested. Choose a role manually.

+
+
Job post
+ {!selectedPost ? ( + +

Pick a role for this CV. After assign it is a normal candidate on that job.

-
) : ( - suggestionCards.map(({ rank, post }) => ( - setSelection(id)} - resumeText={resumeText} - /> - )) - )} - {manualPost && ( setSelection(id)} + selected={String(selection) === String(selectedPost.id)} + onSelect={() => {}} resumeText={resumeText} /> )} @@ -758,10 +564,10 @@ function MatchingWorkspace({ className="btn btn-secondary" style={{ width: '100%', marginTop: 8 }} disabled={!canEdit} - title={!canEdit ? 'Requires inbox.edit' : undefined} + title={!canEdit ? 'Requires candidates.edit' : undefined} onClick={onPickManual} > - Choose a different role… + {selectedPost ? 'Choose a different role…' : 'Choose a role…'}
@@ -780,11 +586,10 @@ function MatchingWorkspace({ diff --git a/frontend/src/screens/TalentPool.jsx b/frontend/src/screens/TalentPool.jsx index 0e6520f..b602f8c 100644 --- a/frontend/src/screens/TalentPool.jsx +++ b/frontend/src/screens/TalentPool.jsx @@ -4,12 +4,11 @@ The layout, the toolbar, the card and the 8-tab profile modal are the originals, unchanged. Only the data source moved. - The endpoint returns name, email, experience, application_status and the - suggested job title. It has no aiScore, skills, currentCompany, source or - department — the agent writes a verdict and prose, not a score, and no - résumé-derived skills are persisted. Each record is therefore OVERLAID on a - seed candidate: real values win, seed fills the rest, so the card renders - exactly as it always did. + The endpoint returns name, email, experience, application_status, suggested + job titles and the attached job_posts (with department). It has no skills or + currentCompany on list rows — those still come from the seed overlay. Each + record is therefore OVERLAID on a seed candidate: real values win, seed fills + the rest, so the card renders exactly as it always did. Clicking a card opens CandidateProfile in place. It used to deep-link into /candidates, which stopped resolving once the ids became real user_ids. @@ -69,6 +68,25 @@ function years(value) { return Number.isFinite(n) ? n : null } +/** + * Distinct departments from the candidate's assigned + suggested job posts. + * Seed templates also carry a department, but that is a prototype leftover and + * must not drive the toolbar filter — it would never match /job/departments/fetch. + */ +function departmentsOf(row) { + const seen = new Set() + const out = [] + const add = (value) => { + const d = typeof value === 'string' ? value : '' + if (!d || seen.has(d)) return + seen.add(d) + out.push(d) + } + add(row.assigned_job_post?.department) + for (const jp of row.job_posts || []) add(jp.department) + return out +} + /** * One API record overlaid on one seed candidate. * @@ -84,6 +102,7 @@ function merge(row, template) { || row.current_title const stage = STAGE_FROM_STATUS[row.application_status] || template.stage const experience = years(row.experience) + const departments = departmentsOf(row) return { ...template, @@ -97,6 +116,10 @@ function merge(row, template) { status: stage, currentTitle: title || template.currentTitle, jobTitle: title || template.jobTitle, + // Live job-post departments only. Seed department is left on `department` + // for the seed-only profile modal, but the filter reads `departments`. + departments, + department: departments[0] || template.department, // Prefer real Form / platform tags from manual_upload; seed only as fallback. source: row.source || template.source, // NO seed fallback. `ai_score` is the candidate's current ats_results row, @@ -185,7 +208,7 @@ export default function TalentPool() { const list = useMemo( () => pool.filter((c) => { - if (dept && c.department !== dept) return false + if (dept && !(c.departments || []).includes(dept)) return false if (q && !(c.name + c.currentCompany + c.skills.join(' ')).toLowerCase().includes(q.toLowerCase())) return false return true }), @@ -235,7 +258,7 @@ export default function TalentPool() { Date: Sun, 30 Aug 2026 21:49:08 +0500 Subject: [PATCH 2/5] add hiring manager added --- backend/job/app.py | 16 +- backend/job/assignment/models.py | 33 +- backend/job/assignment/serializers.py | 22 +- backend/job/assignment/views.py | 116 ++++-- backend/job/job_post/export.py | 4 +- backend/job/job_post/models.py | 38 +- backend/job/job_post/serializers.py | 4 +- backend/job/job_post/views.py | 77 +++- .../manual/015_job_post_hiring_manager.sql | 12 + backend/users/app.py | 7 +- backend/users/models.py | 4 +- backend/users/views.py | 4 +- frontend/src/api/assignments.js | 32 +- frontend/src/api/jobs.js | 5 +- frontend/src/api/users.js | 4 + frontend/src/lib/queryKeys.js | 1 + frontend/src/screens/Jobs.jsx | 362 +++++++++++++----- frontend/src/screens/Managers.jsx | 39 +- frontend/src/screens/RecruiterHub.jsx | 196 +++++++++- frontend/src/screens/Tasks.jsx | 54 ++- 20 files changed, 848 insertions(+), 182 deletions(-) create mode 100644 backend/migrations/manual/015_job_post_hiring_manager.sql diff --git a/backend/job/app.py b/backend/job/app.py index 1d46b48..73751ed 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -138,6 +138,8 @@ class JobUpdate(BaseModel): experience_min: int | None = None experience_max: int | None = None description: str | None = None + current_recruiter_id: UUID | None = None + hiring_manager_id: UUID | None = None class JobStatusUpdate(BaseModel): @@ -756,6 +758,7 @@ async def fetch_jobs( department: str | None = Query(None), requisition_status: str | None = Query(None), employment_type: str | None = Query(None), + hiring_manager_id: str | None = Query(None), # le=500 (not 100): the Jobs board loads a full client-side page for facets; # a 200 ceiling used to 422 the SPA and render an empty requisition list. top: int | None = Query(10, ge=1, le=500), @@ -771,7 +774,8 @@ async def fetch_jobs( service=JobPost(session=session) data,total=await service.fetch_jobs( search=search,department=department,requisition_status=requisition_status, - employment_type=employment_type,top=top,skip=skip,active_only=active_only, + employment_type=employment_type,hiring_manager_id=hiring_manager_id, + top=top,skip=skip,active_only=active_only, ) return JSONResponse(content={"data":data,"total":total,"status_code":200}) except HTTPException: @@ -786,6 +790,7 @@ async def export_jobs( department: str | None = Query(None), requisition_status: str | None = Query(None), employment_type: str | None = Query(None), + hiring_manager_id: str | None = Query(None), active_only: bool = Query(False), current_user: dict = Depends(require_permission(PermissionTag.JOBS_EXPORT)), session: AsyncSession = Depends(get_session), @@ -795,7 +800,8 @@ async def export_jobs( service=JobPost(session=session) data,_=await service.fetch_jobs( search=search,department=department,requisition_status=requisition_status, - employment_type=employment_type,top=None,skip=0,active_only=active_only, + employment_type=employment_type,hiring_manager_id=hiring_manager_id, + top=None,skip=0,active_only=active_only, ) filename=f"jobs-export-{datetime.now(timezone.utc).strftime('%Y-%m-%d')}.xlsx" return Response( @@ -1168,12 +1174,16 @@ async def fetch_pipeline_transitions( @router.get("/job/assignments/fetch") async def fetch_job_assignments( job_post_id:str=Query(...), + current_only:bool=Query(True), + assignment_role:str=Query(None), current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)), session: AsyncSession = Depends(get_session), ): try: service=Assignment(session=session) - data=await service.list_job_assignments(job_post_id) + data=await service.list_job_assignments( + job_post_id,current_only=current_only,assignment_role=assignment_role, + ) return JSONResponse(content={"data":data,"total":len(data),"status_code":200}) except HTTPException: raise diff --git a/backend/job/assignment/models.py b/backend/job/assignment/models.py index fe906b9..9d19580 100644 --- a/backend/job/assignment/models.py +++ b/backend/job/assignment/models.py @@ -40,17 +40,48 @@ class JobAssignments(SQLModel, table=True): return result.scalars().first() @classmethod - async def fetch_by_job(cls, session: AsyncSession, job_post_id, *, current_only: bool = True): + async def fetch_by_job( + cls, + session: AsyncSession, + job_post_id, + *, + current_only: bool = True, + assignment_role: str | None = None, + ): uid = cls._as_uuid(job_post_id) if uid is None: return [] statement = select(cls).where(cls.job_post_id == uid) if current_only: statement = statement.where(cls.valid_to.is_(None)) + if assignment_role: + statement = statement.where(cls.assignment_role == assignment_role) statement = statement.order_by(cls.valid_from.desc()) result = await session.execute(statement) return list(result.scalars().all()) + @classmethod + async def close_current(cls, session: AsyncSession, job_post_id, assignment_role): + """End every open interval of this role on the job. Returns how many closed.""" + uid = cls._as_uuid(job_post_id) + if uid is None or not assignment_role: + return 0 + statement = select(cls).where( + cls.job_post_id == uid, + cls.assignment_role == assignment_role, + cls.valid_to.is_(None), + ) + result = await session.execute(statement) + rows = list(result.scalars().all()) + if not rows: + return 0 + now = _now() + for row in rows: + row.valid_to = now + session.add(row) + await session.commit() + return len(rows) + @classmethod async def insert_assignment(cls, session: AsyncSession, fields: dict): row = cls(**fields) diff --git a/backend/job/assignment/serializers.py b/backend/job/assignment/serializers.py index c1f22fc..f393a5d 100644 --- a/backend/job/assignment/serializers.py +++ b/backend/job/assignment/serializers.py @@ -1,24 +1,34 @@ -def serialize_job_assignment(row) -> dict: +def serialize_job_assignment(row, names=None) -> dict: + names = names or {} + user_key = str(row.user_id) if row.user_id else None + by_key = str(row.assigned_by) if row.assigned_by else None return { "id": str(row.id), "job_post_id": str(row.job_post_id) if row.job_post_id else None, - "user_id": str(row.user_id) if row.user_id else None, + "user_id": user_key, + "user_name": names.get(user_key) if user_key else None, "assignment_role": row.assignment_role, "valid_from": row.valid_from.isoformat() if row.valid_from else None, "valid_to": row.valid_to.isoformat() if row.valid_to else None, - "assigned_by": str(row.assigned_by) if row.assigned_by else None, + "assigned_by": by_key, + "assigned_by_name": names.get(by_key) if by_key else None, "created_at": row.created_at.isoformat() if row.created_at else None, } -def serialize_application_assignment(row) -> dict: +def serialize_application_assignment(row, names=None) -> dict: + names = names or {} + user_key = str(row.user_id) if row.user_id else None + by_key = str(row.assigned_by) if row.assigned_by else None return { "id": str(row.id), "inbox_id": row.inbox_id, - "user_id": str(row.user_id) if row.user_id else None, + "user_id": user_key, + "user_name": names.get(user_key) if user_key else None, "assignment_role": row.assignment_role, "valid_from": row.valid_from.isoformat() if row.valid_from else None, "valid_to": row.valid_to.isoformat() if row.valid_to else None, - "assigned_by": str(row.assigned_by) if row.assigned_by else None, + "assigned_by": by_key, + "assigned_by_name": names.get(by_key) if by_key else None, "created_at": row.created_at.isoformat() if row.created_at else None, } diff --git a/backend/job/assignment/views.py b/backend/job/assignment/views.py index b5be521..e4a01f2 100644 --- a/backend/job/assignment/views.py +++ b/backend/job/assignment/views.py @@ -3,58 +3,129 @@ from sqlalchemy.ext.asyncio import AsyncSession from job.assignment.models import ApplicationAssignments, JobAssignments from job.assignment.serializers import serialize_application_assignment, serialize_job_assignment +from job.job_post.models import JobPosts from role.models import EnumRoles, Roles from users.models import Users +# job_assignments.assignment_role → the users.role that may hold it. +# primary_recruiter is swappable; hiring_manager is the requisition owner. +JOB_ASSIGNMENT_ROLES = { + "primary_recruiter": EnumRoles.RECRUITER, + "hiring_manager": EnumRoles.HIRING_MANAGER, +} +JOB_OWNER_COLUMN = { + "primary_recruiter": "current_recruiter_id", + "hiring_manager": "hiring_manager_id", +} + class Assignment: def __init__(self,session:AsyncSession): self.session=session - async def _require_recruiter(self,user_id): - role=await Roles.get_role_by_name(self.session,EnumRoles.RECRUITER.value) + async def require_role(self,user_id,role_enum,field_name): + role=await Roles.get_role_by_name(self.session,role_enum.value) user=await Users.get_user_by_id(self.session,user_id) if not role or not user or user.role_id!=role.id: - raise HTTPException(status_code=422,detail="user_id must be a recruiter") + raise HTTPException( + status_code=422, + detail=f"{field_name} must be a {role_enum.value}", + ) + if not user.is_active or user.is_deleted: + raise HTTPException(status_code=422,detail=f"{field_name} is not an active user") return user - async def list_job_assignments(self,job_post_id): + def _job_role(self,raw): + key=(raw or "primary_recruiter").strip() + if key=="recruiter": + key="primary_recruiter" + if key not in JOB_ASSIGNMENT_ROLES: + allowed=", ".join(sorted(JOB_ASSIGNMENT_ROLES)) + raise HTTPException( + status_code=422, + detail=f"assignment_role must be one of {allowed}", + ) + return key + + async def record_job_owner(self,job_post_id,user_id,assignment_role,assigned_by): + """Close the open interval of this role, then open a new one. + + user_id None = unassign (hiring_manager cannot be cleared; callers + must not pass None for that role). No-ops when the same person already + holds the open interval. Does not touch job_posts columns. + """ + role=self._job_role(assignment_role) + job_uid=JobAssignments._as_uuid(job_post_id) + by_uid=JobAssignments._as_uuid(assigned_by) + if not job_uid or not by_uid: + raise HTTPException(status_code=422,detail="Invalid job_post_id or assigned_by") + current=await JobAssignments.fetch_by_job( + self.session,job_uid,current_only=True,assignment_role=role, + ) + if user_id is None: + if role=="hiring_manager": + raise HTTPException(status_code=422,detail="hiring_manager_id is required") + await JobAssignments.close_current(self.session,job_uid,role) + return None + user_uid=JobAssignments._as_uuid(user_id) + if not user_uid: + raise HTTPException(status_code=422,detail="Invalid user_id") + if current and str(current[0].user_id)==str(user_uid): + return current[0] + await JobAssignments.close_current(self.session,job_uid,role) + return await JobAssignments.insert_assignment(self.session,{ + "job_post_id":job_uid, + "user_id":user_uid, + "assignment_role":role, + "assigned_by":by_uid, + }) + + async def list_job_assignments(self,job_post_id,current_only=True,assignment_role=None): if not job_post_id: raise HTTPException(status_code=400,detail="job_post_id is required") - rows=await JobAssignments.fetch_by_job(self.session,job_post_id) - return [serialize_job_assignment(r) for r in rows] + role=self._job_role(assignment_role) if assignment_role else None + rows=await JobAssignments.fetch_by_job( + self.session,job_post_id,current_only=current_only,assignment_role=role, + ) + names=await Users.names_by_ids( + self.session,[r.user_id for r in rows]+[r.assigned_by for r in rows], + ) + return [serialize_job_assignment(r,names=names) for r in rows] async def create_job_assignment(self,payload,current_user): user_id=payload.get("user_id") job_post_id=payload.get("job_post_id") if not user_id or not job_post_id: raise HTTPException(status_code=422,detail="user_id and job_post_id are required") - await self._require_recruiter(user_id) - fields={ - "job_post_id":JobAssignments._as_uuid(job_post_id), - "user_id":JobAssignments._as_uuid(user_id), - "assignment_role":payload.get("assignment_role") or "primary_recruiter", - "assigned_by":JobAssignments._as_uuid( - current_user.get("id") if isinstance(current_user,dict) else None - ), - } - if not fields["job_post_id"] or not fields["user_id"] or not fields["assigned_by"]: - raise HTTPException(status_code=422,detail="Invalid job_post_id, user_id, or assigned_by") - row=await JobAssignments.insert_assignment(self.session,fields) - return serialize_job_assignment(row) + job=await JobPosts.get_job_post_by_id(self.session,job_post_id) + if not job or job.is_deleted: + raise HTTPException(status_code=404,detail="Job post not found") + role=self._job_role(payload.get("assignment_role")) + await self.require_role(user_id,JOB_ASSIGNMENT_ROLES[role],"user_id") + assigned_by=current_user.get("id") if isinstance(current_user,dict) else None + row=await self.record_job_owner(job_post_id,user_id,role,assigned_by) + column=JOB_OWNER_COLUMN[role] + await JobPosts.update_job_post(self.session,job_post_id,{column:user_id}) + names=await Users.names_by_ids( + self.session,[row.user_id,row.assigned_by] if row else [], + ) + return serialize_job_assignment(row,names=names) if row else None async def list_application_assignments(self,inbox_id): if inbox_id is None: raise HTTPException(status_code=400,detail="inbox_id is required") rows=await ApplicationAssignments.fetch_by_inbox(self.session,int(inbox_id)) - return [serialize_application_assignment(r) for r in rows] + names=await Users.names_by_ids( + self.session,[r.user_id for r in rows]+[r.assigned_by for r in rows], + ) + return [serialize_application_assignment(r,names=names) for r in rows] async def create_application_assignment(self,payload,current_user): user_id=payload.get("user_id") inbox_id=payload.get("inbox_id") if not user_id or inbox_id is None: raise HTTPException(status_code=422,detail="user_id and inbox_id are required") - await self._require_recruiter(user_id) + await self.require_role(user_id,EnumRoles.RECRUITER,"user_id") fields={ "inbox_id":int(inbox_id), "user_id":ApplicationAssignments._as_uuid(user_id), @@ -66,4 +137,5 @@ class Assignment: if not fields["user_id"] or not fields["assigned_by"]: raise HTTPException(status_code=422,detail="Invalid user_id or assigned_by") row=await ApplicationAssignments.insert_assignment(self.session,fields) - return serialize_application_assignment(row) + names=await Users.names_by_ids(self.session,[row.user_id,row.assigned_by]) + return serialize_application_assignment(row,names=names) diff --git a/backend/job/job_post/export.py b/backend/job/job_post/export.py index 17d9199..0666f1d 100644 --- a/backend/job/job_post/export.py +++ b/backend/job/job_post/export.py @@ -36,6 +36,7 @@ COLUMNS = [ ("Status", 10), ("Publishing", 12), ("Recruiter", 18), + ("Hiring Manager", 18), ("Created By", 18), ("Created", 13), ("Requirements", 46), @@ -126,6 +127,7 @@ def build_jobs_workbook(rows) -> bytes: STATUS_LABELS.get(status_key, status_key), row.get("status") or "", row.get("recruiter_name") or "", + row.get("hiring_manager_name") or "", row.get("created_by_name") or "", _created(row), _bullets(row.get("requirements")), @@ -145,7 +147,7 @@ def build_jobs_workbook(rows) -> bytes: status_cell.alignment = center if status_key in STATUS_COLORS: status_cell.font = Font(bold=True, color=STATUS_COLORS[status_key]) - created_cell = ws.cell(row=r, column=13) + created_cell = ws.cell(row=r, column=14) if created_cell.value is not None: created_cell.number_format = "dd mmm yyyy" for c in (14, 15, 16): diff --git a/backend/job/job_post/models.py b/backend/job/job_post/models.py index 6765169..52f0fe2 100644 --- a/backend/job/job_post/models.py +++ b/backend/job/job_post/models.py @@ -20,12 +20,12 @@ class JobPosts(SQLModel, table=True): id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) title: str = Field(index=True) - # foreign_keys is required, not decoration: current_recruiter_id below is a - # SECOND foreign key into users.id, so the join condition is ambiguous without - # it and every mapper fails to initialize. `user` is the AUTHOR of the post — - # current_recruiter_id is deliberately a bare column with no relationship of - # its own, because Users already carries five selectin relations that load on - # every authenticated request. Same pairing as Notes.user / Notes.author. + # foreign_keys is required, not decoration: current_recruiter_id and + # hiring_manager_id below are extra FKs into users.id, so the join is + # ambiguous without it and every mapper fails to initialize. `user` is the + # AUTHOR of the post. The recruiter and hiring-manager columns stay bare — + # Users already carries five selectin relations that load on every + # authenticated request. Same pairing as Notes.user / Notes.author. user: Optional["Users"] = Relationship( back_populates="job_posts", sa_relationship_kwargs={"lazy": "joined", "foreign_keys": "[JobPosts.created_by]"}, @@ -56,7 +56,12 @@ class JobPosts(SQLModel, table=True): department: str = Field(default="", sa_column_kwargs={"server_default": ""}) vacancies: int = Field(default=1, sa_column_kwargs={"server_default": "1"}) closed_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) + # Who is working the req now (swappable). History lives in job_assignments + # with assignment_role=primary_recruiter; this column is the current pointer. current_recruiter_id: uuid.UUID | None = Field(default=None, foreign_key="users.id") + # Who owns the requisition (stable). Required at create. History lives in + # job_assignments with assignment_role=hiring_manager. + hiring_manager_id: uuid.UUID | None = Field(default=None, foreign_key="users.id", index=True) created_by: uuid.UUID = Field(foreign_key="users.id") created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) @@ -136,6 +141,7 @@ class JobPosts(SQLModel, table=True): department: str | None = None, requisition_status: str | None = None, employment_type: str | None = None, + hiring_manager_id: uuid.UUID | None = None, ): if ids: rows = await cls.get_by_ids(session, ids, active_only=active_only) @@ -157,6 +163,8 @@ class JobPosts(SQLModel, table=True): statement = statement.where(cls.requisition_status == requisition_status) if employment_type: statement = statement.where(cls.employment_type == employment_type) + if hiring_manager_id is not None: + statement = statement.where(cls.hiring_manager_id == hiring_manager_id) 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()) @@ -185,6 +193,24 @@ class JobPosts(SQLModel, table=True): result = await session.execute(statement) return list(result.scalars().all()) + @classmethod + async def count_open_reqs_by_hiring_managers(cls, session: AsyncSession, user_ids): + """Open requisitions per hiring manager, keyed by users.id.""" + uids = [u for u in (user_ids or []) if u] + if not uids: + return {} + statement = ( + select(cls.hiring_manager_id, func.count()) + .where( + cls.hiring_manager_id.in_(uids), + cls.requisition_status == "open", + cls.is_deleted == False, # noqa: E712 + ) + .group_by(cls.hiring_manager_id) + ) + result = await session.execute(statement) + return {uid: int(n or 0) for uid, n in result.all()} + @classmethod async def insert_job_post(cls, session: AsyncSession, fields: dict): row = cls(**fields) diff --git a/backend/job/job_post/serializers.py b/backend/job/job_post/serializers.py index 92c6e61..17b97ef 100644 --- a/backend/job/job_post/serializers.py +++ b/backend/job/job_post/serializers.py @@ -29,7 +29,7 @@ def serialize_job_post(row) -> dict: } -def serialize_job_row(row, *, recruiter_name=None, applicant_count=0) -> dict: +def serialize_job_row(row, *, recruiter_name=None, hiring_manager_name=None, applicant_count=0) -> dict: """Requisition view of a job post, for the Jobs screen. Deliberately separate from serialize_job_post: that payload is shared by the @@ -58,6 +58,8 @@ def serialize_job_row(row, *, recruiter_name=None, applicant_count=0) -> dict: "closed_at": row.closed_at.isoformat() if row.closed_at else None, "current_recruiter_id": str(row.current_recruiter_id) if row.current_recruiter_id else None, "recruiter_name": recruiter_name, + "hiring_manager_id": str(row.hiring_manager_id) if row.hiring_manager_id else None, + "hiring_manager_name": hiring_manager_name, "applicant_count": applicant_count, "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, diff --git a/backend/job/job_post/views.py b/backend/job/job_post/views.py index 21f6414..18c06d0 100644 --- a/backend/job/job_post/views.py +++ b/backend/job/job_post/views.py @@ -3,6 +3,7 @@ import logging import os import uuid from pathlib import Path +from uuid import UUID import httpx from dotenv import load_dotenv @@ -10,7 +11,9 @@ from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession from pydantic import BaseModel, model_validator from inbox.models import Inbox_Messages +from job.assignment.views import Assignment from job.job_post.models import JobPostImages,JobPosts,SocialPlatform +from role.models import EnumRoles from users.models import Users from job.job_post.plugins import ( BufferError, @@ -60,6 +63,8 @@ class JobPostCreate(BaseModel): scheduler_time: time | None = time(0, 0, 0) scheduler_date: date | None = None due_at: str | None = None + hiring_manager_id: UUID + current_recruiter_id: UUID | None = None @model_validator(mode="after") def validate_mode_and_due_at(self): @@ -138,7 +143,24 @@ class JobPost: # Column default is "linkedin"; an unpublished requisition must not # masquerade as a LinkedIn post. fields["platform"]="internal" + + assignment=Assignment(self.session) + hm=await assignment.require_role( + payload.get("hiring_manager_id"),EnumRoles.HIRING_MANAGER,"hiring_manager_id", + ) + fields["hiring_manager_id"]=hm.id + rec=None + if payload.get("current_recruiter_id"): + rec=await assignment.require_role( + payload.get("current_recruiter_id"),EnumRoles.RECRUITER,"current_recruiter_id", + ) + fields["current_recruiter_id"]=rec.id + row=await JobPosts.insert_job_post(self.session,fields) + assigned_by=current_user.get("id") if isinstance(current_user,dict) else None + await assignment.record_job_owner(row.id,hm.id,"hiring_manager",assigned_by) + if rec: + await assignment.record_job_owner(row.id,rec.id,"primary_recruiter",assigned_by) if not publish: return serialize_job_post(row) @@ -187,20 +209,27 @@ class JobPost: return await JobPosts.list_departments(self.session,active_only=active_only) async def fetch_jobs(self,search=None,department=None,requisition_status=None, - employment_type=None,top=None,skip=0,active_only=True): + employment_type=None,hiring_manager_id=None,top=None,skip=0,active_only=True): + hm_uid=None + if hiring_manager_id: + hm_uid=JobPosts._as_uuid(hiring_manager_id) + if hm_uid is None: + raise HTTPException(status_code=422,detail="hiring_manager_id must be a UUID") rows,total=await JobPosts.fetch_job_posts( self.session,search=search,top=top,skip=skip,active_only=active_only, department=department,requisition_status=requisition_status, - employment_type=employment_type, + employment_type=employment_type,hiring_manager_id=hm_uid, ) names=await Users.names_by_ids( - self.session,[r.current_recruiter_id for r in rows], + self.session, + [r.current_recruiter_id for r in rows]+[r.hiring_manager_id for r in rows], ) counts=await Inbox_Messages.counts_by_job_post_ids(self.session,[r.id for r in rows]) return [ serialize_job_row( r, recruiter_name=names.get(str(r.current_recruiter_id)), + hiring_manager_name=names.get(str(r.hiring_manager_id)), applicant_count=counts.get(str(r.id),0), ) for r in rows @@ -208,13 +237,21 @@ class JobPost: async def _job_row(self,row): names=await Users.names_by_ids( - self.session,[row.current_recruiter_id] if row.current_recruiter_id else [], + self.session, + [row.current_recruiter_id,row.hiring_manager_id], + ) + return serialize_job_row( + row, + recruiter_name=names.get(str(row.current_recruiter_id)), + hiring_manager_name=names.get(str(row.hiring_manager_id)), ) - return serialize_job_row(row,recruiter_name=names.get(str(row.current_recruiter_id))) async def update_job(self,job_post_id,payload,current_user): if not current_user: raise HTTPException(status_code=401,detail="Not authenticated") + existing=await JobPosts.get_job_post_by_id(self.session,job_post_id) + if not existing or existing.is_deleted: + raise HTTPException(status_code=404,detail="Job post not found") allowed=("title","department","location","employment_type","vacancies", "salary","experience_min","experience_max","description") fields={k:payload[k] for k in allowed if k in payload} @@ -229,11 +266,41 @@ class JobPost: fields["salary"]=str(high) if "department" in fields and fields["department"] is None: fields["department"]="" + + assignment=Assignment(self.session) + assigned_by=current_user.get("id") if isinstance(current_user,dict) else None + hm_changed=False + rec_changed=False + if "hiring_manager_id" in payload: + raw=payload.get("hiring_manager_id") + if not raw: + raise HTTPException(status_code=422,detail="hiring_manager_id is required") + hm=await assignment.require_role(raw,EnumRoles.HIRING_MANAGER,"hiring_manager_id") + fields["hiring_manager_id"]=hm.id + hm_changed=str(existing.hiring_manager_id)!=str(hm.id) + if "current_recruiter_id" in payload: + raw=payload.get("current_recruiter_id") + if raw is None or raw=="": + fields["current_recruiter_id"]=None + rec_changed=existing.current_recruiter_id is not None + else: + rec=await assignment.require_role(raw,EnumRoles.RECRUITER,"current_recruiter_id") + fields["current_recruiter_id"]=rec.id + rec_changed=str(existing.current_recruiter_id)!=str(rec.id) + if not fields: raise HTTPException(status_code=400,detail="No fields to update") row=await JobPosts.update_job_post(self.session,job_post_id,fields) if not row: raise HTTPException(status_code=404,detail="Job post not found") + if hm_changed: + await assignment.record_job_owner( + job_post_id,fields["hiring_manager_id"],"hiring_manager",assigned_by, + ) + if rec_changed: + await assignment.record_job_owner( + job_post_id,fields.get("current_recruiter_id"),"primary_recruiter",assigned_by, + ) return await self._job_row(row) async def delete_job(self,job_post_id,current_user): diff --git a/backend/migrations/manual/015_job_post_hiring_manager.sql b/backend/migrations/manual/015_job_post_hiring_manager.sql new file mode 100644 index 0000000..9b984c5 --- /dev/null +++ b/backend/migrations/manual/015_job_post_hiring_manager.sql @@ -0,0 +1,12 @@ +-- 015_job_post_hiring_manager.sql +-- Stable owner of a requisition. Distinct from current_recruiter_id (who is +-- working the req now, and may change). Both people also get a job_assignments +-- history row; this column is the current pointer used by Jobs lists and the +-- Managers portal. Applied at startup by alembic_setup.run_manual_sql(). +-- Needed because prod boots with DB_AUTOGENERATE=false. + +ALTER TABLE app.job_posts + ADD COLUMN IF NOT EXISTS hiring_manager_id UUID REFERENCES app.users(id); + +CREATE INDEX IF NOT EXISTS ix_job_posts_hiring_manager_id + ON app.job_posts (hiring_manager_id); diff --git a/backend/users/app.py b/backend/users/app.py index 47d5bbd..b540928 100644 --- a/backend/users/app.py +++ b/backend/users/app.py @@ -246,7 +246,12 @@ async def delete_user( @router.get("/managers/fetch") async def fetch_managers( current_user: dict = Depends( - require_permission(PermissionTag.JOBS_VIEW, PermissionTag.CANDIDATES_VIEW, require_all=False) + require_permission( + PermissionTag.JOBS_VIEW, + PermissionTag.CANDIDATES_VIEW, + PermissionTag.JOB_BOARD_CREATE, + require_all=False, + ) ), session: AsyncSession = Depends(get_session), ): diff --git a/backend/users/models.py b/backend/users/models.py index 53b8500..3eff965 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -33,8 +33,8 @@ 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. - # foreign_keys must match the other side: job_posts.current_recruiter_id is a - # second FK into this table, so this relation has to say it means created_by. + # foreign_keys must match the other side: job_posts also has current_recruiter_id + # and hiring_manager_id into this table, so this relation has to say created_by. job_posts: List[JobPosts] = Relationship( back_populates="user", sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[JobPosts.created_by]"}, diff --git a/backend/users/views.py b/backend/users/views.py index 4abd595..319db4e 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -114,13 +114,13 @@ class User: async def get_managers(self): """Hiring-manager directory for Jobs/Candidates callers who do not hold rbac_users.view.""" - from job.assignment.models import JobAssignments + from job.job_post.models import JobPosts role=await Roles.get_role_by_name(self.session,EnumRoles.HIRING_MANAGER.value) if role is None: raise HTTPException(status_code=500,detail="Role hiring_manager is not seeded") rows=await Users.get_users(self.session,top=500,role_id=role.id) - counts=await JobAssignments.count_open_reqs_by_users(self.session,[u.id for u in rows]) + counts=await JobPosts.count_open_reqs_by_hiring_managers(self.session,[u.id for u in rows]) data=[ { "id": str(u.id), diff --git a/frontend/src/api/assignments.js b/frontend/src/api/assignments.js index ab0a530..78ce874 100644 --- a/frontend/src/api/assignments.js +++ b/frontend/src/api/assignments.js @@ -4,23 +4,28 @@ import { request } from '../lib/apiClient' assignments.js — who owns a requisition, and who owns an application. Two parallel tables behind four routes (backend/job/app.py): - job_assignments — a recruiter on a JOB POST (jobs.view / jobs.edit) - application_assignments — a recruiter on ONE APPLICATION (candidates.view / candidates.edit) + job_assignments — recruiter OR hiring manager on a JOB POST + application_assignments — a recruiter on ONE APPLICATION Rows are valid-time intervals: `valid_to === null` is the assignment in force - now, and the fetch routes return only those by default. There is no unassign - or reassign route — `insert_assignment` closes the previous open interval and - opens a new one, so assigning someone else IS the reassignment. + now. Fetch defaults to current-only; pass currentOnly: false for the history + log. Reassignment closes the previous open interval of the SAME role. - The server rejects any user whose role is not `recruiter` with a 422 - (Assignment._require_recruiter), which is why every picker here is sourced - from /tasks/assignees/fetch — the one endpoint that already returns exactly - the active recruiter-role users, and needs no rbac_users.view to call. + Recruiter pickers use /tasks/assignees/fetch; hiring-manager pickers use + /managers/fetch. Neither needs rbac_users.view. The current pointers also + live on job_posts (current_recruiter_id, hiring_manager_id) and PATCH + /jobs/update is the Jobs-screen write path. ============================================================ */ -/** Current recruiter(s) on one requisition. */ -export function listJob(jobPostId) { - return request('/job/assignments/fetch', { params: { job_post_id: jobPostId } }) +/** Current or historical owners of one requisition. */ +export function listJob(jobPostId, { currentOnly, assignmentRole } = {}) { + return request('/job/assignments/fetch', { + params: { + job_post_id: jobPostId, + current_only: currentOnly, + assignment_role: assignmentRole, + }, + }) } /** Assign a recruiter to a requisition. Supersedes whoever held it. */ @@ -60,7 +65,8 @@ export function toAssignmentView(row, namesById) { return { id: row.id, userId: row.user_id, - name: namesById?.get(String(row.user_id)) ?? null, + name: row.user_name || namesById?.get(String(row.user_id)) || null, + assignedByName: row.assigned_by_name ?? null, role: row.assignment_role || 'primary_recruiter', jobPostId: row.job_post_id ?? null, inboxId: row.inbox_id ?? null, diff --git a/frontend/src/api/jobs.js b/frontend/src/api/jobs.js index 4a268ea..bf6fa61 100644 --- a/frontend/src/api/jobs.js +++ b/frontend/src/api/jobs.js @@ -9,13 +9,14 @@ import { downloadFile, fetchBlobUrl, request } from '../lib/apiClient' * picker payload does not. */ export function list({ search, department, requisitionStatus, employmentType, - top, skip, activeOnly } = {}) { + hiringManagerId, top, skip, activeOnly } = {}) { return request('/jobs/fetch', { params: { search, department, requisition_status: requisitionStatus, employment_type: employmentType, + hiring_manager_id: hiringManagerId, top, skip, active_only: activeOnly, @@ -48,6 +49,8 @@ export function toJobView(row) { publishStatus: row.status, recruiter: row.recruiter_name, recruiterId: row.current_recruiter_id, + hiringManager: row.hiring_manager_name, + hiringManagerId: row.hiring_manager_id, createdByName: row.created_by_name, applicantCount: row.applicant_count ?? 0, // A real Date, so the existing `sortValue: j => j.created.getTime()` keeps working. diff --git a/frontend/src/api/users.js b/frontend/src/api/users.js index a0c112a..82626cb 100644 --- a/frontend/src/api/users.js +++ b/frontend/src/api/users.js @@ -9,6 +9,10 @@ export function list({ record_id, search, top, skip, roleId } = {}) { return request('/users/fetch', { params: { record_id, search, top, skip, role_id: roleId } }) } +export function listManagers() { + return request('/managers/fetch') +} + export function listPendingApprovals() { return request('/users/pending-approvals') } diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index a8907f1..9d845ec 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -54,6 +54,7 @@ export const qk = { managers: { all: () => ['managers'], list: (p = {}) => ['managers', 'list', p], + directory: () => ['managers', 'directory'], }, orgSettings: { all: () => ['orgSettings'], diff --git a/frontend/src/screens/Jobs.jsx b/frontend/src/screens/Jobs.jsx index 64a8e64..be5b9ae 100644 --- a/frontend/src/screens/Jobs.jsx +++ b/frontend/src/screens/Jobs.jsx @@ -26,6 +26,7 @@ import * as jobsApi from '../api/jobs' import * as jobPostsApi from '../api/jobPosts' import * as assignmentsApi from '../api/assignments' import * as tasksApi from '../api/tasks' +import * as usersApi from '../api/users' import { JOB_STATUSES } from '../api/jobs' import { empTypes, fmtShort } from '../data/seed' @@ -183,7 +184,7 @@ export default function Jobs() { if (type && j.type !== type) return false if (q) { const term = q.toLowerCase() - const hay = [j.title, j.department, j.recruiter, j.location] + const hay = [j.title, j.department, j.recruiter, j.hiringManager, j.location] .filter(Boolean) .join(' ') .toLowerCase() @@ -226,6 +227,8 @@ export default function Jobs() { { key: 'platform', label: 'Platform', sortable: true, sortValue: (j) => platformLabel(j.platform), render: (j) => j.platform ? {platformLabel(j.platform)} : '—' }, { key: 'vacancies', label: 'Vacancies', sortable: true, align: 'center', render: (j) => {j.vacancies ?? '—'} }, { key: 'status', label: 'Status', sortable: true, render: (j) => {j.status} }, + { key: 'hiringManager', label: 'Hiring Manager', sortable: true, render: (j) => j.hiringManager || '—' }, + { key: 'recruiter', label: 'Recruiter', sortable: true, render: (j) => j.recruiter || '—' }, { key: 'created', label: 'Created', sortable: true, sortValue: (j) => (j.created ? j.created.getTime() : 0), @@ -374,9 +377,113 @@ const SECTION_LABEL = { const IMAGE_ACCEPT = 'image/png,image/jpeg,image/jpg,image/webp,image/gif,.png,.jpg,.jpeg,.webp,.gif' const MAX_IMAGE_MB = 5 +const ROLE_LABEL = { primary_recruiter: 'Recruiter', hiring_manager: 'Hiring manager' } + +/** + * Searchable picker: type to filter, click a row to store the id. + * Not free-text — the value is always an option id (or '' when allowEmpty). + */ +function SearchSelect({ + options = [], + value, + onChange, + placeholder = 'Search…', + disabled = false, + loading = false, + allowEmpty = false, + emptyLabel = 'Unassigned', + error = false, +}) { + const [q, setQ] = useState('') + const [open, setOpen] = useState(false) + const root = useRef(null) + const selected = options.find((o) => String(o.id) === String(value || '')) + + useEffect(() => { + function onDoc(e) { + if (root.current && !root.current.contains(e.target)) setOpen(false) + } + document.addEventListener('mousedown', onDoc) + return () => document.removeEventListener('mousedown', onDoc) + }, []) + + const term = q.trim().toLowerCase() + const filtered = options.filter((o) => { + if (!term) return true + const hay = [o.name, o.email, o.role_name].filter(Boolean).join(' ').toLowerCase() + return hay.includes(term) + }) + + return ( +
+ { setOpen(true); setQ('') }} + onChange={(e) => { setQ(e.target.value); setOpen(true) }} + /> + {open && !disabled && !loading && ( +
+ {allowEmpty && ( + + )} + {filtered.length === 0 && ( +
No matches
+ )} + {filtered.map((o) => ( + + ))} +
+ )} +
+ ) +} + +function useManagerDirectory() { + return useQuery({ + queryKey: qk.managers.directory(), + queryFn: async () => { + const res = await usersApi.listManagers() + return Array.isArray(res?.data) ? res.data : [] + }, + retry: false, + }) +} + +function useRecruiterDirectory() { + return useQuery({ + queryKey: qk.tasks.assignees(), + queryFn: async () => { + const res = await tasksApi.listAssignees() + return Array.isArray(res?.data) ? res.data : [] + }, + retry: false, + }) +} function JobForm({ departmentOptions, busy, onClose, onSubmit }) { + const managersQuery = useManagerDirectory() + const recruitersQuery = useRecruiterDirectory() const form = useFormState({ + hiring_manager_id: '', + current_recruiter_id: '', title: '', department: '', location: '', @@ -432,6 +539,7 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) { const v = form.values const errors = {} if (!v.title.trim()) errors.title = 'Job title is required' + if (!v.hiring_manager_id) errors.hiring_manager_id = 'Hiring manager is required' const vacancies = Number(v.vacancies) if (!Number.isFinite(vacancies) || vacancies < 1) errors.vacancies = 'Vacancies must be at least 1' const expMin = v.experience_min === '' ? null : Number(v.experience_min) @@ -462,6 +570,8 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) { requirements: splitLines(v.requirements), optional_skills: splitLines(v.optional_skills), description: v.description.trim() || null, + hiring_manager_id: v.hiring_manager_id, + current_recruiter_id: v.current_recruiter_id || undefined, }, imageFile) } @@ -522,6 +632,39 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) { {form.errors.title} +
+ + form.setField('hiring_manager_id', id)} + placeholder="Search hiring managers…" + disabled={busy} + loading={managersQuery.isPending} + error={Boolean(form.errors.hiring_manager_id)} + /> + {form.errors.hiring_manager_id} + {managersQuery.isError && ( +

Could not load hiring managers.

+ )} +
+
+ + form.setField('current_recruiter_id', id)} + placeholder="Search recruiters…" + disabled={busy} + loading={recruitersQuery.isPending} + allowEmpty + emptyLabel="Unassigned" + /> + {recruitersQuery.isError && ( +

Recruiter list needs tasks.view — you can assign later.

+ )} +
+
@@ -671,6 +814,8 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) { } function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) { + const managersQuery = useManagerDirectory() + const recruitersQuery = useRecruiterDirectory() const form = useFormState({ title: j.title || '', department: j.department || '', @@ -680,6 +825,8 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) { experience_min: j.experienceMin != null ? String(j.experienceMin) : '', experience_max: j.experienceMax != null ? String(j.experienceMax) : '', description: j.description || '', + hiring_manager_id: j.hiringManagerId || '', + current_recruiter_id: j.recruiterId || '', }) const assistContext = () => ({ @@ -707,10 +854,11 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) { function submit() { if (busy) return const title = form.values.title.trim() - if (!title) { - form.setErrors({ title: 'Job title is required' }) - return - } + const errors = {} + if (!title) errors.title = 'Job title is required' + if (!form.values.hiring_manager_id) errors.hiring_manager_id = 'Hiring manager is required' + form.setErrors(errors) + if (Object.keys(errors).length) return onSubmit({ title, department: form.values.department.trim() || null, @@ -720,6 +868,8 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) { experience_min: form.values.experience_min === '' ? null : Number(form.values.experience_min), experience_max: form.values.experience_max === '' ? null : Number(form.values.experience_max), description: form.values.description.trim() || null, + hiring_manager_id: form.values.hiring_manager_id, + current_recruiter_id: form.values.current_recruiter_id || null, }) } @@ -748,6 +898,32 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) { form.setField('title', e.target.value)} disabled={busy} /> {form.errors.title}
+
+ + form.setField('hiring_manager_id', id)} + placeholder="Search hiring managers…" + disabled={busy} + loading={managersQuery.isPending} + error={Boolean(form.errors.hiring_manager_id)} + /> + {form.errors.hiring_manager_id} +
+
+ + form.setField('current_recruiter_id', id)} + placeholder="Search recruiters…" + disabled={busy} + loading={recruitersQuery.isPending} + allowEmpty + emptyLabel="Unassigned" + /> +
@@ -796,116 +972,117 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) { } /** - * Recruiter ownership of one requisition — GET/POST /job/assignments/*. - * - * Rows are valid-time intervals and the fetch returns only the OPEN one, so - * "the assigned recruiter" is simply the first row back. There is no unassign - * route: posting a new assignment closes the previous interval, which is why - * the control is a picker with a Save rather than an assign/remove pair. - * - * The picker is /tasks/assignees/fetch because the server rejects any - * non-recruiter with a 422, and that endpoint returns exactly the active - * recruiter-role users without needing rbac_users.view. + * Hiring-manager + recruiter pointers on one requisition. + * Writes go through PATCH /jobs/update; job_assignments keeps the interval log. */ -function RecruiterAssignment({ jobPostId, fallbackName, canEdit }) { +function JobOwnership({ job, canEdit }) { const { toast } = useToast() const qc = useQueryClient() - const [picked, setPicked] = useState('') + const managersQuery = useManagerDirectory() + const recruitersQuery = useRecruiterDirectory() - const assigneesQuery = useQuery({ - queryKey: qk.tasks.assignees(), + const historyQuery = useQuery({ + queryKey: qk.assignments.job(job.id), queryFn: async () => { - const res = await tasksApi.listAssignees() - return Array.isArray(res?.data) ? res.data : [] - }, - retry: false, - }) - - const namesById = useMemo(() => { - const map = new Map() - for (const u of assigneesQuery.data ?? []) map.set(String(u.id), u.name) - return map - }, [assigneesQuery.data]) - - const currentQuery = useQuery({ - queryKey: qk.assignments.job(jobPostId), - queryFn: async () => { - const res = await assignmentsApi.listJob(jobPostId) + const res = await assignmentsApi.listJob(job.id, { currentOnly: false }) const rows = Array.isArray(res?.data) ? res.data : [] - return rows.map((r) => assignmentsApi.toAssignmentView(r, namesById)) + return rows.map((r) => assignmentsApi.toAssignmentView(r)) }, - enabled: Boolean(jobPostId), + enabled: Boolean(job.id), retry: false, }) - const current = currentQuery.data?.[0] ?? null - - const assign = useMutation({ - mutationFn: (userId) => assignmentsApi.assignJob({ jobPostId, userId }), + const patch = useMutation({ + mutationFn: (body) => jobsApi.update(job.id, body), onSuccess: () => { - qc.invalidateQueries({ queryKey: qk.assignments.job(jobPostId) }) + qc.invalidateQueries({ queryKey: qk.assignments.job(job.id) }) qc.invalidateQueries({ queryKey: qk.jobs.all() }) - setPicked('') - toast('Recruiter assigned', 'success') + qc.invalidateQueries({ queryKey: qk.managers.all() }) + toast('Assignment updated', 'success') }, - onError: (err) => toast(friendlyAuthError(err, 'Could not assign the recruiter.'), 'error'), + onError: (err) => toast(friendlyAuthError(err, 'Could not update the assignment.'), 'error'), }) - /* current.name resolves only once the assignee list has loaded; the - requisition's own recruiter_name is the fallback until then. */ - const currentName = current?.name - || (current ? namesById.get(String(current.userId)) : null) - || fallbackName - || null + const history = historyQuery.data ?? [] return ( <>
-
Recruiter ownership
- {currentQuery.isError ? ( -

- {friendlyAuthError(currentQuery.error, 'Assignments did not load.')} - {' '}Needs the jobs.view permission. -

- ) : ( -

- {currentQuery.isPending - ? 'Loading…' - : currentName - ? <>Owned by {currentName}{current?.role ? ` · ${current.role.replace(/_/g, ' ')}` : ''} - : 'No recruiter assigned yet.'} -

+
Ownership
+
+
+ + {canEdit ? ( + { + if (!id || id === String(job.hiringManagerId || '')) return + patch.mutate({ hiring_manager_id: id }) + }} + placeholder="Search hiring managers…" + disabled={patch.isPending} + loading={managersQuery.isPending} + /> + ) : ( +

{job.hiringManager || '—'}

+ )} +
+
+ + {canEdit ? ( + { + const next = id || null + if (String(next || '') === String(job.recruiterId || '')) return + patch.mutate({ current_recruiter_id: next }) + }} + placeholder="Search recruiters…" + disabled={patch.isPending} + loading={recruitersQuery.isPending} + allowEmpty + emptyLabel="Unassigned" + /> + ) : ( +

{job.recruiter || 'No recruiter assigned yet.'}

+ )} +
+
+ {canEdit && recruitersQuery.isError && ( +

The recruiter list needs the tasks.view permission.

)} - {canEdit && !currentQuery.isError && ( -
- - -
- )} - {canEdit && assigneesQuery.isError && ( +
Assignment history
+ {historyQuery.isError ? (

- The recruiter list needs the tasks.view permission. + {friendlyAuthError(historyQuery.error, 'History did not load.')}

+ ) : historyQuery.isPending ? ( +

Loading…

+ ) : history.length === 0 ? ( +

No assignment history yet.

+ ) : ( +
+ {history.map((row) => ( +
+
+
{row.name || 'Unknown'}
+
+ {[ + ROLE_LABEL[row.role] || row.role?.replace(/_/g, ' '), + row.validFrom ? fmtShort(row.validFrom) : null, + row.validTo ? `→ ${fmtShort(row.validTo)}` : 'current', + row.assignedByName ? `by ${row.assignedByName}` : null, + ].filter(Boolean).join(' · ')} +
+
+ {!row.validTo && Current} +
+ ))} +
)}
@@ -992,11 +1169,12 @@ function JobDetail({
Experience
{j.experience || '—'}
Created
{j.created ? fmtShort(j.created) : '—'}
Created by
{j.createdByName || '—'}
+
Hiring Manager
{j.hiringManager || '—'}
Assigned Recruiter
{j.recruiter || '—'}
Closed at
{j.closedAt ? fmtShort(j.closedAt) : '—'}
- + {j.description && ( <> diff --git a/frontend/src/screens/Managers.jsx b/frontend/src/screens/Managers.jsx index c46d068..83dbb17 100644 --- a/frontend/src/screens/Managers.jsx +++ b/frontend/src/screens/Managers.jsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { useMutation, useQuery } from '@tanstack/react-query' @@ -53,6 +53,16 @@ export default function Managers() { const managers = managersQuery.data?.rows ?? [] const total = managersQuery.data?.total ?? 0 const jobs = jobsQuery.data ?? [] + const openByManager = useMemo(() => { + const map = {} + for (const j of jobs) { + if (j.hiringManagerId && j.status === 'Open') { + const key = String(j.hiringManagerId) + map[key] = (map[key] || 0) + 1 + } + } + return map + }, [jobs]) const totalReqs = jobs.filter((j) => j.status === 'Open').length const pages = Math.max(1, Math.ceil(total / pageSize)) const currentPage = Math.min(page, pages) @@ -100,7 +110,7 @@ export default function Managers() {
-
{m.openReqs}Open Reqs
+
{openByManager[String(m.id)] ?? m.openReqs ?? 0}Open Reqs
{m.teamSize ?? '—'}Team Size
@@ -149,6 +159,16 @@ export default function Managers() { function ManagerDetail({ manager: m, jobs, canSend, onClose, navigate, toast }) { const [messaging, setMessaging] = useState(false) + const mineQuery = useQuery({ + queryKey: qk.jobs.list({ hiringManagerId: m.id }), + queryFn: async () => { + const res = await jobsApi.list({ hiringManagerId: m.id, top: 100 }) + const rows = Array.isArray(res?.data) ? res.data : [] + return rows.map(jobsApi.toJobView) + }, + }) + const mine = mineQuery.data ?? jobs.filter((j) => String(j.hiringManagerId) === String(m.id)) + const openMine = mine.filter((j) => j.status === 'Open') const send = useMutation({ mutationFn: (body) => inboxApi.sendEmail({ to: m.email, subject: body.subject, body: body.body, contentType: 'text' }), onError: (err) => toast(friendlyAuthError(err, 'Could not send the message.'), 'error'), @@ -234,8 +254,8 @@ function ManagerDetail({ manager: m, jobs, canSend, onClose, navigate, toast }) ) : ( <>
-
{m.openReqs}Open Reqs
-
{jobs.length}Open Jobs
+
{openMine.length}Open Reqs
+
{mine.length}Jobs
{m.email ? 'Yes' : '—'} Email on file @@ -259,14 +279,13 @@ function ManagerDetail({ manager: m, jobs, canSend, onClose, navigate, toast })

Open requisitions

-

- Jobs are not linked to a hiring-manager id yet, so this list is the current open requisitions rather than this manager's own. -

- {jobs.filter((j) => j.status === 'Open').length === 0 ? ( -

No open requisitions

+ {mineQuery.isPending ? ( +

Loading requisitions…

+ ) : openMine.length === 0 ? ( +

No open requisitions for this manager

) : ( - jobs.filter((j) => j.status === 'Open').slice(0, 8).map((j) => ( + openMine.slice(0, 8).map((j) => (
@@ -222,6 +290,14 @@ export default function RecruiterHub() {
Offer acceptance
+ {canViewTasks && ( +
+
+ {tasksLoading ? '—' : (tasks.length ? `${taskPct}%` : '—')} +
+
Tasks done
+
+ )}
@@ -245,6 +321,26 @@ export default function RecruiterHub() {
+ {canViewTasks && ( +
+ + + + +
+ )} + + {canViewTasks && ( + + )}
@@ -383,3 +479,95 @@ export default function RecruiterHub() {
) } + +function RecruiterTasks({ name, assigneeId, query, now, canEdit, toggling, onToggle }) { + const tasks = query.data ?? [] + const ranked = [...tasks].sort((a, b) => { + const aOver = !a.done && a.due && a.due < now + const bOver = !b.done && b.due && b.due < now + if (aOver !== bOver) return aOver ? -1 : 1 + if (a.done !== b.done) return a.done ? 1 : -1 + const aDue = a.due ? a.due.getTime() : Infinity + const bDue = b.due ? b.due.getTime() : Infinity + return aDue - bDue + }) + const preview = ranked.slice(0, TASK_PREVIEW) + const done = tasks.filter((t) => t.done).length + const pctDone = tasks.length ? Math.round((done / tasks.length) * 100) : 0 + + return ( +
+
+
+

Tasks

+ Assigned to {name} +
+ View all +
+
+
+ {query.isPending ? ( + Fetching this recruiter’s tasks. + ) : query.isError ? ( + + {friendlyAuthError(query.error, 'The server did not answer.')} + {' '}This list needs the tasks.view permission. + + ) : preview.length === 0 ? ( + + Create a task on the Tasks screen and assign it to {name}. + + ) : ( + <> + {preview.map((t) => { + const overdue = !t.done && t.due && t.due < now + return ( +
+ onToggle(t)} + role="checkbox" + aria-checked={t.done} + tabIndex={canEdit ? 0 : -1} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onToggle(t) + } + }} + > + + +
+
+ {t.title} +
+
+ {t.due ? `${overdue ? 'Overdue · ' : 'Due '}${fmtShort(t.due)}` : 'No due date'} +
+
+ {t.priority} +
+ ) + })} +
+ + + {done} of {tasks.length} + {ranked.length > TASK_PREVIEW ? ` · showing ${TASK_PREVIEW}` : ''} + {toggling ? ' · saving…' : ''} + +
+ + )} +
+
+
+ ) +} diff --git a/frontend/src/screens/Tasks.jsx b/frontend/src/screens/Tasks.jsx index 901b59c..5a5ee5b 100644 --- a/frontend/src/screens/Tasks.jsx +++ b/frontend/src/screens/Tasks.jsx @@ -13,7 +13,7 @@ ============================================================ */ import { useMemo, useState } from 'react' -import { useNavigate } from 'react-router-dom' +import { useNavigate, useSearchParams } from 'react-router-dom' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import Modal from '../ui/Modal' @@ -54,10 +54,12 @@ export default function Tasks() { const { toast } = useToast() const { can, user } = useAuth() const navigate = useNavigate() + const [searchParams] = useSearchParams() const qc = useQueryClient() const canCreate = can('tasks.create') && CREATOR_ROLES.includes(user?.role_name) const canEdit = can('tasks.edit') + const assigneeFilter = searchParams.get('assignee') || '' const tasksQuery = useQuery({ queryKey: qk.tasks.list(), queryFn: fetchTasks }) const assigneesQuery = useQuery({ queryKey: qk.tasks.assignees(), queryFn: fetchAssignees }) @@ -73,17 +75,30 @@ export default function Tasks() { const now = new Date() const isOverdue = (t) => !t.done && t.due && t.due < now - const list = useMemo(() => { - if (filter === 'Open') return tasks.filter((t) => !t.done) - if (filter === 'Completed') return tasks.filter((t) => t.done) - if (filter === 'Overdue') return tasks.filter(isOverdue) - if (['High', 'Medium', 'Low'].includes(filter)) return tasks.filter((t) => t.priority === filter) - return tasks - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tasks, filter]) + const scoped = useMemo(() => { + if (!assigneeFilter) return tasks + return tasks.filter((t) => String(t.assigneeId) === String(assigneeFilter)) + }, [tasks, assigneeFilter]) - const openCount = tasks.filter((t) => !t.done).length - const overdueCount = tasks.filter(isOverdue).length + const assigneeLabel = useMemo(() => { + if (!assigneeFilter) return null + const fromTask = scoped.find((t) => t.assignee)?.assignee + if (fromTask) return fromTask + const fromPicker = (assigneesQuery.data ?? []).find((u) => String(u.id) === String(assigneeFilter)) + return fromPicker?.name || null + }, [assigneeFilter, scoped, assigneesQuery.data]) + + const list = useMemo(() => { + if (filter === 'Open') return scoped.filter((t) => !t.done) + if (filter === 'Completed') return scoped.filter((t) => t.done) + if (filter === 'Overdue') return scoped.filter(isOverdue) + if (['High', 'Medium', 'Low'].includes(filter)) return scoped.filter((t) => t.priority === filter) + return scoped + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scoped, filter]) + + const openCount = scoped.filter((t) => !t.done).length + const overdueCount = scoped.filter(isOverdue).length // Optimistic flip with rollback: the checkbox must not lag the click, but a // 403/422 must snap it back rather than lie. @@ -158,7 +173,11 @@ export default function Tasks() {
))}
+ {assigneeFilter && ( +
+ + From Recruiter Hub + {assigneeLabel ? ` · ${assigneeLabel}` : ''} + + +
+ )}
-- 2.40.1 From 9dadcb469845b438d7e58831e8d0cfb0e5672bfe Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Sun, 30 Aug 2026 22:01:43 +0500 Subject: [PATCH 3/5] dropdwon inserted --- frontend/src/screens/Jobs.jsx | 161 +++++++++++++++++++--------------- 1 file changed, 92 insertions(+), 69 deletions(-) diff --git a/frontend/src/screens/Jobs.jsx b/frontend/src/screens/Jobs.jsx index be5b9ae..e7aae69 100644 --- a/frontend/src/screens/Jobs.jsx +++ b/frontend/src/screens/Jobs.jsx @@ -15,6 +15,7 @@ import AiFieldAssist from '../ui/AiFieldAssist' import DataTable from '../ui/DataTable' import Modal from '../ui/Modal' import PageHeader from '../ui/PageHeader' +import { Tabs } from '../ui/Tabs' import { Badge, EmptyState, FieldError, Icon, SkeletonRows } from '../ui/primitives' import { useToast } from '../ui/Toast' import { useAuth } from '../auth/AuthContext' @@ -981,17 +982,6 @@ function JobOwnership({ job, canEdit }) { const managersQuery = useManagerDirectory() const recruitersQuery = useRecruiterDirectory() - const historyQuery = useQuery({ - queryKey: qk.assignments.job(job.id), - queryFn: async () => { - const res = await assignmentsApi.listJob(job.id, { currentOnly: false }) - const rows = Array.isArray(res?.data) ? res.data : [] - return rows.map((r) => assignmentsApi.toAssignmentView(r)) - }, - enabled: Boolean(job.id), - retry: false, - }) - const patch = useMutation({ mutationFn: (body) => jobsApi.update(job.id, body), onSuccess: () => { @@ -1003,8 +993,6 @@ function JobOwnership({ job, canEdit }) { onError: (err) => toast(friendlyAuthError(err, 'Could not update the assignment.'), 'error'), }) - const history = historyQuery.data ?? [] - return ( <>
@@ -1054,41 +1042,49 @@ function JobOwnership({ job, canEdit }) { {canEdit && recruitersQuery.isError && (

The recruiter list needs the tasks.view permission.

)} - -
Assignment history
- {historyQuery.isError ? ( -

- {friendlyAuthError(historyQuery.error, 'History did not load.')} -

- ) : historyQuery.isPending ? ( -

Loading…

- ) : history.length === 0 ? ( -

No assignment history yet.

- ) : ( -
- {history.map((row) => ( -
-
-
{row.name || 'Unknown'}
-
- {[ - ROLE_LABEL[row.role] || row.role?.replace(/_/g, ' '), - row.validFrom ? fmtShort(row.validFrom) : null, - row.validTo ? `→ ${fmtShort(row.validTo)}` : 'current', - row.assignedByName ? `by ${row.assignedByName}` : null, - ].filter(Boolean).join(' · ')} -
-
- {!row.validTo && Current} -
- ))} -
- )}
) } +function AssignmentHistory({ historyQuery }) { + const history = historyQuery.data ?? [] + + if (historyQuery.isError) { + return ( +

+ {friendlyAuthError(historyQuery.error, 'History did not load.')} +

+ ) + } + if (historyQuery.isPending) { + return

Loading…

+ } + if (history.length === 0) { + return

No assignment history yet.

+ } + return ( +
+ {history.map((row) => ( +
+
+
{row.name || 'Unknown'}
+
+ {[ + ROLE_LABEL[row.role] || row.role?.replace(/_/g, ' '), + row.validFrom ? fmtShort(row.validFrom) : null, + row.validTo ? `→ ${fmtShort(row.validTo)}` : 'current', + row.assignedByName ? `by ${row.assignedByName}` : null, + ].filter(Boolean).join(' · ')} +
+
+ {!row.validTo && Current} +
+ ))} +
+ ) +} + /* Cover image, when the post has one — fetched with the bearer token into an object URL, because a bare cannot carry auth headers. null (404) simply renders nothing. */ @@ -1113,6 +1109,18 @@ function JobCover({ jobId }) { function JobDetail({ job: j, canEdit, canDelete, statusBusy, deleteBusy, onClose, onPublish, onEdit, onStatus, onDelete, }) { + const [tab, setTab] = useState('details') + const historyQuery = useQuery({ + queryKey: qk.assignments.job(j.id), + queryFn: async () => { + const res = await assignmentsApi.listJob(j.id, { currentOnly: false }) + const rows = Array.isArray(res?.data) ? res.data : [] + return rows.map((r) => assignmentsApi.toAssignmentView(r)) + }, + enabled: Boolean(j.id), + retry: false, + }) + return (
-
-
Department
{j.department || '—'}
-
Location
{j.location || '—'}
-
Employment Type
{j.type || '—'}
-
Platform
{platformLabel(j.platform) || '—'}
-
Vacancies
{j.vacancies ?? '—'}
-
Experience
{j.experience || '—'}
-
Created
{j.created ? fmtShort(j.created) : '—'}
-
Created by
{j.createdByName || '—'}
-
Hiring Manager
{j.hiringManager || '—'}
-
Assigned Recruiter
{j.recruiter || '—'}
-
Closed at
{j.closedAt ? fmtShort(j.closedAt) : '—'}
-
+ - - - {j.description && ( + {tab === 'details' && ( <> -
-
-
Description
-

{j.description}

+
+
Department
{j.department || '—'}
+
Location
{j.location || '—'}
+
Employment Type
{j.type || '—'}
+
Platform
{platformLabel(j.platform) || '—'}
+
Vacancies
{j.vacancies ?? '—'}
+
Experience
{j.experience || '—'}
+
Created
{j.created ? fmtShort(j.created) : '—'}
+
Created by
{j.createdByName || '—'}
+
Hiring Manager
{j.hiringManager || '—'}
+
Assigned Recruiter
{j.recruiter || '—'}
+
Closed at
{j.closedAt ? fmtShort(j.closedAt) : '—'}
+ + + + {j.description && ( + <> +
+
+
Description
+

{j.description}

+
+ + )} + {!!(j.skills && j.skills.length) && ( +
+
Required Skills
+
{j.skills.map((s) => {s})}
+
+ )} )} - {!!(j.skills && j.skills.length) && ( -
-
Required Skills
-
{j.skills.map((s) => {s})}
-
- )} + + {tab === 'history' && } ) } -- 2.40.1 From 4b84ae4a307c93def3272799b8f6b3b7268ba622 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Sun, 30 Aug 2026 23:24:00 +0500 Subject: [PATCH 4/5] recruiterhub activity improvement --- backend/analytics/serializers.py | 3 +- backend/analytics/views.py | 512 ++++-------------- backend/inbox/models.py | 177 ++++++ backend/job/app.py | 40 +- backend/job/assignment/models.py | 3 +- backend/job/candidate/models.py | 174 +++++- backend/job/cost/models.py | 35 +- backend/job/interviews/views.py | 17 +- backend/job/job_post/enums.py | 47 ++ backend/job/job_post/export.py | 4 +- backend/job/job_post/models.py | 147 ++++- backend/job/job_post/serializers.py | 25 + backend/job/job_post/views.py | 36 +- .../manual/016_job_post_status_history.sql | 21 + backend/offer/models.py | 32 ++ backend/reports/runner.py | 1 + backend/users/models.py | 17 + frontend/src/api/interviews.js | 3 +- frontend/src/api/jobs.js | 26 +- frontend/src/lib/queryKeys.js | 2 + frontend/src/screens/Jobs.jsx | 161 ++++-- frontend/src/screens/RecruiterHub.jsx | 27 +- frontend/src/ui/DataTable.jsx | 5 +- 23 files changed, 1028 insertions(+), 487 deletions(-) create mode 100644 backend/job/job_post/enums.py create mode 100644 backend/migrations/manual/016_job_post_status_history.sql diff --git a/backend/analytics/serializers.py b/backend/analytics/serializers.py index 50ebb1f..8be2007 100644 --- a/backend/analytics/serializers.py +++ b/backend/analytics/serializers.py @@ -20,11 +20,12 @@ def serialize_source_count(source,count,source_id=None,spend=0.0) -> dict: } -def serialize_recruiter_row(user_id,name,hires,open_reqs,avg_time_to_hire) -> dict: +def serialize_recruiter_row(user_id,name,hires,open_reqs,avg_time_to_hire,completed=0) -> dict: return { "id": str(user_id) if user_id else None, "name": name, "hires": int(hires or 0), + "completed": int(completed or 0), "open_reqs": int(open_reqs or 0), "avg_time_to_hire": float(avg_time_to_hire) if avg_time_to_hire is not None else None, } diff --git a/backend/analytics/views.py b/backend/analytics/views.py index 6a4b994..17a5bc2 100644 --- a/backend/analytics/views.py +++ b/backend/analytics/views.py @@ -1,7 +1,5 @@ -import uuid from datetime import datetime,timedelta,timezone -from sqlalchemy import and_,func,or_,select from sqlalchemy.ext.asyncio import AsyncSession from analytics.serializers import ( @@ -14,22 +12,14 @@ from inbox.models import Inbox,Inbox_Messages,SourceChannels from job.assignment.models import JobAssignments from job.candidate.models import ApplicationStageTransitions,Interviews from job.cost.models import HiringCosts +from job.job_post.enums import RequisitionStatus from job.job_post.models import JobPosts from offer.models import Offers from org_settings.models import OrgSettings -from role.models import EnumRoles,Roles +from role.models import EnumRoles from users.models import Users -def _as_uuid(value): - if value in (None,""): - return None - try: - return uuid.UUID(str(value)) - except (TypeError,ValueError): - return None - - def _month_start(dt: datetime) -> datetime: return datetime(dt.year,dt.month,1,tzinfo=timezone.utc) @@ -75,220 +65,29 @@ def _month_key(dt): return datetime(dt.year,dt.month,1,tzinfo=timezone.utc) -def _days_expr(end_col,start_col): - return func.extract("epoch",end_col-start_col)/86400.0 - - class Analytics: def __init__(self,session:AsyncSession): self.session=session - async def _count_jobs(self,status,from_date=None,to_date=None,department=None,recruiter_id=None,*,closed_in_window=False): - statement=select(func.count()).select_from(JobPosts).where(JobPosts.is_deleted==False) # noqa: E712 - if status: - statement=statement.where(JobPosts.requisition_status==status) - if department: - statement=statement.where(JobPosts.department==department) - rid=_as_uuid(recruiter_id) - if rid is not None: - statement=statement.where(JobPosts.current_recruiter_id==rid) - if closed_in_window: - if from_date is not None: - statement=statement.where(JobPosts.closed_at>=from_date) - if to_date is not None: - statement=statement.where(JobPosts.closed_at=as_of), - JobPosts.requisition_status=="open", - ) - if department: - statement=statement.where(JobPosts.department==department) - rid=_as_uuid(recruiter_id) - if rid is not None: - statement=statement.where(JobPosts.current_recruiter_id==rid) - result=await self.session.execute(statement) - return int(result.scalar_one() or 0) - - async def _count_candidates(self,from_date=None,to_date=None,department=None,recruiter_id=None): - statement=( - select(func.count()) - .select_from(Inbox) - .join(Users,Inbox.user_id==Users.id) - .join(Roles,Users.role_id==Roles.id) - .where(Roles.role_name==EnumRoles.CANDIDATE.value) - ) - if from_date is not None: - statement=statement.where(Inbox.created_at>=from_date) - if to_date is not None: - statement=statement.where(Inbox.created_at=from_date) - if to_date is not None: - statement=statement.where(stamp=from_date) - if to_date is not None: - statement=statement.where(hired.valid_from=from_date) - if to_date is not None: - msg=msg.where(Inbox.created_at=from_date) - if to_date is not None: - statement=statement.where(hire.c.valid_from=from_date) - if to_date is not None: - statement=statement.where(JobPosts.closed_at=from_date) - if to_date is not None: - statement=statement.where(HiringCosts.incurred_at=today_start, - Interviews.interview_date=now, + closed_jobs_prior=await JobPosts.count_requisitions( + self.session,status="closed",department=department,recruiter_id=recruiter_id, + from_date=prior_from,to_date=prior_to,closed_in_window=True, ) - interviews_upcoming=int((await self.session.execute(upcoming_q)).scalar_one() or 0) - next_q=select(func.min(func.coalesce(Interviews.interview_time,Interviews.interview_date))).where( - Interviews.interview_status.ilike("scheduled"), - Interviews.interview_date>=now, + total_candidates=await Inbox.count_in_window( + self.session,window_from,window_to,department,recruiter_id, + ) + total_candidates_prior=await Inbox.count_in_window( + self.session,prior_from,prior_to,department,recruiter_id, + ) + + interviews_today=await Interviews.count_between( + self.session,today_start,tomorrow,recruiter_id=recruiter_id, + ) + interviews_upcoming=await Interviews.count_upcoming( + self.session,now,recruiter_id=recruiter_id, + ) + next_at=await Interviews.next_scheduled_at( + self.session,now,recruiter_id=recruiter_id, ) - next_at=(await self.session.execute(next_q)).scalar_one() next_interview_at=next_at.isoformat() if next_at else None - offers_accepted=await self._count_offers(["accepted"],window_from,window_to,department,recruiter_id) - offers_accepted_prior=await self._count_offers(["accepted"],prior_from,prior_to,department,recruiter_id) - offers_sent=await self._count_offers( - ["sent","negotiating","accepted","declined","expired"], - window_from,window_to,department,recruiter_id,exclude_draft=True, + offers_accepted=await Offers.count_in_window( + self.session,["accepted"],window_from,window_to,department,recruiter_id, ) - offers_sent_prior=await self._count_offers( - ["sent","negotiating","accepted","declined","expired"], - prior_from,prior_to,department,recruiter_id,exclude_draft=True, + offers_accepted_prior=await Offers.count_in_window( + self.session,["accepted"],prior_from,prior_to,department,recruiter_id, + ) + offers_sent=await Offers.count_in_window( + self.session,None,window_from,window_to,department,recruiter_id,exclude_draft=True, + ) + offers_sent_prior=await Offers.count_in_window( + self.session,None,prior_from,prior_to,department,recruiter_id,exclude_draft=True, ) hires=await self._count_hires(window_from,window_to,department,recruiter_id) hires_prior=await self._count_hires(prior_from,prior_to,department,recruiter_id) - time_to_hire=await self._avg_time_to_hire(window_from,window_to,department,recruiter_id) - time_to_hire_prior=await self._avg_time_to_hire(prior_from,prior_to,department,recruiter_id) - time_to_fill=await self._avg_time_to_fill(window_from,window_to,department,recruiter_id) - time_to_fill_prior=await self._avg_time_to_fill(prior_from,prior_to,department,recruiter_id) + time_to_hire=await ApplicationStageTransitions.avg_time_to_hire( + self.session,window_from,window_to,department,recruiter_id, + ) + time_to_hire_prior=await ApplicationStageTransitions.avg_time_to_hire( + self.session,prior_from,prior_to,department,recruiter_id, + ) + time_to_fill=await JobPosts.avg_time_to_fill( + self.session,window_from,window_to,department,recruiter_id, + ) + time_to_fill_prior=await JobPosts.avg_time_to_fill( + self.session,prior_from,prior_to,department,recruiter_id, + ) cost_per_hire=await self._cost_per_hire(hires,window_from,window_to,department,recruiter_id) - cost_per_hire_prior=await self._cost_per_hire(hires_prior,prior_from,prior_to,department,recruiter_id) + cost_per_hire_prior=await self._cost_per_hire( + hires_prior,prior_from,prior_to,department,recruiter_id, + ) # REQ-ANL-08: the time-to-hire baseline is an org setting with provenance # ({"days": N, "source": "..."}), never a constant — OPEN-12 flags the BRD's @@ -397,28 +210,10 @@ class Analytics: } async def get_funnel(self,from_date=None,to_date=None,department=None,recruiter_id=None): - statement=select( - Inbox_Messages.application_status, - func.count().label("count"), - ).select_from(Inbox_Messages) - if department or recruiter_id: - statement=statement.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id) - if department: - statement=statement.where(JobPosts.department==department) - rid=_as_uuid(recruiter_id) - if rid is not None: - statement=statement.where( - or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid) - ) - if from_date is not None or to_date is not None: - statement=statement.join(Inbox,Inbox.message_id==Inbox_Messages.id) - if from_date is not None: - statement=statement.where(Inbox.created_at>=from_date) - if to_date is not None: - statement=statement.where(Inbox.created_at=start) - .group_by(month_bucket) - .order_by(month_bucket) - ) - if department or recruiter_id: - apps_q=( - apps_q - .outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id) - .outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id) - ) - if department: - apps_q=apps_q.where(JobPosts.department==department) - rid=_as_uuid(recruiter_id) - if rid is not None: - apps_q=apps_q.where( - or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid) - ) - apps_rows=await self.session.execute(apps_q) apps_map={} - for month,count in apps_rows.all(): - apps_map[_month_key(month)]=int(count or 0) + for month,count in await Inbox.counts_by_month( + self.session,start,department=department,recruiter_id=recruiter_id, + ): + apps_map[_month_key(month)]=count - hire_bucket=func.date_trunc("month",ApplicationStageTransitions.valid_from) - hires_q=( - select(hire_bucket.label("month"),func.count().label("count")) - .select_from(ApplicationStageTransitions) - .where( - ApplicationStageTransitions.to_stage==Candidate_application_Status.HIRED.value, - ApplicationStageTransitions.valid_from>=start, - ) - .group_by(hire_bucket) - .order_by(hire_bucket) - ) - if department or recruiter_id: - hires_q=( - hires_q - .outerjoin(Inbox,ApplicationStageTransitions.inbox_id==Inbox.id) - .outerjoin(Inbox_Messages,Inbox.message_id==Inbox_Messages.id) - .outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id) - ) - if department: - hires_q=hires_q.where(JobPosts.department==department) - rid=_as_uuid(recruiter_id) - if rid is not None: - hires_q=hires_q.where( - or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid) - ) - hire_rows=await self.session.execute(hires_q) hire_map={} - for month,count in hire_rows.all(): - hire_map[_month_key(month)]=int(count or 0) + for month,count in await ApplicationStageTransitions.counts_hires_by_month( + self.session,start,department=department,recruiter_id=recruiter_id, + ): + hire_map[_month_key(month)]=count labels=[] applications=[] @@ -500,57 +250,18 @@ class Analytics: return {"labels": labels,"applications": applications,"hires": hires} async def get_source_performance(self,from_date=None,to_date=None,department=None,recruiter_id=None): - statement=( - select( - SourceChannels.id.label("source_id"), - func.coalesce(SourceChannels.label,"Unknown").label("source"), - func.count().label("count"), - ) - .select_from(Inbox_Messages) - .outerjoin(SourceChannels,Inbox_Messages.source_channel_id==SourceChannels.id) + rows=await Inbox_Messages.counts_by_source( + self.session,from_date=from_date,to_date=to_date, + department=department,recruiter_id=recruiter_id, ) - if department or recruiter_id: - statement=statement.outerjoin(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id) - if department: - statement=statement.where(JobPosts.department==department) - rid=_as_uuid(recruiter_id) - if rid is not None: - statement=statement.where( - or_(Inbox_Messages.recruiter_id==rid,JobPosts.current_recruiter_id==rid) - ) - if from_date is not None or to_date is not None: - statement=statement.join(Inbox,Inbox.message_id==Inbox_Messages.id) - if from_date is not None: - statement=statement.where(Inbox.created_at>=from_date) - if to_date is not None: - statement=statement.where(Inbox.created_at=from_date) - if to_date is not None: - spend_q=spend_q.where(HiringCosts.incurred_at=from_date) - if to_date is not None: - hires_q=hires_q.where(Inbox.created_at= from_date) + if to_date is not None: + statement = statement.where(cls.created_at < to_date) + statement = cls.scoped_to_job(statement, department, recruiter_id) + result = await session.execute(statement) + return int(result.scalar_one() or 0) + + @classmethod + async def counts_by_month( + cls, session: AsyncSession, start, department=None, recruiter_id=None, + ): + month_bucket = func.date_trunc("month", cls.created_at) + statement = ( + select(month_bucket.label("month"), func.count().label("count")) + .select_from(cls) + .where(cls.created_at >= start) + .group_by(month_bucket) + .order_by(month_bucket) + ) + statement = cls.scoped_to_job(statement, department, recruiter_id) + result = await session.execute(statement) + return [(month, int(count or 0)) for month, count in result.all()] + @classmethod async def get_inbox_by_id(cls,session:AsyncSession,record_id:int|str|None): if record_id is None: @@ -979,6 +1037,117 @@ class Inbox_Messages(SQLModel, table=True): await session.refresh(row) return row + @classmethod + async def count_hires_by_recruiter( + cls, session: AsyncSession, recruiter_id, *, from_date=None, to_date=None, department=None, + ): + """Messages currently HIRED and assigned to this recruiter_id.""" + try: + uid = uuid.UUID(str(recruiter_id)) + except (TypeError, ValueError): + return 0 + from job.job_post.models import JobPosts + + statement = select(func.count()).select_from(cls).where( + cls.recruiter_id == uid, + cls.application_status == Candidate_application_Status.HIRED, + ) + if department: + statement = statement.outerjoin(JobPosts, cls.assigned_job_post_id == JobPosts.id).where( + JobPosts.department == department + ) + if from_date is not None or to_date is not None: + statement = statement.join(Inbox, Inbox.message_id == cls.id) + if from_date is not None: + statement = statement.where(Inbox.created_at >= from_date) + if to_date is not None: + statement = statement.where(Inbox.created_at < to_date) + result = await session.execute(statement) + return int(result.scalar_one() or 0) + + @classmethod + def scoped_to_job(cls, statement, department=None, recruiter_id=None): + """Optional department / recruiter via assigned job post.""" + if not department and not recruiter_id: + return statement + from job.job_post.models import JobPosts + statement = statement.outerjoin(JobPosts, cls.assigned_job_post_id == JobPosts.id) + if department: + statement = statement.where(JobPosts.department == department) + try: + rid = uuid.UUID(str(recruiter_id)) if recruiter_id not in (None, "") else None + except (TypeError, ValueError): + rid = None + if rid is not None: + statement = statement.where( + or_(cls.recruiter_id == rid, JobPosts.current_recruiter_id == rid) + ) + return statement + + @classmethod + def _window_by_inbox(cls, statement, from_date=None, to_date=None): + if from_date is None and to_date is None: + return statement + statement = statement.join(Inbox, Inbox.message_id == cls.id) + if from_date is not None: + statement = statement.where(Inbox.created_at >= from_date) + if to_date is not None: + statement = statement.where(Inbox.created_at < to_date) + return statement + + @classmethod + async def count_hired( + cls, session: AsyncSession, from_date=None, to_date=None, department=None, recruiter_id=None, + ): + """Messages currently HIRED, windowed on inbox.created_at.""" + statement = ( + select(func.count()) + .select_from(cls) + .join(Inbox, Inbox.message_id == cls.id) + .where(cls.application_status == Candidate_application_Status.HIRED) + ) + if from_date is not None: + statement = statement.where(Inbox.created_at >= from_date) + if to_date is not None: + statement = statement.where(Inbox.created_at < to_date) + statement = cls.scoped_to_job(statement, department, recruiter_id) + result = await session.execute(statement) + return int(result.scalar_one() or 0) + + @classmethod + async def counts_by_application_status( + cls, session: AsyncSession, from_date=None, to_date=None, department=None, recruiter_id=None, + ): + statement = select(cls.application_status, func.count().label("count")).select_from(cls) + statement = cls.scoped_to_job(statement, department, recruiter_id) + statement = cls._window_by_inbox(statement, from_date, to_date) + statement = statement.group_by(cls.application_status) + result = await session.execute(statement) + counts = {} + for status, n in result.all(): + key = str(status.value if hasattr(status, "value") else status) + counts[key] = int(n or 0) + return counts + + @classmethod + async def counts_by_source( + cls, session: AsyncSession, from_date=None, to_date=None, department=None, recruiter_id=None, + ): + statement = ( + select( + SourceChannels.id.label("source_id"), + func.coalesce(SourceChannels.label, "Unknown").label("source"), + func.count().label("count"), + ) + .select_from(cls) + .outerjoin(SourceChannels, cls.source_channel_id == SourceChannels.id) + ) + statement = cls.scoped_to_job(statement, department, recruiter_id) + statement = cls._window_by_inbox(statement, from_date, to_date) + statement = statement.group_by(SourceChannels.id, SourceChannels.label).order_by(func.count().desc()) + result = await session.execute(statement) + return [(source_id, source, int(count or 0)) for source_id, source, count in result.all()] + class Inbox_Message_Triage(SQLModel, table=True): """One intake verdict per upstream message id — the gate before inbox_messages. @@ -1168,6 +1337,14 @@ class SourceChannels(SQLModel, table=True): ) return list(result.scalars().all()) + @classmethod + async def labels_by_ids(cls, session: AsyncSession, ids): + keys = [cid for cid in (ids or []) if cid is not None] + if not keys: + return [] + result = await session.execute(select(cls.id, cls.label).where(cls.id.in_(keys))) + return [(cid, label) for cid, label in result.all()] + class AtsResults(SQLModel, table=True): __tablename__ = "ats_results" diff --git a/backend/job/app.py b/backend/job/app.py index 73751ed..14928a7 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -752,6 +752,39 @@ async def fetch_job_departments( raise HTTPException(status_code=500,detail=str(e)) +@router.get("/jobs/requisition-statuses/fetch") +async def fetch_requisition_statuses( + current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)), + session: AsyncSession = Depends(get_session), +): + """Hiring-lifecycle tags for the Jobs status dropdown (open/on_hold/closed/completed).""" + try: + service=JobPost(session=session) + data=await service.fetch_requisition_statuses() + return JSONResponse(content={"data":data,"total":len(data),"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.get("/jobs/status-history/fetch") +async def fetch_job_status_history( + job_post_id:str=Query(...), + current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)), + session: AsyncSession = Depends(get_session), +): + """Who changed requisition_status on one job, from what, to what, and when.""" + try: + service=JobPost(session=session) + data=await service.fetch_status_history(job_post_id) + return JSONResponse(content={"data":data,"total":len(data),"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + @router.get("/jobs/fetch") async def fetch_jobs( search: str | None = Query(None), @@ -895,6 +928,7 @@ async def fetch_interview( from_date:datetime=Query(None), to_date:datetime=Query(None), status:str=Query(None), + recruiter_id:str=Query(None), top:int=Query(None), skip:int=Query(0,ge=0), current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)), @@ -902,14 +936,14 @@ async def fetch_interview( ): try: service=Interview(session=session) - if not interview_id and inbox_id is None and (from_date is not None or to_date is not None or status is not None or top is not None): + if not interview_id and inbox_id is None and (from_date is not None or to_date is not None or status is not None or recruiter_id or top is not None): data,total=await service.get_interviews_range( - from_date=from_date,to_date=to_date,status=status,top=top,skip=skip, + from_date=from_date,to_date=to_date,status=status,recruiter_id=recruiter_id,top=top,skip=skip, ) return JSONResponse(content={"data":data,"total":total,"status_code":200}) data=await service.get_interview( interview_id=interview_id,inbox_id=inbox_id, - from_date=from_date,to_date=to_date,status=status,top=top,skip=skip, + from_date=from_date,to_date=to_date,status=status,recruiter_id=recruiter_id,top=top,skip=skip, ) if isinstance(data,tuple): data,total=data diff --git a/backend/job/assignment/models.py b/backend/job/assignment/models.py index 9d19580..371a703 100644 --- a/backend/job/assignment/models.py +++ b/backend/job/assignment/models.py @@ -105,6 +105,7 @@ class JobAssignments(SQLModel, table=True): @classmethod async def count_open_reqs_by_users(cls, session: AsyncSession, user_ids): """Open requisitions per user: current assignments joined to open job_posts.""" + from job.job_post.enums import RequisitionStatus from job.job_post.models import JobPosts uids = [u for u in (user_ids or []) if u] @@ -117,7 +118,7 @@ class JobAssignments(SQLModel, table=True): .where( cls.user_id.in_(uids), cls.valid_to.is_(None), - JobPosts.requisition_status == "open", + JobPosts.requisition_status == RequisitionStatus.OPEN.value, JobPosts.is_deleted == False, # noqa: E712 ) .group_by(cls.user_id) diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index 120e07b..cf90ff1 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -3,7 +3,7 @@ from datetime import datetime, timezone from typing import TYPE_CHECKING, List, Optional from fastapi import HTTPException -from sqlalchemy import JSON, DateTime, Index, UniqueConstraint, func, or_ +from sqlalchemy import JSON, DateTime, Index, UniqueConstraint, and_, func, or_ from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -790,6 +790,11 @@ class Interviews(SQLModel, table=True): interview_type: str = Field(default="") interview_status: str = Field(default="") inbox_id: int | None = Field(default=None, foreign_key="inbox.id") + # Optional denorm so Recruiter Hub can join interviews → job_posts.current_recruiter_id + # without walking inbox. Filled on create from the application's assigned job; + # migration 011 added the columns. user_id is the candidate, not the recruiter. + user_id: uuid.UUID | None = Field(default=None, foreign_key="users.id") + job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id") graph_event_id: str | None = Field(default=None) web_link: str | None = Field(default=None) inbox: Optional["Inbox"] = Relationship( @@ -830,6 +835,28 @@ class Interviews(SQLModel, table=True): ) return result.scalars().all() + @classmethod + def scoped_to_recruiter(cls, statement, recruiter_id): + """Restrict an Interviews select to the recruiter who owns the job. + + COALESCE(interviews.job_post_id, inbox_messages.assigned_job_post_id) + → job_posts.current_recruiter_id. Interviews with no job drop out. + """ + from inbox.models import Inbox, Inbox_Messages + from job.job_post.models import JobPosts + + rid = cls._as_uuid(recruiter_id) + if rid is None: + return statement + job_id = func.coalesce(cls.job_post_id, Inbox_Messages.assigned_job_post_id) + return ( + statement + .outerjoin(Inbox, cls.inbox_id == Inbox.id) + .outerjoin(Inbox_Messages, Inbox.message_id == Inbox_Messages.id) + .outerjoin(JobPosts, JobPosts.id == job_id) + .where(JobPosts.current_recruiter_id == rid) + ) + @classmethod async def get_interviews_in_range( cls, @@ -838,6 +865,7 @@ class Interviews(SQLModel, table=True): from_date=None, to_date=None, status: str | None = None, + recruiter_id=None, top: int | None = None, skip: int = 0, ): @@ -848,6 +876,8 @@ class Interviews(SQLModel, table=True): statement = statement.where(cls.interview_date < to_date) if status: statement = statement.where(cls.interview_status == status) + if recruiter_id: + statement = cls.scoped_to_recruiter(statement, recruiter_id) count_statement = select(func.count()).select_from(statement.subquery()) total = (await session.execute(count_statement)).scalar_one() statement = ( @@ -860,6 +890,41 @@ class Interviews(SQLModel, table=True): result = await session.execute(statement) return list(result.scalars().all()), total + @classmethod + async def count_between(cls, session: AsyncSession, start, end, recruiter_id=None): + statement = select(func.count()).select_from(cls).where( + cls.interview_date >= start, + cls.interview_date < end, + ) + if recruiter_id: + statement = cls.scoped_to_recruiter(statement, recruiter_id) + result = await session.execute(statement) + return int(result.scalar_one() or 0) + + @classmethod + async def count_upcoming(cls, session: AsyncSession, as_of, recruiter_id=None): + statement = select(func.count()).select_from(cls).where( + cls.interview_status.ilike("scheduled"), + cls.interview_date >= as_of, + ) + if recruiter_id: + statement = cls.scoped_to_recruiter(statement, recruiter_id) + result = await session.execute(statement) + return int(result.scalar_one() or 0) + + @classmethod + async def next_scheduled_at(cls, session: AsyncSession, as_of, recruiter_id=None): + statement = select( + func.min(func.coalesce(cls.interview_time, cls.interview_date)) + ).select_from(cls).where( + cls.interview_status.ilike("scheduled"), + cls.interview_date >= as_of, + ) + if recruiter_id: + statement = cls.scoped_to_recruiter(statement, recruiter_id) + result = await session.execute(statement) + return result.scalar_one() + @classmethod async def job_titles_by_inbox(cls, session: AsyncSession, inbox_ids) -> dict[int, str]: """Resolve {inbox_id: job_title} for a page of interview rows. @@ -1222,6 +1287,113 @@ class ApplicationStageTransitions(SQLModel, table=True): result = await session.execute(statement) return result.scalar_one() + @classmethod + async def avg_time_to_hire( + cls, session: AsyncSession, from_date=None, to_date=None, department=None, recruiter_id=None, + ): + """Mean days from first stage (from_stage IS NULL) to HIRED, optional filters.""" + from inbox.enums import Candidate_application_Status + from inbox.models import Inbox, Inbox_Messages + from job.job_post.models import JobPosts + + entry = cls.__table__.alias("entry") + hire = cls.__table__.alias("hire") + days = func.extract("epoch", hire.c.valid_from - entry.c.valid_from) / 86400.0 + statement = ( + select(func.avg(days)) + .select_from( + hire.join( + entry, + and_( + hire.c.inbox_id == entry.c.inbox_id, + entry.c.from_stage.is_(None), + ), + ) + ) + .where(hire.c.to_stage == Candidate_application_Status.HIRED.value) + ) + if from_date is not None: + statement = statement.where(hire.c.valid_from >= from_date) + if to_date is not None: + statement = statement.where(hire.c.valid_from < to_date) + if department or recruiter_id: + statement = ( + statement + .outerjoin(Inbox, hire.c.inbox_id == Inbox.id) + .outerjoin(Inbox_Messages, Inbox.message_id == Inbox_Messages.id) + .outerjoin(JobPosts, Inbox_Messages.assigned_job_post_id == JobPosts.id) + ) + if department: + statement = statement.where(JobPosts.department == department) + rid = cls._as_uuid(recruiter_id) + if rid is not None: + statement = statement.where( + or_(Inbox_Messages.recruiter_id == rid, JobPosts.current_recruiter_id == rid) + ) + result = await session.execute(statement) + value = result.scalar_one() + return float(value) if value is not None else None + + @classmethod + def scoped_to_job(cls, statement, department=None, recruiter_id=None): + if not department and not recruiter_id: + return statement + from inbox.models import Inbox, Inbox_Messages + from job.job_post.models import JobPosts + + statement = ( + statement + .outerjoin(Inbox, cls.inbox_id == Inbox.id) + .outerjoin(Inbox_Messages, Inbox.message_id == Inbox_Messages.id) + .outerjoin(JobPosts, Inbox_Messages.assigned_job_post_id == JobPosts.id) + ) + if department: + statement = statement.where(JobPosts.department == department) + rid = cls._as_uuid(recruiter_id) + if rid is not None: + statement = statement.where( + or_(Inbox_Messages.recruiter_id == rid, JobPosts.current_recruiter_id == rid) + ) + return statement + + @classmethod + async def count_hires( + cls, session: AsyncSession, from_date=None, to_date=None, department=None, recruiter_id=None, + ): + from inbox.enums import Candidate_application_Status + + statement = select(func.count()).select_from(cls).where( + cls.to_stage == Candidate_application_Status.HIRED.value + ) + if from_date is not None: + statement = statement.where(cls.valid_from >= from_date) + if to_date is not None: + statement = statement.where(cls.valid_from < to_date) + statement = cls.scoped_to_job(statement, department, recruiter_id) + result = await session.execute(statement) + return int(result.scalar_one() or 0) + + @classmethod + async def counts_hires_by_month( + cls, session: AsyncSession, start, department=None, recruiter_id=None, + ): + from inbox.enums import Candidate_application_Status + + month_bucket = func.date_trunc("month", cls.valid_from) + statement = ( + select(month_bucket.label("month"), func.count().label("count")) + .select_from(cls) + .where( + cls.to_stage == Candidate_application_Status.HIRED.value, + cls.valid_from >= start, + ) + .group_by(month_bucket) + .order_by(month_bucket) + ) + statement = cls.scoped_to_job(statement, department, recruiter_id) + result = await session.execute(statement) + return [(month, int(count or 0)) for month, count in result.all()] + class CandidateHistory(SQLModel, table=True): """Append-only audit log for one candidate (users.id), scoped to an application. diff --git a/backend/job/cost/models.py b/backend/job/cost/models.py index 507f849..00d8d41 100644 --- a/backend/job/cost/models.py +++ b/backend/job/cost/models.py @@ -83,13 +83,46 @@ class HiringCosts(SQLModel, table=True): return await cls.get_by_id(session, row.id) @classmethod - async def sum_amount(cls, session: AsyncSession, *, from_date=None, to_date=None): + def scoped_to_job(cls, statement, department=None, recruiter_id=None): + if not department and not recruiter_id: + return statement + from job.job_post.models import JobPosts + statement = statement.outerjoin(JobPosts, cls.job_post_id == JobPosts.id) + if department: + statement = statement.where(JobPosts.department == department) + rid = cls._as_uuid(recruiter_id) + if rid is not None: + statement = statement.where(JobPosts.current_recruiter_id == rid) + return statement + + @classmethod + async def sum_amount(cls, session: AsyncSession, *, from_date=None, to_date=None, + department=None, recruiter_id=None): statement = select(func.coalesce(func.sum(cls.amount), 0.0)) if from_date is not None: statement = statement.where(cls.incurred_at >= from_date) if to_date is not None: statement = statement.where(cls.incurred_at < to_date) + statement = cls.scoped_to_job(statement, department, recruiter_id) result = await session.execute(statement) return float(result.scalar_one() or 0.0) + @classmethod + async def sum_by_source_channel( + cls, session: AsyncSession, *, from_date=None, to_date=None, + department=None, recruiter_id=None, + ): + statement = select( + cls.source_channel_id, + func.coalesce(func.sum(cls.amount), 0.0), + ).where(cls.source_channel_id.is_not(None)) + if from_date is not None: + statement = statement.where(cls.incurred_at >= from_date) + if to_date is not None: + statement = statement.where(cls.incurred_at < to_date) + statement = cls.scoped_to_job(statement, department, recruiter_id) + statement = statement.group_by(cls.source_channel_id) + result = await session.execute(statement) + return {channel_id: float(total or 0.0) for channel_id, total in result.all()} + import users.models as _users_models # noqa: E402, F401 diff --git a/backend/job/interviews/views.py b/backend/job/interviews/views.py index bc6d3eb..eb69c73 100644 --- a/backend/job/interviews/views.py +++ b/backend/job/interviews/views.py @@ -24,7 +24,7 @@ class Interview: async def _serialize(self,row): return serialize_interview(row,job_title=await self._job_title_for(row)) - async def get_interview(self,interview_id=None,inbox_id=None,from_date=None,to_date=None,status=None,top=None,skip=0): + async def get_interview(self,interview_id=None,inbox_id=None,from_date=None,to_date=None,status=None,recruiter_id=None,top=None,skip=0): if interview_id: row=await Interviews.get_interview_by_id(self.session,interview_id) if not row: @@ -33,18 +33,19 @@ class Interview: if inbox_id is not None: rows=await Interviews.get_interviews_by_inbox(self.session,int(inbox_id)) return [serialize_interview(r) for r in rows] - if from_date is not None or to_date is not None or status is not None or top is not None: + if from_date is not None or to_date is not None or status is not None or recruiter_id or top is not None: return await self.get_interviews_range( - from_date=from_date,to_date=to_date,status=status,top=top,skip=skip, + from_date=from_date,to_date=to_date,status=status,recruiter_id=recruiter_id,top=top,skip=skip, ) raise HTTPException(status_code=400,detail="interview_id or inbox_id is required") - async def get_interviews_range(self,from_date=None,to_date=None,status=None,top=None,skip=0): + async def get_interviews_range(self,from_date=None,to_date=None,status=None,recruiter_id=None,top=None,skip=0): rows,total=await Interviews.get_interviews_in_range( self.session, from_date=from_date, to_date=to_date, status=status, + recruiter_id=recruiter_id, top=top, skip=skip, ) @@ -52,6 +53,7 @@ class Interview: return [serialize_interview(r,job_title=titles.get(r.inbox_id)) for r in rows],total async def create_interview(self,payload,current_user=None): + from inbox.models import Inbox fields={ "interview_date":payload.get("interview_date"), "interview_time":payload.get("interview_time"), @@ -59,6 +61,13 @@ class Interview: "interview_status":payload.get("interview_status") or "", "inbox_id":payload.get("inbox_id"), } + inbox=await Inbox.get_inbox_with_message(self.session,payload.get("inbox_id")) + if inbox: + if inbox.user_id: + fields["user_id"]=inbox.user_id + msg=inbox.messages + if msg and msg.assigned_job_post_id: + fields["job_post_id"]=msg.assigned_job_post_id fields={k:v for k,v in fields.items() if v is not None} row=await Interviews.insert_interview(self.session,fields) when=row.interview_date or row.interview_time diff --git a/backend/job/job_post/enums.py b/backend/job/job_post/enums.py new file mode 100644 index 0000000..58fc815 --- /dev/null +++ b/backend/job/job_post/enums.py @@ -0,0 +1,47 @@ +from enum import Enum + + +class RequisitionStatus(str, Enum): + """Hiring lifecycle on job_posts.requisition_status. + + Distinct from job_posts.status, which is Buffer publish state + (draft/scheduled/published/failed). Values are the wire form the Jobs + screen already PATCHes; labels are what the dropdown renders. + """ + + OPEN = "open" + ON_HOLD = "on_hold" + CLOSED = "closed" + COMPLETED = "completed" + + @property + def label(self) -> str: + return _LABELS[self] + + @classmethod + def parse(cls, value): + """Accept the stored value or the UI label. None if neither matches.""" + raw = (value or "").strip() + if not raw: + return None + lowered = raw.lower().replace(" ", "_") + for member in cls: + if raw == member.value or lowered == member.value or raw == member.label: + return member + return None + + @classmethod + def values(cls) -> tuple[str, ...]: + return tuple(m.value for m in cls) + + @classmethod + def as_list(cls) -> list[dict]: + return [{"value": m.value, "label": m.label} for m in cls] + + +_LABELS = { + RequisitionStatus.OPEN: "Open", + RequisitionStatus.ON_HOLD: "On Hold", + RequisitionStatus.CLOSED: "Closed", + RequisitionStatus.COMPLETED: "Completed", +} diff --git a/backend/job/job_post/export.py b/backend/job/job_post/export.py index 0666f1d..4b7025a 100644 --- a/backend/job/job_post/export.py +++ b/backend/job/job_post/export.py @@ -20,8 +20,8 @@ BRAND_DARK = "0B3D2E" # header/banner green, matches the app chrome BRAND_STRIPE = "EFF7F2" # zebra row tint BORDER_TINT = "CBDCD2" -STATUS_LABELS = {"open": "Open", "closed": "Closed", "on_hold": "On Hold"} -STATUS_COLORS = {"open": "1B7F4B", "closed": "B3261E", "on_hold": "9A6700"} +STATUS_LABELS = {"open": "Open", "closed": "Closed", "on_hold": "On Hold", "completed": "Completed"} +STATUS_COLORS = {"open": "1B7F4B", "closed": "B3261E", "on_hold": "9A6700", "completed": "0F6E56"} # (header, column width) COLUMNS = [ diff --git a/backend/job/job_post/models.py b/backend/job/job_post/models.py index 52f0fe2..6554eb0 100644 --- a/backend/job/job_post/models.py +++ b/backend/job/job_post/models.py @@ -2,10 +2,12 @@ import uuid from datetime import datetime, timezone from typing import TYPE_CHECKING, Optional -from sqlalchemy import DateTime, JSON, func, or_ +from sqlalchemy import DateTime, JSON, Index, func, or_ from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import Field, Relationship, SQLModel, select +from job.job_post.enums import RequisitionStatus + if TYPE_CHECKING: # runtime import would be circular: users.models imports this module from users.models import Users @@ -49,7 +51,7 @@ class JobPosts(SQLModel, table=True): buffer_sent_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) status: str = Field(default="draft") buffer_error: str | None = Field(default=None) - # requisition_status is the hiring lifecycle (open/closed/on_hold). Distinct from + # requisition_status is the hiring lifecycle (RequisitionStatus). Distinct from # `status`, which tracks Buffer publishing (draft/scheduled/published/failed). # server_default is load-bearing: this column arrives as an ALTER on a populated table. requisition_status: str = Field(default="open", sa_column_kwargs={"server_default": "open"}) @@ -211,10 +213,100 @@ class JobPosts(SQLModel, table=True): result = await session.execute(statement) return {uid: int(n or 0) for uid, n in result.all()} + @classmethod + async def count_by_current_recruiter( + cls, session: AsyncSession, recruiter_id, *, status, department=None, + from_date=None, to_date=None, + ): + """Requisitions owned by current_recruiter_id in one requisition_status.""" + uid = cls._as_uuid(recruiter_id) + if uid is None: + return 0 + statement = select(func.count()).select_from(cls).where( + cls.current_recruiter_id == uid, + cls.requisition_status == status, + cls.is_deleted == False, # noqa: E712 + ) + if department: + statement = statement.where(cls.department == department) + if from_date is not None: + statement = statement.where(cls.closed_at >= from_date) + if to_date is not None: + statement = statement.where(cls.closed_at < to_date) + result = await session.execute(statement) + return int(result.scalar_one() or 0) + + @classmethod + def _scoped(cls, statement, department=None, recruiter_id=None): + if department: + statement = statement.where(cls.department == department) + uid = cls._as_uuid(recruiter_id) if recruiter_id is not None else None + if uid is not None: + statement = statement.where(cls.current_recruiter_id == uid) + return statement + + @classmethod + async def count_requisitions( + cls, session: AsyncSession, status=None, department=None, recruiter_id=None, + from_date=None, to_date=None, *, closed_in_window=False, + ): + statement = select(func.count()).select_from(cls).where(cls.is_deleted == False) # noqa: E712 + if status: + statement = statement.where(cls.requisition_status == status) + statement = cls._scoped(statement, department, recruiter_id) + if closed_in_window: + if from_date is not None: + statement = statement.where(cls.closed_at >= from_date) + if to_date is not None: + statement = statement.where(cls.closed_at < to_date) + result = await session.execute(statement) + return int(result.scalar_one() or 0) + + @classmethod + async def count_open_snapshot(cls, session: AsyncSession, as_of, department=None, recruiter_id=None): + """Jobs that existed and were still open at `as_of` (best-effort).""" + statement = select(func.count()).select_from(cls).where( + cls.is_deleted == False, # noqa: E712 + cls.created_at < as_of, + or_(cls.closed_at.is_(None), cls.closed_at >= as_of), + cls.requisition_status == RequisitionStatus.OPEN.value, + ) + statement = cls._scoped(statement, department, recruiter_id) + result = await session.execute(statement) + return int(result.scalar_one() or 0) + + @classmethod + async def avg_time_to_fill( + cls, session: AsyncSession, from_date=None, to_date=None, department=None, recruiter_id=None, + ): + days = func.extract("epoch", cls.closed_at - cls.created_at) / 86400.0 + statement = select(func.avg(days)).select_from(cls).where( + cls.is_deleted == False, # noqa: E712 + cls.requisition_status.in_(( + RequisitionStatus.CLOSED.value, + RequisitionStatus.COMPLETED.value, + )), + cls.closed_at.is_not(None), + ) + if from_date is not None: + statement = statement.where(cls.closed_at >= from_date) + if to_date is not None: + statement = statement.where(cls.closed_at < to_date) + statement = cls._scoped(statement, department, recruiter_id) + result = await session.execute(statement) + value = result.scalar_one() + return float(value) if value is not None else None + @classmethod async def insert_job_post(cls, session: AsyncSession, fields: dict): row = cls(**fields) session.add(row) + session.add(JobPostStatusHistory( + job_post_id=row.id, + from_status=None, + to_status=row.requisition_status or "open", + changed_by=row.created_by, + )) await session.commit() return await cls.get_job_post_by_id(session, row.id) @@ -289,23 +381,68 @@ class JobPosts(SQLModel, table=True): return row @classmethod - async def set_requisition_status(cls, session: AsyncSession, record_id: str, status: str): + async def set_requisition_status( + cls, session: AsyncSession, record_id: str, status: str, *, changed_by=None, + ): row = await cls.get_job_post_by_id(session, record_id) if not row or row.is_deleted: return None previous = row.requisition_status + if previous == status: + return row row.requisition_status = status - if status == "closed": - if previous != "closed" or row.closed_at is None: + terminal = status in ("closed", "completed") + if terminal: + if previous not in ("closed", "completed") or row.closed_at is None: row.closed_at = _now() else: row.closed_at = None row.updated_at = _now() session.add(row) + actor = cls._as_uuid(changed_by) if changed_by is not None else None + session.add(JobPostStatusHistory( + job_post_id=row.id, + from_status=previous, + to_status=status, + changed_by=actor, + )) await session.commit() return await cls.get_job_post_by_id(session, record_id) +class JobPostStatusHistory(SQLModel, table=True): + """Who changed job_posts.requisition_status, from what, to what, and when. + + Distinct from job_assignments (ownership intervals). The Jobs History tab + merges both. Applied on prod by migrations/manual/016_job_post_status_history.sql. + """ + + __tablename__ = "job_post_status_history" + __table_args__ = ( + Index("ix_job_post_status_history_job_created", "job_post_id", "created_at"), + ) + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + job_post_id: uuid.UUID = Field(foreign_key="job_posts.id", index=True) + from_status: str | None = Field(default=None) + to_status: str + changed_by: uuid.UUID | None = Field(default=None, foreign_key="users.id") + actor_kind: str = Field(default="user") + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + + @classmethod + async def fetch_by_job(cls, session: AsyncSession, job_post_id): + uid = JobPosts._as_uuid(job_post_id) + if uid is None: + return [] + result = await session.execute( + select(cls) + .where(cls.job_post_id == uid) + .order_by(cls.created_at.desc(), cls.id.desc()) + ) + return list(result.scalars().all()) + + class JobPostImages(SQLModel, table=True): """Cover image of a job post, stored as bytes IN the database. diff --git a/backend/job/job_post/serializers.py b/backend/job/job_post/serializers.py index 17b97ef..0610900 100644 --- a/backend/job/job_post/serializers.py +++ b/backend/job/job_post/serializers.py @@ -1,3 +1,13 @@ +from job.job_post.enums import RequisitionStatus + + +def _status_label(value): + if value in (None, ""): + return None + parsed = RequisitionStatus.parse(value) + return parsed.label if parsed else value + + def serialize_job_post(row) -> dict: return { "id": str(row.id), @@ -66,3 +76,18 @@ def serialize_job_row(row, *, recruiter_name=None, hiring_manager_name=None, app "created_at": row.created_at.isoformat() if row.created_at else None, "updated_at": row.updated_at.isoformat() if row.updated_at else None, } + + +def serialize_status_history(row, *, changed_by_name=None) -> dict: + return { + "id": str(row.id), + "job_post_id": str(row.job_post_id) if row.job_post_id else None, + "from_status": row.from_status, + "from_label": _status_label(row.from_status), + "to_status": row.to_status, + "to_label": _status_label(row.to_status), + "changed_by": str(row.changed_by) if row.changed_by else None, + "changed_by_name": changed_by_name, + "actor_kind": row.actor_kind, + "created_at": row.created_at.isoformat() if row.created_at else None, + } diff --git a/backend/job/job_post/views.py b/backend/job/job_post/views.py index 18c06d0..a7b13dc 100644 --- a/backend/job/job_post/views.py +++ b/backend/job/job_post/views.py @@ -12,7 +12,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from pydantic import BaseModel, model_validator from inbox.models import Inbox_Messages from job.assignment.views import Assignment -from job.job_post.models import JobPostImages,JobPosts,SocialPlatform +from job.job_post.enums import RequisitionStatus +from job.job_post.models import JobPostImages,JobPostStatusHistory,JobPosts,SocialPlatform from role.models import EnumRoles from users.models import Users from job.job_post.plugins import ( @@ -25,7 +26,7 @@ from job.job_post.plugins import ( render_job_post, resolve_channel, ) -from job.job_post.serializers import serialize_job_post, serialize_job_row +from job.job_post.serializers import serialize_job_post, serialize_job_row, serialize_status_history load_dotenv() logger=logging.getLogger("job.job_post") @@ -208,6 +209,20 @@ class JobPost: async def fetch_departments(self,active_only=False): return await JobPosts.list_departments(self.session,active_only=active_only) + async def fetch_requisition_statuses(self): + return RequisitionStatus.as_list() + + async def fetch_status_history(self,job_post_id): + job=await JobPosts.get_job_post_by_id(self.session,job_post_id) + if not job or job.is_deleted: + raise HTTPException(status_code=404,detail="Job post not found") + rows=await JobPostStatusHistory.fetch_by_job(self.session,job_post_id) + names=await Users.names_by_ids(self.session,[r.changed_by for r in rows]) + return [ + serialize_status_history(r,changed_by_name=names.get(str(r.changed_by))) + for r in rows + ] + async def fetch_jobs(self,search=None,department=None,requisition_status=None, employment_type=None,hiring_manager_id=None,top=None,skip=0,active_only=True): hm_uid=None @@ -352,13 +367,20 @@ class JobPost: if not current_user: raise HTTPException(status_code=401,detail="Not authenticated") status=(payload.get("requisition_status") or "").strip() - allowed=("open","closed","on_hold") - if status not in allowed: - raise HTTPException(status_code=422,detail=f"requisition_status must be one of {', '.join(allowed)}") - row=await JobPosts.set_requisition_status(self.session,job_post_id,status) + parsed=RequisitionStatus.parse(status) + if parsed is None: + raise HTTPException( + status_code=422, + detail=f"requisition_status must be one of {', '.join(RequisitionStatus.values())}", + ) + status=parsed.value + actor=current_user.get("id") if isinstance(current_user,dict) else None + row=await JobPosts.set_requisition_status( + self.session,job_post_id,status,changed_by=actor, + ) if not row: raise HTTPException(status_code=404,detail="Job post not found") - if status=="closed": + if status==RequisitionStatus.CLOSED.value: try: from notifications.models import Notifications raw=row.current_recruiter_id or (current_user.get("id") if current_user else None) diff --git a/backend/migrations/manual/016_job_post_status_history.sql b/backend/migrations/manual/016_job_post_status_history.sql new file mode 100644 index 0000000..29a0ae5 --- /dev/null +++ b/backend/migrations/manual/016_job_post_status_history.sql @@ -0,0 +1,21 @@ +-- 016_job_post_status_history.sql +-- Audit log of job_posts.requisition_status changes (who, from, to, when). +-- The Jobs History tab merges this with job_assignments. Applied at startup +-- by alembic_setup.run_manual_sql(). Needed because prod boots with +-- DB_AUTOGENERATE=false. + +CREATE TABLE IF NOT EXISTS app.job_post_status_history ( + id UUID PRIMARY KEY, + job_post_id UUID NOT NULL REFERENCES app.job_posts(id), + from_status VARCHAR, + to_status VARCHAR NOT NULL, + changed_by UUID REFERENCES app.users(id), + actor_kind VARCHAR NOT NULL DEFAULT 'user', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_job_post_status_history_job_post_id + ON app.job_post_status_history (job_post_id); + +CREATE INDEX IF NOT EXISTS ix_job_post_status_history_job_created + ON app.job_post_status_history (job_post_id, created_at); diff --git a/backend/offer/models.py b/backend/offer/models.py index a6da752..f83a0bf 100644 --- a/backend/offer/models.py +++ b/backend/offer/models.py @@ -115,6 +115,38 @@ class Offers(SQLModel, table=True): result = await session.execute(statement) return result.scalar_one() + @classmethod + def scoped_to_job(cls, statement, department=None, recruiter_id=None): + if not department and not recruiter_id: + return statement + from job.job_post.models import JobPosts + statement = statement.outerjoin(JobPosts, cls.job_post_id == JobPosts.id) + if department: + statement = statement.where(JobPosts.department == department) + rid = cls._as_uuid(recruiter_id) + if rid is not None: + statement = statement.where(JobPosts.current_recruiter_id == rid) + return statement + + @classmethod + async def count_in_window( + cls, session: AsyncSession, statuses=None, from_date=None, to_date=None, + department=None, recruiter_id=None, *, exclude_draft=False, + ): + statement = select(func.count()).select_from(cls) + if exclude_draft: + statement = statement.where(cls.status != "draft") + elif statuses: + statement = statement.where(cls.status.in_(statuses)) + stamp = func.coalesce(cls.sent_at, cls.responded_at, cls.created_at) + if from_date is not None: + statement = statement.where(stamp >= from_date) + if to_date is not None: + statement = statement.where(stamp < to_date) + statement = cls.scoped_to_job(statement, department, recruiter_id) + result = await session.execute(statement) + return int(result.scalar_one() or 0) + class OfferStatusHistory(SQLModel, table=True): __tablename__ = "offer_status_history" diff --git a/backend/reports/runner.py b/backend/reports/runner.py index 6702d6f..321cb97 100644 --- a/backend/reports/runner.py +++ b/backend/reports/runner.py @@ -184,6 +184,7 @@ async def _build_recruiter_performance(session, f): row["avg_time_to_hire"] = _round(row.get("avg_time_to_hire")) columns = [ {"key": "name", "label": "Recruiter"}, + {"key": "completed", "label": "Completed Requisitions"}, {"key": "hires", "label": "Hires"}, {"key": "open_reqs", "label": "Open Requisitions"}, {"key": "avg_time_to_hire", "label": "Avg Time to Hire (days)"}, diff --git a/backend/users/models.py b/backend/users/models.py index 3eff965..eecf6b1 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -117,6 +117,23 @@ class Users(SQLModel, table=True): result = await session.execute(statement) return result.scalars().all() + @classmethod + async def list_by_role_name(cls, session: AsyncSession, role_name, user_id=None): + """Active (non-deleted) users whose Roles.role_name matches. Optional id filter.""" + statement = ( + select(cls) + .join(Roles, cls.role_id == Roles.id) + .where( + Roles.role_name == role_name, + cls.is_deleted == False, # noqa: E712 + ) + ) + uid = cls._as_uuid(user_id) if user_id is not None else None + if uid is not None: + statement = statement.where(cls.id == uid) + result = await session.execute(statement) + return list(result.scalars().all()) + @classmethod async def names_by_ids(cls, session: AsyncSession, user_ids) -> dict[str, str]: """Resolve {user_id: name} in a single query. diff --git a/frontend/src/api/interviews.js b/frontend/src/api/interviews.js index 73a3238..119a76d 100644 --- a/frontend/src/api/interviews.js +++ b/frontend/src/api/interviews.js @@ -35,12 +35,13 @@ export const INTERVIEW_TYPES = [ ] /** Range read. `top` is always sent so the route takes the range branch. */ -export function listRange({ fromDate, toDate, status, top = 200, skip } = {}) { +export function listRange({ fromDate, toDate, status, recruiterId, top = 200, skip } = {}) { return request('/interview/fetch', { params: { from_date: fromDate, to_date: toDate, status, + recruiter_id: recruiterId, top, skip, }, diff --git a/frontend/src/api/jobs.js b/frontend/src/api/jobs.js index bf6fa61..0b5b0c5 100644 --- a/frontend/src/api/jobs.js +++ b/frontend/src/api/jobs.js @@ -25,9 +25,22 @@ export function list({ search, department, requisitionStatus, employmentType, } /* requisition_status is the HIRING lifecycle. The row's separate `status` field is - the Buffer publishing lifecycle — never map the two onto one badge. */ -const REQ_STATUS_LABEL = { open: 'Open', closed: 'Closed', on_hold: 'On Hold' } -export const JOB_STATUSES = Object.values(REQ_STATUS_LABEL) + the Buffer publishing lifecycle — never map the two onto one badge. Fallback + matches GET /jobs/requisition-statuses/fetch so the dropdown still works if + that call 403s. */ +export const REQUISITION_STATUSES = [ + { value: 'open', label: 'Open' }, + { value: 'on_hold', label: 'On Hold' }, + { value: 'closed', label: 'Closed' }, + { value: 'completed', label: 'Completed' }, +] +const REQ_STATUS_LABEL = Object.fromEntries(REQUISITION_STATUSES.map((s) => [s.value, s.label])) +const LABEL_TO_STATUS = Object.fromEntries(REQUISITION_STATUSES.map((s) => [s.label, s.value])) +export const JOB_STATUSES = REQUISITION_STATUSES.map((s) => s.label) + +export function listRequisitionStatuses() { + return request('/jobs/requisition-statuses/fetch') +} function experienceLabel(min, max) { if (min == null && max == null) return null @@ -66,8 +79,6 @@ export function toJobView(row) { } } -const LABEL_TO_STATUS = { Open: 'open', Closed: 'closed', 'On Hold': 'on_hold' } - /** * Styled .xlsx download of the requisition list — GET /jobs/export * (jobs.export). Same filters as list(); `status` takes the UI label. @@ -121,3 +132,8 @@ export function setStatus(jobPostId, status) { body: { requisition_status }, }) } + +/** Status-change audit for one requisition — GET /jobs/status-history/fetch. */ +export function listStatusHistory(jobPostId) { + return request('/jobs/status-history/fetch', { params: { job_post_id: jobPostId } }) +} diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index 9d845ec..e7eaa8a 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -79,6 +79,8 @@ export const qk = { jobs: { all: () => ['jobs'], list: (p = {}) => ['jobs', 'list', p], + requisitionStatuses: () => ['jobs', 'requisition-statuses'], + statusHistory: (id) => ['jobs', 'status-history', id], }, talent: { all: () => ['talent'], diff --git a/frontend/src/screens/Jobs.jsx b/frontend/src/screens/Jobs.jsx index e7aae69..7bf1f5d 100644 --- a/frontend/src/screens/Jobs.jsx +++ b/frontend/src/screens/Jobs.jsx @@ -28,7 +28,6 @@ import * as jobPostsApi from '../api/jobPosts' import * as assignmentsApi from '../api/assignments' import * as tasksApi from '../api/tasks' import * as usersApi from '../api/users' -import { JOB_STATUSES } from '../api/jobs' import { empTypes, fmtShort } from '../data/seed' // Backend allows up to 500; stay at 100 so we match Managers.jsx (shared qk.jobs.list). @@ -78,6 +77,18 @@ export default function Jobs() { const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs }) const jobs = useMemo(() => jobsQuery.data ?? [], [jobsQuery.data]) + const statusesQuery = useQuery({ + queryKey: qk.jobs.requisitionStatuses(), + queryFn: async () => { + const res = await jobsApi.listRequisitionStatuses() + const rows = Array.isArray(res?.data) ? res.data : [] + return rows.length ? rows : jobsApi.REQUISITION_STATUSES + }, + }) + const statusLabels = useMemo( + () => (statusesQuery.data ?? jobsApi.REQUISITION_STATUSES).map((s) => s.label), + [statusesQuery.data], + ) const [q, setQ] = useState('') const [dept, setDept] = useState('') @@ -92,12 +103,19 @@ export default function Jobs() { const canDelete = can('jobs.delete') // Deep-link intents from global search, the dashboard and the manager portal. + // Consume once and replace history state: jobs refetch after a status PATCH + // used to replay openCreate and pop the create modal over the detail view. useEffect(() => { const st = location.state - if (!st) return + if (!st?.openCreate && !st?.openJob) return if (st.openCreate) setCreating(true) - if (st.openJob) setViewing(jobs.find((j) => j.id === st.openJob) ?? null) - }, [location.state, jobs]) + if (st.openJob) { + const job = jobs.find((j) => j.id === st.openJob) + if (job) setViewing(job) + else if (!jobsQuery.isSuccess) return + } + navigate('.', { replace: true, state: null }) + }, [location.state, jobs, jobsQuery.isSuccess, navigate]) useEffect(() => { if (!viewing) return @@ -239,17 +257,17 @@ export default function Jobs() { key: '_a', label: 'Actions', align: 'right', render: (j) => (
- + {canEdit && ( - + )} - {canEdit && (j.status === 'Closed' ? ( + {canEdit && (j.status === 'Closed' || j.status === 'Completed' ? ( ) : ( ))} - +
), }, @@ -314,7 +333,7 @@ export default function Jobs() { ) : ( {j.status} @@ -1173,7 +1264,7 @@ function JobDetail({ onChange={setTab} tabs={[ { key: 'details', label: 'Details' }, - { key: 'history', label: 'History', count: historyQuery.data?.length }, + { key: 'history', label: 'History', count: historyCount || undefined }, ]} /> @@ -1213,7 +1304,7 @@ function JobDetail({ )} - {tab === 'history' && } + {tab === 'history' && } ) } diff --git a/frontend/src/screens/RecruiterHub.jsx b/frontend/src/screens/RecruiterHub.jsx index 700ce38..a850186 100644 --- a/frontend/src/screens/RecruiterHub.jsx +++ b/frontend/src/screens/RecruiterHub.jsx @@ -24,6 +24,11 @@ is the worklist the prototype filed under Recruiter Hub. Completing a row writes the same /tasks/update the Tasks screen uses, so the two stay in sync. Hidden without tasks.view; the rest of the hub still loads. + + Interviews Today / upcoming / the heatmap join interviews → job_posts via + COALESCE(interviews.job_post_id, inbox_messages.assigned_job_post_id) and + filter on current_recruiter_id. The leaderboard ranks by completed + requisitions (requisition_status=completed), not inbox hires. ============================================================ */ import { useMemo, useState } from 'react' @@ -143,16 +148,18 @@ export default function RecruiterHub() { }, []) const heatQuery = useQuery({ - queryKey: qk.interviews.range({ scope: 'heatmap', weeks: WEEKS }), + queryKey: qk.interviews.range({ scope: 'heatmap', weeks: WEEKS, recruiterId: activeId }), queryFn: async () => { const res = await interviewsApi.listRange({ fromDate: heatFrom.toISOString(), toDate: new Date().toISOString(), + recruiterId: activeId, top: 500, }) const rows = Array.isArray(res?.data) ? res.data : [] return rows.map(interviewsApi.toInterviewView) }, + enabled: Boolean(activeId), }) const heatmap = useMemo(() => { @@ -191,7 +198,7 @@ export default function RecruiterHub() { }, [funnelQuery.data]) const board = useMemo( - () => [...recruiters].sort((a, b) => (b.hires ?? 0) - (a.hires ?? 0)).slice(0, 8), + () => [...recruiters].sort((a, b) => (b.completed ?? 0) - (a.completed ?? 0) || (b.hires ?? 0) - (a.hires ?? 0)).slice(0, 8), [recruiters], ) @@ -269,6 +276,8 @@ export default function RecruiterHub() {
{selected.open_reqs ?? 0} open req{(selected.open_reqs ?? 0) === 1 ? '' : 's'} {' · '} + {selected.completed ?? 0} completed + {' · '} {selected.hires ?? 0} hire{(selected.hires ?? 0) === 1 ? '' : 's'} {canViewTasks && !tasksLoading ? ( <> @@ -364,7 +373,7 @@ export default function RecruiterHub() {

Interview Load

- Team-wide, last {WEEKS} weeks + This recruiter, last {WEEKS} weeks
@@ -403,7 +412,7 @@ export default function RecruiterHub() { More

- Interviews carry no recruiter, so this counts the whole team. + Counted from interviews on this recruiter’s jobs.

)} @@ -414,12 +423,12 @@ export default function RecruiterHub() {
-

Recruiter Leaderboard

Top performers by hires
+

Recruiter Leaderboard

Ranked by completed requisitions
{board.length === 0 ? ( - - The board fills in as applications reach the hired stage. + + Mark a job Completed when hiring finishes to rank recruiters here. ) : ( board.map((rec, i) => { @@ -445,8 +454,8 @@ export default function RecruiterHub() {
-
{rec.hires ?? 0}
-
hires
+
{rec.completed ?? 0}
+
completed
) diff --git a/frontend/src/ui/DataTable.jsx b/frontend/src/ui/DataTable.jsx index 750bdbc..20c2c87 100644 --- a/frontend/src/ui/DataTable.jsx +++ b/frontend/src/ui/DataTable.jsx @@ -248,7 +248,10 @@ export default function DataTable({ columns, rows, pageSize = DEFAULT_PAGE_SIZE, key={row.id ?? i} className={onRowClick ? 'row-click' : undefined} tabIndex={onRowClick ? 0 : undefined} - onClick={onRowClick ? () => onRowClick(row) : undefined} + onClick={onRowClick ? (e) => { + if (e.target.closest('button, a, select, input, textarea, label, .row-actions')) return + onRowClick(row) + } : undefined} onKeyDown={onRowClick ? (e) => { if (e.key === 'Enter' && e.target === e.currentTarget) onRowClick(row) } : undefined} -- 2.40.1 From bb056456fc0a24f99d94ad852cec0dcaea7b4e1b Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Mon, 31 Aug 2026 00:27:27 +0500 Subject: [PATCH 5/5] the pipeline and dashboard synced --- backend/README.md | 2 +- backend/analytics/views.py | 21 ++++- backend/assessments/views.py | 21 ++--- backend/candidate_forms/views.py | 32 +++---- backend/g_sheet/app.py | 2 +- backend/g_sheet/models.py | 7 ++ backend/g_sheet/views.py | 21 +++-- backend/inbox/app.py | 19 ++-- backend/inbox/models.py | 104 +++++++++++++++++++--- backend/inbox/views.py | 54 +++++++---- backend/job/candidate/models.py | 51 +++++++++++ backend/job/candidate/views.py | 16 +--- backend/job/feedback/views.py | 19 +--- backend/job/history/views.py | 10 +-- backend/job/notes/views.py | 19 +--- backend/role/models.py | 10 +++ backend/tasks/views.py | 13 +-- backend/users/models.py | 15 ++++ frontend/src/api/analytics.js | 23 +++++ frontend/src/api/candidates.js | 3 +- frontend/src/api/inbox.js | 8 +- frontend/src/api/pipeline.js | 27 +++--- frontend/src/data/seed.js | 2 +- frontend/src/lib/charts.js | 1 + frontend/src/screens/Analytics.jsx | 6 +- frontend/src/screens/CandidateProfile.jsx | 5 +- frontend/src/screens/Candidates.jsx | 2 +- frontend/src/screens/Dashboard.jsx | 64 ++++++------- frontend/src/screens/Inbox.jsx | 40 +++++---- frontend/src/screens/Matching.jsx | 1 + frontend/src/screens/Pipeline.jsx | 5 ++ frontend/src/screens/RecruiterHub.jsx | 2 +- frontend/src/screens/Reports.jsx | 7 +- frontend/src/screens/TalentPool.jsx | 14 +-- frontend/src/styles/styles.css | 43 +++++++-- 35 files changed, 449 insertions(+), 240 deletions(-) diff --git a/backend/README.md b/backend/README.md index c65e4b7..1e1ea3c 100644 --- a/backend/README.md +++ b/backend/README.md @@ -515,7 +515,7 @@ All require `analytics.view`. Common query params: `from_date`, `to_date`, `depa |---|---|---| | GET | `/analytics/kpis/fetch` | The KPI cards, each with a prior-period comparison | | GET | `/analytics/hiring-trend/fetch?months=7` | Applications vs hires by month | -| GET | `/analytics/funnel/fetch` | Candidate count per stage | +| GET | `/analytics/funnel/fetch` | Candidate count per stage (inbox + manual-upload, same population as the pipeline board) | | GET | `/analytics/recruiter-performance/fetch?top=5` | Per recruiter: hires, open reqs, avg time-to-hire | | GET | `/analytics/source-performance/fetch` | Applications per source channel | diff --git a/backend/analytics/views.py b/backend/analytics/views.py index 17a5bc2..34872ce 100644 --- a/backend/analytics/views.py +++ b/backend/analytics/views.py @@ -10,7 +10,7 @@ from analytics.serializers import ( from inbox.enums import Candidate_application_Status from inbox.models import Inbox,Inbox_Messages,SourceChannels from job.assignment.models import JobAssignments -from job.candidate.models import ApplicationStageTransitions,Interviews +from job.candidate.models import ApplicationStageTransitions,Interviews,Manual_UPLOAD_CANDIDATE from job.cost.models import HiringCosts from job.job_post.enums import RequisitionStatus from job.job_post.models import JobPosts @@ -210,10 +210,27 @@ class Analytics: } async def get_funnel(self,from_date=None,to_date=None,department=None,recruiter_id=None): - counts=await Inbox_Messages.counts_by_application_status( + # Same two sources the pipeline board counts: inbox applications on an + # assigned job, plus manual-upload candidates. Counting only + # inbox_messages left Add Candidate rows (and anyone dragged to + # Interview there) invisible on the dashboard doughnut. + inbox_counts=await Inbox_Messages.counts_by_application_status( self.session,from_date=from_date,to_date=to_date, department=department,recruiter_id=recruiter_id, ) + manual_counts=await Manual_UPLOAD_CANDIDATE.counts_by_application_status( + self.session,from_date=from_date,to_date=to_date, + department=department,recruiter_id=recruiter_id, + ) + counts={stage.value:0 for stage in Candidate_application_Status} + pending=Candidate_application_Status.PENDING.value + for src in (inbox_counts,manual_counts): + for key,n in src.items(): + n=int(n or 0) + if key in counts: + counts[key]+=n + else: + counts[pending]+=n return [ serialize_stage_count(stage.value,counts.get(stage.value,0)) for stage in Candidate_application_Status diff --git a/backend/assessments/views.py b/backend/assessments/views.py index 750c3ce..cd917bf 100644 --- a/backend/assessments/views.py +++ b/backend/assessments/views.py @@ -4,9 +4,7 @@ from datetime import timezone import httpx from fastapi import HTTPException -from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload from assessments.models import Assessments, _now from assessments.serializers import serialize_assessment @@ -82,12 +80,7 @@ class Assessment: inbox_by_id = {} if inbox_ids: - result = await self.session.execute( - select(Inbox) - .options(selectinload(Inbox.messages), selectinload(Inbox.user)) - .where(Inbox.id.in_(inbox_ids)) - ) - inbox_by_id = {row.id: row for row in result.scalars().all()} + inbox_by_id = {row.id: row for row in await Inbox.get_by_ids(self.session, inbox_ids)} for row in inbox_by_id.values(): msg = row.messages if msg is not None and msg.assigned_job_post_id: @@ -95,10 +88,9 @@ class Assessment: manual_by_id = {} if manual_ids: - result = await self.session.execute( - select(Manual_UPLOAD_CANDIDATE).where(Manual_UPLOAD_CANDIDATE.id.in_(manual_ids)) - ) - manual_by_id = {row.id: row for row in result.scalars().all()} + manual_by_id = { + row.id: row for row in await Manual_UPLOAD_CANDIDATE.get_by_ids(self.session, manual_ids) + } for row in manual_by_id.values(): if row.job_post_id: job_ids.append(row.job_post_id) @@ -106,8 +98,9 @@ class Assessment: jobs_by_id = {} uids = [j for j in set(job_ids) if j] if uids: - result = await self.session.execute(select(JobPosts).where(JobPosts.id.in_(uids))) - jobs_by_id = {row.id: row for row in result.scalars().all()} + jobs_by_id = { + row.id: row for row in await JobPosts.get_by_ids(self.session, uids, active_only=False) + } return inbox_by_id, manual_by_id, jobs_by_id def _labels(self, row, inbox_by_id, manual_by_id, jobs_by_id): diff --git a/backend/candidate_forms/views.py b/backend/candidate_forms/views.py index fffdd92..b0d93b9 100644 --- a/backend/candidate_forms/views.py +++ b/backend/candidate_forms/views.py @@ -3,9 +3,7 @@ import uuid from datetime import timezone from fastapi import HTTPException -from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload from candidate_forms.models import CandidateForms, _now from candidate_forms.plugins import ( @@ -138,12 +136,7 @@ class CandidateForm: inbox_by_id = {} if inbox_ids: - result = await self.session.execute( - select(Inbox) - .options(selectinload(Inbox.messages), selectinload(Inbox.user)) - .where(Inbox.id.in_(inbox_ids)) - ) - inbox_by_id = {row.id: row for row in result.scalars().all()} + inbox_by_id = {row.id: row for row in await Inbox.get_by_ids(self.session, inbox_ids)} for row in inbox_by_id.values(): msg = row.messages if msg is not None and msg.assigned_job_post_id: @@ -151,10 +144,9 @@ class CandidateForm: manual_by_id = {} if manual_ids: - result = await self.session.execute( - select(Manual_UPLOAD_CANDIDATE).where(Manual_UPLOAD_CANDIDATE.id.in_(manual_ids)) - ) - manual_by_id = {row.id: row for row in result.scalars().all()} + manual_by_id = { + row.id: row for row in await Manual_UPLOAD_CANDIDATE.get_by_ids(self.session, manual_ids) + } for row in manual_by_id.values(): if row.job_post_id: job_ids.append(row.job_post_id) @@ -162,17 +154,13 @@ class CandidateForm: jobs_by_id = {} uids = [j for j in set(job_ids) if j] if uids: - result = await self.session.execute(select(JobPosts).where(JobPosts.id.in_(uids))) - jobs_by_id = {row.id: row for row in result.scalars().all()} + jobs_by_id = { + row.id: row for row in await JobPosts.get_by_ids(self.session, uids, active_only=False) + } user_ids = {r.interviewer_id for r in rows if r.interviewer_id} user_ids |= {r.created_by for r in rows if r.created_by} - users_by_id = {} - if user_ids: - result = await self.session.execute( - select(Users.id, Users.name).where(Users.id.in_(user_ids)) - ) - users_by_id = {uid: name for uid, name in result.all()} + users_by_id = await Users.names_by_ids(self.session, user_ids) return inbox_by_id, manual_by_id, jobs_by_id, users_by_id def _labels(self, row, inbox_by_id, manual_by_id, jobs_by_id): @@ -210,8 +198,8 @@ class CandidateForm: row, candidate_name=name, job_title=title, - interviewer_name=users_by_id.get(row.interviewer_id), - created_by_name=users_by_id.get(row.created_by), + interviewer_name=users_by_id.get(str(row.interviewer_id)) if row.interviewer_id else None, + created_by_name=users_by_id.get(str(row.created_by)) if row.created_by else None, ) ) return out diff --git a/backend/g_sheet/app.py b/backend/g_sheet/app.py index a3a97f4..2b399ad 100644 --- a/backend/g_sheet/app.py +++ b/backend/g_sheet/app.py @@ -290,7 +290,7 @@ async def set_form_processing_state( ): try: service=SheetFormData(session=session) - data=await service.set_processing_state(record_id,payload.processing_state) + data=await service.set_processing_state(record_id,payload.processing_state,current_user) return JSONResponse(content={"data":data,"total":1,"status_code":200}) except HTTPException: raise diff --git a/backend/g_sheet/models.py b/backend/g_sheet/models.py index 6e2d018..69a1f97 100644 --- a/backend/g_sheet/models.py +++ b/backend/g_sheet/models.py @@ -416,6 +416,13 @@ class SheetImportRun(SQLModel, table=True): ) return result.scalars().first() + @classmethod + async def get_latest(cls, session: AsyncSession): + result = await session.execute( + select(cls).order_by(cls.created_at.desc()).limit(1) + ) + return result.scalars().first() + @classmethod async def insert_run(cls, session: AsyncSession, fields: dict, *, commit: bool = True): row = cls(**fields) diff --git a/backend/g_sheet/views.py b/backend/g_sheet/views.py index eb94994..80e1fe7 100644 --- a/backend/g_sheet/views.py +++ b/backend/g_sheet/views.py @@ -319,11 +319,7 @@ class SheetImport(SheetRead): row=await SheetImportRun.get_active(session) if row: return serialize_import_run(row) - from sqlmodel import select - result=await session.execute( - select(SheetImportRun).order_by(SheetImportRun.created_at.desc()).limit(1) - ) - row=result.scalars().first() + row=await SheetImportRun.get_latest(session) if not row: raise HTTPException(status_code=404,detail="No import runs yet") return serialize_import_run(row) @@ -411,7 +407,7 @@ class SheetFormData(Sheet): await self._promote_to_application(updated) return await self.get_form_data_by_id(record_id) - async def set_processing_state(self,record_id,processing_state): + async def set_processing_state(self,record_id,processing_state,current_user=None): allowed=("unread","imported","processed","rejected") if processing_state not in allowed: raise HTTPException(status_code=422,detail=f"processing_state must be one of {', '.join(allowed)}") @@ -423,7 +419,18 @@ class SheetFormData(Sheet): if processing_state=="processed": if not row.job_post_id: raise HTTPException(status_code=422,detail="Assign a job post before moving to shortlist") - await self._promote_to_application(row) + promoted=await self._promote_to_application(row) + status=(getattr(promoted,"status",None) or "").strip() + if promoted is not None and status in ("","CLOSED","PROCESS","BANKED","REJECTED"): + from job.pipeline.views import Pipeline + try: + await Pipeline(session).change_stage( + "PENDING",current_user,manual_upload_id=promoted.id, + change_reason="Moved to shortlist from sheet forms", + ) + except HTTPException as exc: + if exc.status_code!=400: + raise updated=await FormData.set_processing_state(session,record_id,processing_state) if not updated: raise HTTPException(status_code=404,detail="Form data not found") diff --git a/backend/inbox/app.py b/backend/inbox/app.py index 3f05929..765c22e 100644 --- a/backend/inbox/app.py +++ b/backend/inbox/app.py @@ -48,6 +48,7 @@ class ReadAllBody(BaseModel): application_status: Candidate_application_Status = Candidate_application_Status.CLOSED assigned: bool | None = None is_duplicate: bool | None = None + processing_state: str | None = None class TriageOverrideBody(BaseModel): @@ -251,6 +252,7 @@ async def mark_all_inbox_read( application_status=payload.application_status, assigned=payload.assigned, is_duplicate=payload.is_duplicate, + processing_state=payload.processing_state, ) return JSONResponse(content={"data":data,"total":data["updated"],"status_code":200}) except HTTPException: @@ -283,6 +285,7 @@ async def get_all_applications( assigned: bool | None = Query(default=None), is_duplicate: bool | None = Query(default=None), no_suggestions: bool | None = Query(default=None), + processing_state: str | None = Query(default=None), search: str | None = Query(None), # Caller-chosen page size (Inbox / Job Matching send 10/25/50/100). None = unpaged. top: int | None = Query(None, ge=1, le=500), @@ -293,20 +296,20 @@ async def get_all_applications( try: service=Email(session=session) - if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED): - items=await service.get_all_applications(top, skip, search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions) - total=await service.count_inbox_messages(search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions) + 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) or processing_state: + items=await service.get_all_applications(top, skip, search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state) + total=await service.count_inbox_messages(search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state) return JSONResponse(content={"data":items,"total":total,"status_code":200}) if isread==False: - items=await service.get_all_applications(top, skip, search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions) - total=await service.count_inbox_messages(search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions) + items=await service.get_all_applications(top, skip, search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state) + total=await service.count_inbox_messages(search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state) 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,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions) - total=await service.count_inbox_messages(search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions) + items=await service.get_all_applications(top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state) + total=await service.count_inbox_messages(search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state) return JSONResponse(content={"data":items,"total":total,"status_code":200}) except HTTPException: raise @@ -392,7 +395,7 @@ async def set_processing_state( ): try: service=Email(session=session) - data=await service.set_processing_state(record_id,payload.processing_state) + data=await service.set_processing_state(record_id,payload.processing_state,current_user) return JSONResponse(content={"data":data,"total":1,"status_code":200}) except HTTPException: raise diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 3b9af02..e65f1eb 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -106,6 +106,7 @@ class Inbox(SQLModel, table=True): .join(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id) .outerjoin(AtsResults,cls.ats_id==AtsResults.id) .where(Inbox_Messages.assigned_job_post_id.is_not(None)) + .where(Inbox_Messages.attachment==True) # noqa: E712 .where(Roles.role_name==EnumRoles.CANDIDATE.value) # Newest-first is the list contract; score is only a tiebreak # within the same instant. id keeps paging stable. @@ -185,6 +186,7 @@ class Inbox(SQLModel, table=True): .join(Inbox_Messages,cls.message_id==Inbox_Messages.id) .join(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id) .where(Inbox_Messages.assigned_job_post_id.is_not(None)) + .where(Inbox_Messages.attachment==True) # noqa: E712 .where(Roles.role_name==EnumRoles.CANDIDATE.value) .group_by(Inbox_Messages.application_status) ) @@ -328,6 +330,23 @@ class Inbox(SQLModel, table=True): result=await session.execute(select(cls).where(cls.id==iid)) return result.scalars().first() + @classmethod + async def get_by_ids(cls,session:AsyncSession,ids): + keys=[] + for raw in ids or []: + try: + keys.append(int(raw)) + except (TypeError,ValueError): + continue + if not keys: + return [] + result=await session.execute( + select(cls) + .options(selectinload(cls.messages),selectinload(cls.user)) + .where(cls.id.in_(keys)) + ) + return list(result.scalars().all()) + @classmethod async def get_inbox_with_message(cls,session:AsyncSession,record_id:int|str|None): """Inbox row with `messages` selectin-loaded for stage / application writers.""" @@ -758,6 +777,7 @@ class Inbox_Messages(SQLModel, table=True): assigned: bool | None=None, is_duplicate: bool | None=None, no_suggestions: bool | None=None, + processing_state: str | None=None, ): """The one WHERE chain shared by the list, the count and the bulk read UPDATE. @@ -787,11 +807,16 @@ class Inbox_Messages(SQLModel, table=True): func.jsonb_array_length(cls.suggested_job_post_ids) == 0, ) ) + if processing_state: + statement = statement.where(cls.processing_state == processing_state) + # Inbox / Job Matching only list applications that arrived with a file. + # Graph hasAttachments lands on this column; body-only mail stays out. + statement = statement.where(cls.attachment == True) # noqa: E712 return statement @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, assigned: bool | None=None, is_duplicate: bool | None=None, no_suggestions: bool | None=None + 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, is_duplicate: bool | None=None, no_suggestions: bool | None=None, processing_state: str | None=None ): # Page size is the caller's `top` (Inbox sends 10/25/50/100); `skip` is # (page-1)*top so page 1 of 25 -> 0..24, page 2 -> 25..49. Newest first @@ -799,7 +824,7 @@ class Inbox_Messages(SQLModel, table=True): statement = cls._apply_filters( select(cls).order_by(cls.created_at.desc()), search, isread, application_status, assigned, is_duplicate, - no_suggestions, + no_suggestions, processing_state, ) if skip: statement = statement.offset(skip) @@ -867,11 +892,11 @@ class Inbox_Messages(SQLModel, table=True): return {str(job_id): int(n) for job_id, n in result.all()} @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, is_duplicate: bool | None=None, no_suggestions: bool | None=None): + 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, is_duplicate: bool | None=None, no_suggestions: bool | None=None, processing_state: str | None=None): statement = cls._apply_filters( select(func.count()).select_from(cls), search, isread, application_status, assigned, is_duplicate, - no_suggestions, + no_suggestions, processing_state, ) result = await session.execute(statement) return result.scalar_one() @@ -975,6 +1000,7 @@ class Inbox_Messages(SQLModel, table=True): application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None, + processing_state: str | None=None, ) -> int: """Mark every row matching a list filter. Returns rows actually CHANGED. @@ -983,7 +1009,7 @@ class Inbox_Messages(SQLModel, table=True): was already read. It also keeps read_overridden_at off rows nobody decided anything about, so the Outlook sweep keeps its reach over untouched mail. """ - statement=cls._apply_filters(update(cls),search,isread,application_status,assigned,is_duplicate) + statement=cls._apply_filters(update(cls),search,isread,application_status,assigned,is_duplicate,processing_state=processing_state) statement=statement.where(cls.message_read!=bool(read)) result=await session.execute( statement.values(message_read=bool(read),read_overridden_at=_now()) @@ -997,12 +1023,12 @@ class Inbox_Messages(SQLModel, table=True): func.count().label("all_count"), func.coalesce(func.sum(case((cls.message_read == False, 1), else_=0)), 0).label("unread"), # noqa: E712 func.coalesce(func.sum(case((cls.processing_state == "imported", 1), else_=0)), 0).label("imported"), - func.coalesce(func.sum(case((cls.application_status == Candidate_application_Status.PROCESS, 1), else_=0)), 0).label("processed"), - func.coalesce(func.sum(case((cls.application_status == Candidate_application_Status.REJECTED, 1), else_=0)), 0).label("rejected"), + func.coalesce(func.sum(case((cls.processing_state == "processed", 1), else_=0)), 0).label("processed"), + func.coalesce(func.sum(case((cls.processing_state == "rejected", 1), else_=0)), 0).label("rejected"), func.coalesce(func.sum(case((cls.is_duplicate == True, 1), else_=0)), 0).label("duplicates"), # noqa: E712 func.coalesce(func.sum(case((cls.assigned_job_post_id.is_not(None), 1), else_=0)), 0).label("assigned"), func.coalesce(func.sum(case((cls.assigned_job_post_id.is_(None), 1), else_=0)), 0).label("unassigned"), - ) + ).where(cls.attachment == True) # noqa: E712 row = (await session.execute(statement)).one() return { "all": int(row.all_count or 0), @@ -1021,6 +1047,15 @@ class Inbox_Messages(SQLModel, table=True): if not row: return None row.processing_state = processing_state + # Pipeline Shortlist reads application_status=PENDING. CLOSED is the + # inbox default and maps to Rejected on the board — leaving it unchanged + # here is why "Move to Shortlist" never landed in Shortlist. + current = row.application_status + current_val = current.value if isinstance(current, Candidate_application_Status) else str(current or "") + if processing_state == "processed" and current_val in ("", "CLOSED", "PROCESS"): + row.application_status = Candidate_application_Status.PENDING + elif processing_state == "rejected": + row.application_status = Candidate_application_Status.REJECTED session.add(row) await session.commit() await session.refresh(row) @@ -1118,9 +1153,37 @@ class Inbox_Messages(SQLModel, table=True): async def counts_by_application_status( cls, session: AsyncSession, from_date=None, to_date=None, department=None, recruiter_id=None, ): - statement = select(cls.application_status, func.count().label("count")).select_from(cls) - statement = cls.scoped_to_job(statement, department, recruiter_id) - statement = cls._window_by_inbox(statement, from_date, to_date) + """Current-stage counts for the same inbox population the pipeline board uses. + + Assigned-to-a-job, candidate-role only — Inbox.count_by_status without a + job filter. Optional department / recruiter / created_at window sit on + top of that; with none of those this matches the board's inbox column. + """ + from job.job_post.models import JobPosts + statement = ( + select(cls.application_status, func.count().label("count")) + .select_from(Inbox) + .join(Users, Inbox.user_id == Users.id) + .join(Roles, Users.role_id == Roles.id) + .join(cls, Inbox.message_id == cls.id) + .join(JobPosts, cls.assigned_job_post_id == JobPosts.id) + .where(cls.assigned_job_post_id.is_not(None)) + .where(Roles.role_name == EnumRoles.CANDIDATE.value) + ) + if from_date is not None: + statement = statement.where(Inbox.created_at >= from_date) + if to_date is not None: + statement = statement.where(Inbox.created_at < to_date) + if department: + statement = statement.where(JobPosts.department == department) + try: + rid = uuid.UUID(str(recruiter_id)) if recruiter_id not in (None, "") else None + except (TypeError, ValueError): + rid = None + if rid is not None: + statement = statement.where( + or_(cls.recruiter_id == rid, JobPosts.current_recruiter_id == rid) + ) statement = statement.group_by(cls.application_status) result = await session.execute(statement) counts = {} @@ -1386,6 +1449,18 @@ class AtsResults(SQLModel, table=True): ) return result.scalars().first() + @classmethod + async def get_by_ids(cls, session: AsyncSession, ids): + keys = [] + for raw in ids or []: + uid = cls._as_uuid(raw) + if uid is not None: + keys.append(uid) + if not keys: + return [] + result = await session.execute(select(cls).where(cls.id.in_(keys))) + return list(result.scalars().all()) + @classmethod async def get_for_inbox_job(cls, session: AsyncSession, inbox_id, job_post_id): """Any score for this application against this job — current or superseded. @@ -1534,6 +1609,13 @@ class MailboxSyncRun(SQLModel, table=True): ) return result.scalars().first() + @classmethod + async def get_latest(cls, session: AsyncSession): + result = await session.execute( + select(cls).order_by(cls.created_at.desc()).limit(1) + ) + return result.scalars().first() + @classmethod async def insert_run(cls, session: AsyncSession, fields: dict, *, commit: bool = True): row = cls(**fields) diff --git a/backend/inbox/views.py b/backend/inbox/views.py index 4b85e1f..956da03 100644 --- a/backend/inbox/views.py +++ b/backend/inbox/views.py @@ -273,13 +273,13 @@ class Email: 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,assigned=None,is_duplicate=None,no_suggestions=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,is_duplicate=is_duplicate,no_suggestions=no_suggestions) + async def get_all_applications(self,top,skip,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=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) or processing_state: + messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state) elif isread==False: - messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,isread,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions) + messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,isread,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state) else: - messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions) + messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state) urls=await Inbox.linkedin_urls_by_message_ids(self.session,[m.id for m in messages]) return [serialize_application(m,linkedin_url=urls.get(m.id)) for m in messages] @@ -351,11 +351,7 @@ class Email: if row: return serialize_mailbox_sync_run(row) # Latest finished run so the UI can still show the last result after refresh. - from sqlmodel import select - result=await self.session.execute( - select(MailboxSyncRun).order_by(MailboxSyncRun.created_at.desc()).limit(1) - ) - row=result.scalars().first() + row=await MailboxSyncRun.get_latest(self.session) if not row: raise HTTPException(status_code=404,detail="No sync runs yet") return serialize_mailbox_sync_run(row) @@ -431,13 +427,13 @@ 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,assigned=None,is_duplicate=None,no_suggestions=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,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions) + async def count_inbox_messages(self,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None): + if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED or processing_state: + return await Inbox_Messages.count_inbox_messages(self.session,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state) elif isread==False: - return await Inbox_Messages.count_inbox_messages(self.session,search,isread=False,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions) + return await Inbox_Messages.count_inbox_messages(self.session,search,isread=False,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state) else: - return await Inbox_Messages.count_inbox_messages(self.session,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions) + return await Inbox_Messages.count_inbox_messages(self.session,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state) async def assign_job_post(self,record_id,job_post_id): message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id) @@ -495,7 +491,7 @@ class Email: async def set_read_all(self,read,search=None,isread:bool=True, application_status:Candidate_application_Status=Candidate_application_Status.CLOSED, - assigned=None,is_duplicate=None): + assigned=None,is_duplicate=None,processing_state=None): """Mark every row the SAME filter set would have listed. The filter arguments are the caller's current view, not a free-form query: the @@ -505,6 +501,7 @@ class Email: updated=await Inbox_Messages.set_read_scope( self.session,read,search=search,isread=isread, application_status=application_status,assigned=assigned,is_duplicate=is_duplicate, + processing_state=processing_state, ) logger.info("scope read: updated=%s read=%s isread=%s assigned=%s status=%s dup=%s", updated,bool(read),isread,assigned,getattr(application_status,"value",application_status),is_duplicate) @@ -531,10 +528,33 @@ class Email: async def get_counts(self): return await Inbox_Messages.count_processing(self.session) - async def set_processing_state(self,record_id,processing_state): + async def set_processing_state(self,record_id,processing_state,current_user=None): allowed=("unread","imported","processed","rejected") if processing_state not in allowed: raise HTTPException(status_code=422,detail=f"processing_state must be one of {', '.join(allowed)}") + if processing_state=="processed": + existing=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id) + if not existing: + raise HTTPException(status_code=404,detail="Message not found") + if not existing.assigned_job_post_id: + raise HTTPException(status_code=422,detail="Assign a job post before moving to shortlist") + # Record CLOSED/PROCESS → PENDING so the board history matches the card. + current=existing.application_status + current_val=current.value if isinstance(current,Candidate_application_Status) else str(current or "") + if current_val in ("","CLOSED","PROCESS"): + link=await Inbox.get_inbox_by_message_id(self.session,existing.id) + if link is not None: + from job.pipeline.views import Pipeline + try: + await Pipeline(self.session).change_stage( + Candidate_application_Status.PENDING.value, + current_user, + inbox_id=link.id, + change_reason="Moved to shortlist from inbox", + ) + except HTTPException as exc: + if exc.status_code!=400: + raise message=await Inbox_Messages.set_processing_state(self.session,record_id,processing_state) if not message: raise HTTPException(status_code=404,detail="Message not found") diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index cf90ff1..62ac1c6 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -175,6 +175,45 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): return counts except Exception as e: raise HTTPException(status_code=500,detail=str(e)) + + @classmethod + async def counts_by_application_status( + cls, session: AsyncSession, from_date=None, to_date=None, department=None, recruiter_id=None, + ): + """Current-stage counts for the same manual-upload population the board uses. + + Inner-joined to a job post, same as count_by_status. Optional department / + recruiter / created_at window sit on top; with none of those this matches + the board's manual_upload column. + """ + from users.models import Users + from job.job_post.models import JobPosts + qry=( + select(cls.status,func.count()) + .select_from(cls) + .join(Users,cls.user_id==Users.id) + .join(JobPosts,cls.job_post_id==JobPosts.id) + ) + if from_date is not None: + qry=qry.where(cls.created_at>=from_date) + if to_date is not None: + qry=qry.where(cls.created_at uuid.UUID | None: if record_id in (None, ""): @@ -290,6 +329,18 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): result = await session.execute(select(cls).where(cls.id == uid)) return result.scalars().first() + @classmethod + async def get_by_ids(cls, session: AsyncSession, ids): + keys = [] + for raw in ids or []: + uid = cls._as_uuid(raw) + if uid is not None: + keys.append(uid) + if not keys: + return [] + result = await session.execute(select(cls).where(cls.id.in_(keys))) + return list(result.scalars().all()) + @classmethod async def get_by_email_and_job(cls, session: AsyncSession, email: str, job_post_id): """Idempotency for form / re-import promotes against the same role.""" diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index 6fdddd5..4fa0488 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -5,9 +5,6 @@ from pathlib import Path from dotenv import load_dotenv from fastapi import HTTPException from pypdf import PdfReader -from sqlalchemy import select -from sqlalchemy.orm import selectinload -from sqlmodel import true from app.core.errors import ATSError,ErrorCode from app.models.scoring import CompletedCandidate from app.services.pdf import extract_resume,sanitize_filename @@ -988,22 +985,13 @@ class CandidateView: 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()] + notes=[serialize_note(r) for r in await Notes.get_notes_by_user(self.session,uid)] # ATS score from inbox denorm / ats_results via Inbox.ats_id — never from # a Candidates join on message id. Keywords live on the scored Candidates # row: candidate_id when set, else email+job for the matched-user path. ats_ids=[r.ats_id for r in records if getattr(r,"ats_id",None)] - ats_rows=[] - if ats_ids: - result=await self.session.execute(select(AtsResults).where(AtsResults.id.in_(ats_ids))) - ats_rows=list(result.scalars().all()) + ats_rows=await AtsResults.get_by_ids(self.session,ats_ids) if ats_ids else [] assigned_uid=AtsResults._as_uuid(base.get("assigned_job_post_id")) if base.get("assigned_job_post_id") else None chosen=None if assigned_uid is not None: diff --git a/backend/job/feedback/views.py b/backend/job/feedback/views.py index 6af94e6..0f6f7f6 100644 --- a/backend/job/feedback/views.py +++ b/backend/job/feedback/views.py @@ -1,7 +1,5 @@ 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.models import FeedbackTemplates @@ -15,13 +13,7 @@ class FeedbackView: 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() + return await Feedback.get_feedback_by_id(self.session,record_id) async def get_feedback(self,feedback_id=None,inbox_id=None): if feedback_id: @@ -31,13 +23,8 @@ class FeedbackView: 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()] + rows=await Feedback.get_feedback_by_inbox(self.session,int(inbox_id)) + return [serialize_feedback(r) for r in rows] async def create_feedback(self,payload,current_user): fields={ diff --git a/backend/job/history/views.py b/backend/job/history/views.py index 7d204be..385c971 100644 --- a/backend/job/history/views.py +++ b/backend/job/history/views.py @@ -2,7 +2,6 @@ import logging from datetime import datetime from sqlalchemy.ext.asyncio import AsyncSession -from sqlmodel import select from inbox.models import Inbox from job.candidate.models import CandidateHistory, Manual_UPLOAD_CANDIDATE @@ -117,10 +116,5 @@ class HistoryRecorder: self.session, user_id, limit=limit, offset=offset ) actor_ids = {r.actor_id for r in rows if r.actor_id} - names = {} - if actor_ids: - result = await self.session.execute( - select(Users.id, Users.name).where(Users.id.in_(actor_ids)) - ) - names = {uid: name for uid, name in result.all()} - return [serialize_history(r, actor_name=names.get(r.actor_id)) for r in rows], total + names = await Users.names_by_ids(self.session, actor_ids) + return [serialize_history(r, actor_name=names.get(str(r.actor_id))) for r in rows], total diff --git a/backend/job/notes/views.py b/backend/job/notes/views.py index bb2498b..77923a5 100644 --- a/backend/job/notes/views.py +++ b/backend/job/notes/views.py @@ -1,7 +1,5 @@ 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.history.enums import HistoryEvent @@ -14,13 +12,7 @@ class Note: 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() + return await Notes.get_note_by_id(self.session,record_id) async def get_note(self,note_id=None,user_id=None): if note_id: @@ -33,13 +25,8 @@ class Note: 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()] + rows=await Notes.get_notes_by_user(self.session,uid) + return [serialize_note(r) for r in rows] async def create_note(self,payload,current_user): fields={ diff --git a/backend/role/models.py b/backend/role/models.py index 2d06382..3a25b45 100644 --- a/backend/role/models.py +++ b/backend/role/models.py @@ -258,6 +258,16 @@ class Roles(SQLModel, table=True): result = await session.execute(statement) return result.scalars().first() + @classmethod + async def get_by_names(cls, session: AsyncSession, names): + keys = [n for n in (names or []) if n] + if not keys: + return [] + result = await session.execute( + select(cls).where(cls.role_name.in_(keys), cls.is_deleted == False) # noqa: E712 + ) + return list(result.scalars().all()) + @classmethod async def count_roles(cls, session: AsyncSession, search: str | None): statement = ( diff --git a/backend/tasks/views.py b/backend/tasks/views.py index b34c4d1..6148a6a 100644 --- a/backend/tasks/views.py +++ b/backend/tasks/views.py @@ -2,9 +2,7 @@ import uuid from datetime import timezone from fastapi import HTTPException -from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload from role.models import Roles from tasks.models import Tasks @@ -55,10 +53,7 @@ class Task: ids = [i for i in set(ids) if i] if not ids: return {} - result = await self.session.execute( - select(Users).options(selectinload(Users.role)).where(Users.id.in_(ids)) - ) - return {u.id: u for u in result.scalars().all()} + return {u.id: u for u in await Users.get_by_ids(self.session, ids)} async def _validate_assignee(self, assignee_id): """Assignees must be recruiter-role accounts (role resolved from the DB): @@ -78,10 +73,8 @@ class Task: """Creation is limited to system admin / HR admin / recruiter. The role ids are looked up from the roles table, and the permission-tag guard on the route (tasks.create) still applies on top of this.""" - result = await self.session.execute( - select(Roles).where(Roles.role_name.in_(CREATOR_ROLES)) - ) - allowed_ids = {r.id for r in result.scalars().all()} + rows = await Roles.get_by_names(self.session, CREATOR_ROLES) + allowed_ids = {r.id for r in rows} if current_user.get("role_id") not in allowed_ids: raise HTTPException( status_code=403, diff --git a/backend/users/models.py b/backend/users/models.py index eecf6b1..6128002 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -150,6 +150,21 @@ class Users(SQLModel, table=True): ) return {str(uid): name for uid, name in result.all()} + @classmethod + async def get_by_ids(cls, session: AsyncSession, ids): + """Users with role selectin-loaded. UUID keys so callers can map by row.assignee_id.""" + uids = [] + for raw in ids or []: + uid = cls._as_uuid(raw) + if uid is not None: + uids.append(uid) + if not uids: + return [] + result = await session.execute( + select(cls).options(selectinload(cls.role)).where(cls.id.in_(uids)) + ) + return list(result.scalars().all()) + @classmethod async def get_user_by_id(cls, session: AsyncSession, record_id: str): uid = cls._as_uuid(record_id) diff --git a/frontend/src/api/analytics.js b/frontend/src/api/analytics.js index 746ed4c..7eed04c 100644 --- a/frontend/src/api/analytics.js +++ b/frontend/src/api/analytics.js @@ -1,4 +1,5 @@ import { request } from '../lib/apiClient' +import { toStageCounts } from './pipeline' /** * Dashboard analytics aggregates — backend/analytics/app.py. @@ -27,6 +28,28 @@ export function funnel({ fromDate, toDate, department, recruiterId } = {}) { }) } +/** Board column order used by the pipeline page. Rejected is last so callers + * that drop outcomes can slice it off without re-sorting. */ +const BOARD_STAGE_ORDER = [ + 'Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', + 'Approved', 'Hired', 'On Hold', 'Rejected', +] + +/** + * Fold /analytics/funnel/fetch rows (11 enum statuses) onto the pipeline + * columns. Same mapping the board uses, so CLOSED is Rejected and ONHOLD / + * APPROVED keep their own columns rather than folding into Screening / Hired. + */ +export function toBoardStageRows(funnelRows, { includeRejected = false } = {}) { + const folded = toStageCounts( + Object.fromEntries((funnelRows || []).map((r) => [r.stage, r.count || 0])), + ) + const order = includeRejected + ? BOARD_STAGE_ORDER + : BOARD_STAGE_ORDER.filter((s) => s !== 'Rejected') + return order.map((stage) => ({ stage, count: folded[stage] || 0 })) +} + export function hiringTrend({ months = 7, fromDate, toDate, department, recruiterId } = {}) { return request('/analytics/hiring-trend/fetch', { params: { diff --git a/frontend/src/api/candidates.js b/frontend/src/api/candidates.js index 86f0df2..759274e 100644 --- a/frontend/src/api/candidates.js +++ b/frontend/src/api/candidates.js @@ -13,6 +13,7 @@ ============================================================ */ import { downloadFile, fetchBlobUrl, request } from '../lib/apiClient' +import { STATUS_FROM_STAGE } from './pipeline' /** Active job posts for pickers. Needs job_board.view OR candidates.view. * @@ -318,7 +319,7 @@ export function createManual({ put('current_position', currentPosition) put('platform', source) put('experience', experience) - put('status', stage) + put('status', STATUS_FROM_STAGE[stage] || stage) put('referral_by', referralBy) return request('/candidate/create/candidate', { method: 'POST', body: form }) } diff --git a/frontend/src/api/inbox.js b/frontend/src/api/inbox.js index fed258d..6cc2dbc 100644 --- a/frontend/src/api/inbox.js +++ b/frontend/src/api/inbox.js @@ -20,7 +20,7 @@ export function listMessages() { * `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, assigned, isDuplicate, noSuggestions } = {}) { +export function listApplications({ search, top, skip, recordId, isread, applicationStatus, assigned, isDuplicate, noSuggestions, processingState } = {}) { 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 @@ -30,6 +30,8 @@ export function listApplications({ search, top, skip, recordId, isread, applicat // Same for `is_duplicate`: omit unless the Duplicates tab. // `no_suggestions`: Job Matching "No suggestions" tab — unassigned + empty // suggested_job_post_ids. Omit unless that tab. + // `processing_state`: Processed / Rejected tabs (Move to Shortlist writes + // processed, not application_status PROCESS). params: { search, top, @@ -40,6 +42,7 @@ export function listApplications({ search, top, skip, recordId, isread, applicat assigned, is_duplicate: isDuplicate, no_suggestions: noSuggestions, + processing_state: processingState, }, }) } @@ -122,7 +125,7 @@ export function bulkSetRead(recordIds, read) { * Resolves to `{updated, read}`, where `updated` counts rows that actually * CHANGED state, so it is safe to show in a toast. */ -export function setReadAll({ read, search, isread, applicationStatus, assigned, isDuplicate } = {}) { +export function setReadAll({ read, search, isread, applicationStatus, assigned, isDuplicate, processingState } = {}) { return request('/inbox/read-all', { method: 'PATCH', body: { @@ -132,6 +135,7 @@ export function setReadAll({ read, search, isread, applicationStatus, assigned, application_status: applicationStatus, assigned, is_duplicate: isDuplicate, + processing_state: processingState, }, }) } diff --git a/frontend/src/api/pipeline.js b/frontend/src/api/pipeline.js index dea298b..727d785 100644 --- a/frontend/src/api/pipeline.js +++ b/frontend/src/api/pipeline.js @@ -15,40 +15,43 @@ import { request } from '../lib/apiClient' /** * Candidate_application_Status (backend/inbox/enums.py) -> the board column. * - * The enum has 11 values and the board 7 columns, so this is deliberately - * many-to-one: CLOSED is the column DEFAULT (untriaged, never mailed anywhere) - * and reads as Shortlist rather than as an outcome, ONHOLD parks in Screening, and - * APPROVED is the pre-HIRED spelling of a hire. + * The enum has 11 values. Approved and On Hold are first-class columns (they + * used to be folded into Hired / Screening). CLOSED is the inbox default for + * an application that did not progress — it reads as Rejected, same as the + * board's Rejected column, not as Shortlist. * * Anything unmapped falls through to Shortlist rather than vanishing from the * board — a card with no column is a candidate nobody sees. */ export const STAGE_FROM_STATUS = { PENDING: 'Shortlist', - CLOSED: 'Shortlist', PROCESS: 'Screening', - ONHOLD: 'Screening', SCREENING: 'Screening', + ONHOLD: 'On Hold', ASSESSMENT: 'Assessment', INTERVIEW: 'Interview', OFFER: 'Offer', + APPROVED: 'Approved', HIRED: 'Hired', - APPROVED: 'Hired', + CLOSED: 'Rejected', REJECTED: 'Rejected', } /** - * Column -> the status WRITTEN on a drop. Not the inverse of the map above: the - * legacy spellings (PROCESS, ONHOLD, APPROVED, CLOSED) are readable but are - * never written, so the vocabulary converges on the canonical value as cards get - * moved. Shortlist writes PENDING because the enum has no SHORTLIST member. + * Column -> the status WRITTEN on a drop. Not the inverse of the map above: + * PROCESS is readable as Screening but is never written, so the vocabulary + * converges on the canonical value as cards get moved. Shortlist writes + * PENDING because the enum has no SHORTLIST member. Rejected writes REJECTED + * (not CLOSED) so new drops are distinguishable from the inbox default. */ export const STATUS_FROM_STAGE = { Shortlist: 'PENDING', Screening: 'SCREENING', + 'On Hold': 'ONHOLD', Assessment: 'ASSESSMENT', Interview: 'INTERVIEW', Offer: 'OFFER', + Approved: 'APPROVED', Hired: 'HIRED', Rejected: 'REJECTED', } @@ -84,7 +87,7 @@ export function listApplications({ jobId, limit, offset } = {}) { } /** - * Fold the 11 status counts into the 7 board columns. Unmapped keys (UNKNOWN) + * Fold the 11 status counts into the board columns. Unmapped keys (UNKNOWN) * land in Shortlist, same as STAGE_FROM_STATUS's card fallback. */ export function toStageCounts(byStatus) { diff --git a/frontend/src/data/seed.js b/frontend/src/data/seed.js index 316e9c7..af88575 100644 --- a/frontend/src/data/seed.js +++ b/frontend/src/data/seed.js @@ -33,7 +33,7 @@ export const TODAY = new Date('2026-07-09T09:00:00'); const grades = ['L2', 'L3', 'L4', 'L5', 'L6', 'L7']; const jobStatuses = ['Open', 'On Hold', 'Closed', 'Draft']; const educationLevels = ["Bachelor's Degree", "Master's Degree", "PhD", "Associate Degree", "High School"]; - const stages = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired', 'Rejected']; + const stages = ['Shortlist', 'Screening', 'On Hold', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired', 'Rejected']; const sources = ['LinkedIn', 'Company Site', 'Referral', 'Indeed', 'Job Fair', 'Agency', 'GitHub', 'AngelList']; const companies = ['Stripe', 'Airbnb', 'Datadog', 'Notion', 'Figma', 'Shopify', 'Snowflake', 'Twilio', 'Coinbase', 'Atlassian', 'Asana', 'Ramp', 'Brex', 'Vercel', 'Retool', 'Amplitude', 'Segment', 'MongoDB', 'HashiCorp', 'Cloudflare']; diff --git a/frontend/src/lib/charts.js b/frontend/src/lib/charts.js index 08f73d8..3628404 100644 --- a/frontend/src/lib/charts.js +++ b/frontend/src/lib/charts.js @@ -286,6 +286,7 @@ function css(name) { return getComputedStyle(document.documentElement).getProper let a0 = -Math.PI / 2; segs.length = 0; data.forEach((v, i) => { + if (!v) return const a1 = a0 + (v / total) * Math.PI * 2 * prog; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.arc(cx, cy, r, a0, a1); ctx.closePath(); diff --git a/frontend/src/screens/Analytics.jsx b/frontend/src/screens/Analytics.jsx index e42ccaf..77b0887 100644 --- a/frontend/src/screens/Analytics.jsx +++ b/frontend/src/screens/Analytics.jsx @@ -342,10 +342,10 @@ export default function Analytics() { } }, [offersQuery.data]) - /* The funnel has 11 statuses; REJECTED is dropped because it is an outcome, - not a stage, and its volume flattens every other bar. */ + /* Fold the 11 enum statuses onto the 7 pipeline columns; REJECTED is dropped + because it is an outcome, not a stage, and its volume flattens every other bar. */ const pipeline = useMemo(() => { - const rows = (funnelQuery.data ?? []).filter((p) => p.stage !== 'REJECTED') + const rows = analyticsApi.toBoardStageRows(funnelQuery.data ?? []) return { labels: rows.map((p) => p.stage), data: rows.map((p) => p.count), diff --git a/frontend/src/screens/CandidateProfile.jsx b/frontend/src/screens/CandidateProfile.jsx index 10b947f..54c3eb5 100644 --- a/frontend/src/screens/CandidateProfile.jsx +++ b/frontend/src/screens/CandidateProfile.jsx @@ -44,7 +44,7 @@ import { companies, fmtDate, moneyK, pick } from '../data/seed' Forms, Feedback) → track (Notes, Activity) → audit (Timeline, History). */ const TABS = ['Overview', 'Resume', 'Interview', 'Forms', 'Feedback', 'Notes', 'Activity', 'Timeline', 'History'] // Forward progression for the live Advance button. Rejected has no next stage. -const KANBAN_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired'] +const KANBAN_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired'] 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'] @@ -213,6 +213,7 @@ export default function CandidateProfile({ onDone: () => { qc.invalidateQueries({ queryKey: qk.pipeline.all() }) qc.invalidateQueries({ queryKey: qk.forms.all() }) + qc.invalidateQueries({ queryKey: qk.analytics.all() }) }, }) @@ -281,7 +282,7 @@ export default function CandidateProfile({ {advanceLive.isPending ? 'Moving…' : nextStage ? `Advance to ${nextStage}` - : stageLabel === 'Rejected' ? 'Rejected' : 'Pipeline complete'} + : stageLabel === 'Rejected' || stageLabel === 'On Hold' ? stageLabel : 'Pipeline complete'} ) : (
{funnelQuery.isError ? ( - {widgetError(funnelQuery.error, 'analytics.view', 'The server did not return the funnel.')} + {widgetError(funnelQuery.error, 'pipeline.view', 'The server did not return the pipeline.')} - ) : pipeRows.length === 0 && funnelQuery.isSuccess ? ( + ) : funnelQuery.isSuccess && pipeRows.length === 0 ? ( Stage counts appear once applications are in the system. @@ -478,7 +480,7 @@ export default function Dashboard() { style={{ width: `${r.pct}%`, background: r.color }} />
- {r.pct}% + {r.count}
))}
diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index bee6987..270277a 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -46,14 +46,14 @@ const DEFAULT_FORM_SHEET = 'Form Responses - Candidate Database Sheet 2026' const SHEET_SOURCE_META = { icon: 'layers', color: 'var(--c4)', channel: 'Sheet' } /** - * Server-side filters for each tab. Email Processed / Rejected use - * Candidate_application_Status (PROCESS / REJECTED). Sheet Forms use - * form_data.processing_state (same vocabulary as inbox Import/Reject). + * Server-side filters for each tab. Email Processed / Rejected follow + * processing_state (the same writes as Import / Shortlist / Reject). Sheet + * Forms use form_data.processing_state. */ const TAB_FILTERS = { Unread: { isread: false }, - Processed: { applicationStatus: 'PROCESS' }, - Rejected: { applicationStatus: 'REJECTED' }, + Processed: { processingState: 'processed' }, + Rejected: { processingState: 'rejected' }, Duplicates: { isDuplicate: true }, } @@ -224,9 +224,9 @@ function SourceChip({ item }) { // The dot carries the partner's brand colour; the label uses theme text — // 11px labels in the partner colour failed AA in both themes. return ( - + - {item.source} + {item.source} ) } @@ -904,6 +904,7 @@ export default function Inbox() { qc.invalidateQueries({ queryKey: qk.mailbox.counts() }) qc.invalidateQueries({ queryKey: qk.pipeline.all() }) qc.invalidateQueries({ queryKey: qk.candidates.all() }) + qc.invalidateQueries({ queryKey: qk.analytics.all() }) }, }) @@ -1104,6 +1105,17 @@ export default function Inbox() { )}
{i.position}
+
+
+ {outlookListTime(i.received)} +
+ {i.atsScore != null && ( +
+ )} + {isForms && i.noticePeriod && ( +
{i.noticePeriod}
+ )} +
{i.processing} {i.applicationStatus && i.applicationStatus !== 'CLOSED' && ( @@ -1114,17 +1126,6 @@ export default function Inbox() { )}
-
-
- {outlookListTime(i.received)} -
- {i.atsScore != null && ( -
- )} - {isForms && i.noticePeriod && ( -
{i.noticePeriod}
- )} -
)) )} @@ -1310,6 +1311,7 @@ function FormApplicantDetail({ qc.invalidateQueries({ queryKey: qk.mailbox.formRow(vars.recordId) }) qc.invalidateQueries({ queryKey: qk.pipeline.all() }) qc.invalidateQueries({ queryKey: qk.candidates.all() }) + qc.invalidateQueries({ queryKey: qk.analytics.all() }) }, }) @@ -1648,6 +1650,8 @@ function ApplicationDetail({ onSettled: (_res, _err, vars) => { qc.invalidateQueries({ queryKey: qk.mailbox.all() }) qc.invalidateQueries({ queryKey: qk.mailbox.message(vars.recordId) }) + qc.invalidateQueries({ queryKey: qk.pipeline.all() }) + qc.invalidateQueries({ queryKey: qk.candidates.all() }) }, }) diff --git a/frontend/src/screens/Matching.jsx b/frontend/src/screens/Matching.jsx index 70b3a5f..84ac818 100644 --- a/frontend/src/screens/Matching.jsx +++ b/frontend/src/screens/Matching.jsx @@ -223,6 +223,7 @@ export default function Matching() { await qc.invalidateQueries({ queryKey: qk.candidates.all() }) await qc.invalidateQueries({ queryKey: qk.cvBank.all() }) await qc.invalidateQueries({ queryKey: qk.pipeline.all() }) + await qc.invalidateQueries({ queryKey: qk.analytics.all() }) await qc.invalidateQueries({ queryKey: qk.candidates.matchingDetail(vars.recordId) }) if (tab === 'needs' && vars.jobPostId) { diff --git a/frontend/src/screens/Pipeline.jsx b/frontend/src/screens/Pipeline.jsx index 86794ac..72a5af1 100644 --- a/frontend/src/screens/Pipeline.jsx +++ b/frontend/src/screens/Pipeline.jsx @@ -34,7 +34,9 @@ export const KANBAN_STAGES = [ { name: 'Assessment', color: 'var(--stage-3)' }, { name: 'Interview', color: 'var(--stage-4)' }, { name: 'Offer', color: 'var(--stage-5)' }, + { name: 'Approved', color: 'var(--stage-8)' }, { name: 'Hired', color: 'var(--stage-6)' }, + { name: 'On Hold', color: 'var(--stage-9)' }, { name: 'Rejected', color: 'var(--stage-7)' }, ] @@ -186,6 +188,9 @@ export default function Pipeline() { // Stage lives on the inbox row every candidate screen reads, so their // caches are stale too the moment this lands. qc.invalidateQueries({ queryKey: qk.candidates.all() }) + // Dashboard / Analytics funnel is a separate cache; without this a drag + // to Interview leaves the doughnut on the previous snapshot for up to 60s. + qc.invalidateQueries({ queryKey: qk.analytics.all() }) }, }) diff --git a/frontend/src/screens/RecruiterHub.jsx b/frontend/src/screens/RecruiterHub.jsx index a850186..61d8f54 100644 --- a/frontend/src/screens/RecruiterHub.jsx +++ b/frontend/src/screens/RecruiterHub.jsx @@ -189,7 +189,7 @@ export default function RecruiterHub() { }, [trendQuery.data]) const pipelineData = useMemo(() => { - const rows = (funnelQuery.data ?? []).filter((p) => p.stage !== 'REJECTED') + const rows = analyticsApi.toBoardStageRows(funnelQuery.data ?? []) return { labels: rows.map((p) => p.stage), data: rows.map((p) => p.count), diff --git a/frontend/src/screens/Reports.jsx b/frontend/src/screens/Reports.jsx index a74ff7d..794431f 100644 --- a/frontend/src/screens/Reports.jsx +++ b/frontend/src/screens/Reports.jsx @@ -58,17 +58,16 @@ const RANGES = [ ] /* Order matters: "reached" is a running sum from the end of this list back to - the start. REJECTED is deliberately absent — see the header note. */ + the start. REJECTED, CLOSED (shown as Rejected on the board) and ONHOLD are + absent — parking and outcomes are not a step in the happy-path suffix sum. */ const FUNNEL_ORDER = [ { key: 'PENDING', label: 'Shortlist' }, - { key: 'CLOSED', label: 'Shortlist' }, { key: 'SCREENING', label: 'Screened' }, { key: 'PROCESS', label: 'Screened' }, - { key: 'ONHOLD', label: 'Screened' }, { key: 'ASSESSMENT', label: 'Assessed' }, { key: 'INTERVIEW', label: 'Interviewed' }, { key: 'OFFER', label: 'Offered' }, - { key: 'APPROVED', label: 'Hired' }, + { key: 'APPROVED', label: 'Approved' }, { key: 'HIRED', label: 'Hired' }, ] diff --git a/frontend/src/screens/TalentPool.jsx b/frontend/src/screens/TalentPool.jsx index b602f8c..20c3961 100644 --- a/frontend/src/screens/TalentPool.jsx +++ b/frontend/src/screens/TalentPool.jsx @@ -50,17 +50,7 @@ import { avatarColor, initials as initialsOf } from '../data/seed' /** Backend GET /candidate/fetch caps `limit` at 100. */ const PAGE_SIZE_MAX = 100 -const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired'] - -/** - * Candidate_application_Status (backend/inbox/enums.py) -> the seed stage - * vocabulary every screen renders. CLOSED is the column default, i.e. untriaged, - * so it reads as Shortlist rather than as an outcome. - */ -const STAGE_FROM_STATUS = { - PENDING: 'Shortlist', CLOSED: 'Shortlist', PROCESS: 'Screening', - ONHOLD: 'Screening', APPROVED: 'Hired', REJECTED: 'Rejected', -} +const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired'] /** "6 years" -> 6. The column is free text, so anything unparseable defers to seed. */ function years(value) { @@ -100,7 +90,7 @@ function merge(row, template) { const title = (row.job_posts || []).map((j) => j.title).find(Boolean) || row.job_title || row.current_title - const stage = STAGE_FROM_STATUS[row.application_status] || template.stage + const stage = pipelineApi.STAGE_FROM_STATUS[row.application_status] || template.stage const experience = years(row.experience) const departments = departmentsOf(row) diff --git a/frontend/src/styles/styles.css b/frontend/src/styles/styles.css index 0205454..3328e89 100644 --- a/frontend/src/styles/styles.css +++ b/frontend/src/styles/styles.css @@ -125,6 +125,7 @@ --stage-1: #0e7490; --stage-2: #5b60e8; --stage-3: #8a5a00; --stage-4: #004d43; --stage-5: #0f9d76; --stage-6: #6f8f14; --stage-7: #b3243a; + --stage-8: #0b6e4f; --stage-9: #b45309; --ring: 0 0 0 3px rgba(0,77,67,.20); /* select chevron: whole url() is tokenised so the stroke can follow the theme */ @@ -186,6 +187,7 @@ --stage-1: #5fd3e8; --stage-2: #8e92ff; --stage-3: #f5c451; --stage-4: #ceff71; --stage-5: #25e9a5; --stage-6: #a8e063; --stage-7: #ff7a8a; + --stage-8: #5ee0b5; --stage-9: #ffb020; --ring: 0 0 0 3px rgba(206,255,113,.28); --chev-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238fada6' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); @@ -993,13 +995,42 @@ canvas { width: 100%; max-width: 100%; display: block; } .ii-time { font-size: 11px; color: var(--text-3); white-space: nowrap; } /* Inbox sidebar only: fit the list instead of scrolling sideways. Username (.ii-name) and subject (.ii-pos) are left alone. */ -.inbox-split { grid-template-columns: minmax(0, 380px) 1fr; } +.inbox-split { grid-template-columns: minmax(0, 420px) 1fr; } .inbox-queue { overflow-x: hidden; min-width: 0; } .inbox-queue .inbox-item { min-width: 0; } -.inbox-queue .ii-meta { flex-wrap: wrap; min-width: 0; } +/* Name + time on row 1, subject on row 2, chips span the full width under + the timestamp so a board address stays on one line. */ +.inbox-queue .ii-main { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + column-gap: 12px; + align-items: start; +} +.inbox-queue .ii-name { grid-column: 1; grid-row: 1; } +.inbox-queue .ii-pos { grid-column: 1; grid-row: 2; } +.inbox-queue .ii-aside { grid-column: 2; grid-row: 1 / span 2; text-align: right; } +.inbox-queue .ii-meta { + grid-column: 1 / -1; + grid-row: 3; + flex-wrap: wrap; + min-width: 0; + overflow: visible; + row-gap: 6px; +} +/* One-line To-address pill: grow with the address, never wrap (wrapping + stretched the chip taller). Badges sit on the next row. */ .inbox-queue .source-chip { - min-width: 0; max-width: 100%; - overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + flex: 0 0 auto; + width: max-content; + max-width: 100%; + overflow: visible; + white-space: nowrap; + line-height: 1.25; +} +.inbox-queue .source-chip-label { + white-space: nowrap; + overflow-wrap: normal; + word-break: normal; } .inbox-queue .toolbar-search { min-width: 0; } .inbox-queue .pagination { @@ -1050,7 +1081,8 @@ canvas { width: 100%; max-width: 100%; display: block; } 11px copy keeps its contrast in both modes. */ .source-chip { display: inline-flex; align-items: center; gap: 5px; - font-size: var(--fs-xs); font-weight: 600; padding: 2px 8px; border-radius: 20px; + font-size: var(--fs-xs); font-weight: 600; line-height: 1.35; + padding: 3px 8px; border-radius: 20px; --chip: var(--text-3); color: var(--text-2); background: var(--bg-sunken); /* fallback: color-mix needs Safari 16.2+ / Chrome 111+ */ @@ -1058,6 +1090,7 @@ canvas { width: 100%; max-width: 100%; display: block; } } .source-chip svg { width: 12px; height: 12px; color: var(--chip); } .source-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; background: var(--chip); } +.source-chip-label { min-width: 0; } .integration-status { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 20px; font-size: var(--fs-sm); font-weight: 600; background: var(--success-soft); color: var(--success); } .integration-status.pending { background: var(--warning-soft); color: var(--warning); } -- 2.40.1