diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 424eb61..f355f43 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -73,7 +73,7 @@ class Inbox(SQLModel, table=True): ) @classmethod - async def get_all(cls,session:AsyncSession): + async def get_all(cls,session:AsyncSession,job_post_id=None,limit=None,offset=0): try: from job.job_post.models import JobPosts qry=( @@ -85,6 +85,10 @@ class Inbox(SQLModel, table=True): Inbox_Messages.candidate_phone_number.label("phone"), Inbox_Messages.assigned_job_post_id, Inbox_Messages.application_status, + Inbox_Messages.current_employment, + Inbox_Messages.current_title, + Inbox_Messages.experience, + cls.created_at, JobPosts.title, AtsResults.id.label("ats_result_id"), AtsResults.overall_score, @@ -103,6 +107,10 @@ class Inbox(SQLModel, table=True): .where(Roles.role_name==EnumRoles.CANDIDATE.value) .order_by(cls.created_at.desc()) ) + if job_post_id: + qry=qry.where(Inbox_Messages.assigned_job_post_id==job_post_id) + if limit is not None: + qry=qry.limit(limit).offset(offset) result=await session.execute(qry) rows=[] for row in result.mappings().all(): @@ -117,21 +125,54 @@ class Inbox(SQLModel, table=True): "candidate_id":str(row["candidate_id"]) if row["candidate_id"] else None, "user_id":str(row["ats_user_id"]) if row["ats_user_id"] else None, } + status=row["application_status"] rows.append({ "inbox_id":row["inbox_id"], "user_id":str(row["user_id"]) if row["user_id"] else None, "name":row["name"], "email":row["email"], - "application_status":row["application_status"].value if row["application_status"] else None, + "application_status":status.value if status else None, "phone":row["phone"], "assigned_job_post_id":str(row["assigned_job_post_id"]) if row["assigned_job_post_id"] else None, - "title":row["title"], + "title":row["title"] or None, + "current_employment":row["current_employment"] or None, + "current_title":row["current_title"] or None, + "experience":row["experience"] or None, + "created_at":row["created_at"].isoformat() if row["created_at"] else None, "ats_result":ats, }) return rows except Exception as e: raise HTTPException(status_code=500,detail=str(e)) + @classmethod + async def count_by_status(cls,session:AsyncSession,job_post_id=None): + try: + from job.job_post.models import JobPosts + qry=( + select(Inbox_Messages.application_status,func.count()) + .select_from(cls) + .join(Users,cls.user_id==Users.id) + .join(Roles,Users.role_id==Roles.id) + .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(Roles.role_name==EnumRoles.CANDIDATE.value) + .group_by(Inbox_Messages.application_status) + ) + if job_post_id: + qry=qry.where(Inbox_Messages.assigned_job_post_id==job_post_id) + result=await session.execute(qry) + counts={} + for status,n in result.all(): + key=status.value if hasattr(status,"value") else (str(status) if status else None) + if not key: + continue + counts[key]=int(n or 0) + return counts + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + @classmethod def _candidate_search_filter(cls, search: str): pattern = f"%{search}%" diff --git a/backend/job/app.py b/backend/job/app.py index d08d004..acd57c8 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -679,18 +679,33 @@ async def change_candidate_stage( @router.get("/pipeline/candidates/fetch") async def fetch_pipeline_candidates( - user_id:Optional[uuid.UUID]=Query(None), job_post_id:Optional[uuid.UUID]=Query(None), + limit:int=Query(200,ge=1,le=1000), + offset:int=Query(0,ge=0), current_user: dict = Depends(require_permission(PermissionTag.PIPELINE_VIEW)), session: AsyncSession = Depends(get_session), ): try: service=Pipeline(session=session) - if user_id and job_post_id: - data=await service.get_pipeline_candidates(user_id=user_id,job_post_id=job_post_id) - else: - data=await service.get_all() - return JSONResponse(content={"data":data,"status_code":200}) + result=await service.get_all(job_post_id=job_post_id,limit=limit,offset=offset) + return JSONResponse(content={**result,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.get("/pipeline/candidate/score/fetch") +async def fetch_pipeline_candidate_score( + user_id:uuid.UUID=Query(...), + job_post_id:uuid.UUID=Query(...), + current_user: dict = Depends(require_permission(PermissionTag.PIPELINE_VIEW)), + session: AsyncSession = Depends(get_session), +): + try: + service=Pipeline(session=session) + data=await service.get_pipeline_candidates(user_id=user_id,job_post_id=job_post_id) + return JSONResponse(content={"data":data,"total":1,"status_code":200}) except HTTPException: raise except Exception as e: diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index 7cd553b..4119c54 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -52,7 +52,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) @classmethod - async def get_all(cls, session: AsyncSession): + async def get_all(cls, session: AsyncSession, job_post_id=None, limit=None, offset=0): try: from inbox.models import AtsResults from users.models import Users @@ -91,6 +91,10 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): ) .order_by(cls.created_at.desc()) ) + if job_post_id: + qry=qry.where(cls.job_post_id==job_post_id) + if limit is not None: + qry=qry.limit(limit).offset(offset) result=await session.execute(qry) rows=[] for row in result.mappings().all(): @@ -113,7 +117,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): "job_post_id":str(row["job_post_id"]) if row["job_post_id"] else None, "name":row["name"], "candidate_phone":row["candidate_phone"], - "title":row["title"], + "title":row["title"] or None, "application_status":row["status"] or None, "current_company":row["current_company"] or None, "current_position":row["current_position"] or None, @@ -125,6 +129,29 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): return rows except Exception as e: raise HTTPException(status_code=500,detail=str(e)) + + @classmethod + async def count_by_status(cls, session: AsyncSession, job_post_id=None): + try: + 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) + .group_by(cls.status) + ) + if job_post_id: + qry=qry.where(cls.job_post_id==job_post_id) + result=await session.execute(qry) + counts={} + for status,n in result.all(): + key=(status or "").strip() or "UNKNOWN" + counts[key]=counts.get(key,0)+int(n or 0) + return counts + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) @staticmethod def _as_uuid(record_id) -> uuid.UUID | None: if record_id in (None, ""): diff --git a/backend/job/pipeline/serializers.py b/backend/job/pipeline/serializers.py index 4d94b49..63bf0f6 100644 --- a/backend/job/pipeline/serializers.py +++ b/backend/job/pipeline/serializers.py @@ -1,3 +1,24 @@ +from inbox.enums import Candidate_application_Status + + +def serialize_pipeline_counts(inbox_counts,manual_counts) -> dict: + by_status={stage.value:0 for stage in Candidate_application_Status} + by_status["UNKNOWN"]=0 + inbox_n=0 + manual_n=0 + for key,n in (inbox_counts or {}).items(): + n=int(n or 0) + inbox_n+=n + bucket=key if key in by_status else "UNKNOWN" + by_status[bucket]+=n + for key,n in (manual_counts or {}).items(): + n=int(n or 0) + manual_n+=n + bucket=key if key in by_status else "UNKNOWN" + by_status[bucket]+=n + return {"by_status":by_status,"inbox":inbox_n,"manual_upload":manual_n} + + def serialize_stage_transition(row) -> dict: return { "id": str(row.id), diff --git a/backend/job/pipeline/views.py b/backend/job/pipeline/views.py index 9f25f67..1d34dec 100644 --- a/backend/job/pipeline/views.py +++ b/backend/job/pipeline/views.py @@ -4,19 +4,28 @@ from sqlalchemy.ext.asyncio import AsyncSession from inbox.enums import Candidate_application_Status from inbox.models import Inbox from job.candidate.models import ApplicationStageTransitions, Manual_UPLOAD_CANDIDATE, _now -from job.pipeline.serializers import serialize_stage_transition +from job.pipeline.serializers import serialize_pipeline_counts, serialize_stage_transition from inbox.plugins import get_ats_score_for_manual_user, get_ats_score_for_user class Pipeline: def __init__(self,session:AsyncSession): self.session=session - async def get_all(self): + async def get_all(self,job_post_id=None,limit=None,offset=0): + # limit/offset are per-source, not a merged page: two tables, no common + # order key. limit=200 returns up to 200 inbox AND up to 200 manual rows. try: - inbox_data=await Inbox.get_all(self.session) - manual_upload_data=await Manual_UPLOAD_CANDIDATE.get_all(self.session) - data={"inbox":inbox_data,"manual_upload":manual_upload_data} - return data + inbox_data=await Inbox.get_all(self.session,job_post_id=job_post_id,limit=limit,offset=offset) + manual_upload_data=await Manual_UPLOAD_CANDIDATE.get_all(self.session,job_post_id=job_post_id,limit=limit,offset=offset) + counts=serialize_pipeline_counts( + await Inbox.count_by_status(self.session,job_post_id=job_post_id), + await Manual_UPLOAD_CANDIDATE.count_by_status(self.session,job_post_id=job_post_id), + ) + return { + "data":{"inbox":inbox_data,"manual_upload":manual_upload_data}, + "counts":counts, + "total":counts["inbox"]+counts["manual_upload"], + } except Exception as e: raise HTTPException(status_code=500,detail=str(e)) diff --git a/frontend/dist/index.html b/frontend/dist/index.html index 8df7bd6..2c57fc8 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -23,7 +23,7 @@ - +
diff --git a/frontend/src/api/pipeline.js b/frontend/src/api/pipeline.js index ea8dab1..4f377b0 100644 --- a/frontend/src/api/pipeline.js +++ b/frontend/src/api/pipeline.js @@ -1,12 +1,9 @@ /* ============================================================ pipeline.js — the kanban board's endpoints (backend/job/app.py). - The board is stitched from two reads plus one write: - - - inbox cards come from GET /candidate/fetch (api/candidates.js `list`); - - manual-upload cards come from GET /pipeline/candidates/fetch (`manual_upload`); - - the write is PATCH /candidate/stage, here. Inbox moves send `inbox_id`; - manual moves send `manual_upload_id`. Exactly one is required. + The board is one read: GET /pipeline/candidates/fetch returns inbox + + manual_upload cards plus per-status counts. Dropping a card fires + PATCH /candidate/stage with `inbox_id` or `manual_upload_id`. Inbox stage lives on inbox_messages.application_status; manual stage lives on manual_upload_candidate.status (same Candidate_application_Status values). @@ -77,10 +74,26 @@ export function changeStage({ inboxId, manualUploadId, toStage, changeReason }) /** * Inbox + manual-upload applications for the board — GET /pipeline/candidates/fetch - * (pipeline.view). Envelope `data` is `{ inbox, manual_upload }`. + * (pipeline.view). Envelope is `{ data: { inbox, manual_upload }, counts, total }`. + * `jobId === ''` (All Jobs) is dropped by buildUrl and sends no filter. */ -export function listApplications() { - return request('/pipeline/candidates/fetch') +export function listApplications({ jobId, limit, offset } = {}) { + return request('/pipeline/candidates/fetch', { + params: { job_post_id: jobId, limit, offset }, + }) +} + +/** + * Fold the 11 status counts into the 7 board columns. Unmapped keys (UNKNOWN) + * land in Applied, same as STAGE_FROM_STATUS's card fallback. + */ +export function toStageCounts(byStatus) { + const counts = Object.fromEntries(Object.keys(STATUS_FROM_STAGE).map((name) => [name, 0])) + for (const [status, n] of Object.entries(byStatus || {})) { + const stage = STAGE_FROM_STATUS[status] ?? 'Applied' + counts[stage] = (counts[stage] ?? 0) + (n || 0) + } + return counts } /** @@ -98,63 +111,52 @@ export function listTransitions({ inboxId, manualUploadId, transitionId } = {}) }) } -/** - * Candidate-profile row -> one kanban card. - * - * `id` is the inbox id, not the user id: the board is one card per APPLICATION - * and `inbox` holds one row per (user, message), so a candidate who mailed us - * three times legitimately occupies three cards with three independent stages. - * `userId` rides along for the deep link into the profile. - * - * Skills are absent by construction — the list payload carries ai_score but not - * matched_keywords (job/candidate/views.py::attach_job_posts sets only the - * score), so the card drops its tag row rather than rendering three blanks. - */ -export function toBoardCard(row) { - const jobTitle = row.job_title ?? row.assigned_job_post?.title ?? null +function sourceFields(row, kind) { + if (kind === 'manual') { + return { + id: `manual:${row.id}`, + inboxId: null, + manualUploadId: row.id, + jobId: row.job_post_id ?? null, + jobTitle: row.title ?? null, + currentTitle: row.current_position || null, + currentCompany: row.current_company || null, + } + } return { id: row.inbox_id, inboxId: row.inbox_id, manualUploadId: null, - userId: row.user_id ?? null, - name: row.name || row.email || 'Unknown', - email: row.email ?? null, - stage: STAGE_FROM_STATUS[row.application_status] ?? 'Applied', - status: row.application_status ?? null, jobId: row.assigned_job_post_id ?? null, - jobTitle, + jobTitle: row.title ?? null, currentTitle: row.current_title || null, currentCompany: row.current_employment || null, - experience: row.experience || null, - aiScore: row.ai_score ?? null, - recommendation: row.recommendation ?? null, - applied: row.created_at ? new Date(row.created_at) : null, } } /** - * GET /pipeline/candidates/fetch `manual_upload` row -> one kanban card. + * Pipeline inbox or manual-upload row -> one kanban card. * - * `id` is prefixed so it cannot collide with an integer inbox id. Stage is - * `manual_upload_candidate.status`, exposed as `application_status`. + * Inbox `id` is the inbox id, not the user id: the board is one card per + * APPLICATION. `userId` rides along for the deep link into the profile. */ -export function toManualBoardCard(row) { +export function toBoardCard(row, kind = 'inbox') { + const src = sourceFields(row, kind) return { - id: `manual:${row.id}`, - inboxId: null, - manualUploadId: row.id, + ...src, userId: row.user_id ?? null, name: row.name || row.email || 'Unknown', email: row.email ?? null, stage: STAGE_FROM_STATUS[row.application_status] ?? 'Applied', status: row.application_status ?? null, - jobId: row.job_post_id ?? null, - jobTitle: row.title ?? null, - currentTitle: row.current_position || null, - currentCompany: row.current_company || null, experience: row.experience || null, aiScore: row.ats_result?.overall_score ?? null, recommendation: row.ats_result?.band ?? null, applied: row.created_at ? new Date(row.created_at) : null, } } + +/** GET /pipeline/candidates/fetch `manual_upload` row -> one kanban card. */ +export function toManualBoardCard(row) { + return toBoardCard(row, 'manual') +} diff --git a/frontend/src/screens/Pipeline.jsx b/frontend/src/screens/Pipeline.jsx index 75d7b7c..0f7ae5e 100644 --- a/frontend/src/screens/Pipeline.jsx +++ b/frontend/src/screens/Pipeline.jsx @@ -1,29 +1,28 @@ /* ============================================================ Pipeline — the kanban board, on live backend data. - Inbox cards come from GET /candidate/fetch; manual-upload cards from - GET /pipeline/candidates/fetch. Dropping a card fires PATCH /candidate/stage - with `inbox_id` or `manual_upload_id`. The server closes the open - application_stage_transitions interval and opens a new one in the same commit. + Cards and column counts come from GET /pipeline/candidates/fetch. Dropping a + card fires PATCH /candidate/stage with `inbox_id` or `manual_upload_id`. The + server closes the open application_stage_transitions interval and opens a + new one in the same commit. - The job filter reads live posts from GET /job/fetch and matches on job id, - so an application nobody has assigned to a post shows under All Jobs only. + The job filter is server-side (`job_post_id`). Applications with no assigned + post are omitted by the list query, so they do not appear under All Jobs. - The card's skill tags are gone: the list payload has ai_score but no + The card's skill tags are gone: the list payload has ats_result but no matched_keywords, and the Candidates screen set the precedent that a column with no source is dropped rather than rendered as blanks. ============================================================ */ import { useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Avatar, EmptyState, Icon, ScoreChip } from '../ui/primitives' import { useToast } from '../ui/Toast' import { useAuth } from '../auth/AuthContext' import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' -import * as candidatesApi from '../api/candidates' import * as jobPostsApi from '../api/jobPosts' import * as pipelineApi from '../api/pipeline' @@ -38,27 +37,20 @@ export const KANBAN_STAGES = [ { name: 'Rejected', color: 'var(--stage-7)' }, ] -/* /candidate/fetch pages with limit/offset and has no job filter, so the board - pulls one page and filters client-side. Rows past this are not on the board — - the header says so rather than silently showing a partial pipeline. */ const BOARD_LIMIT = 200 -const BOARD_KEY = qk.pipeline.board({ limit: BOARD_LIMIT }) - const JOB_LIMIT = 100 -async function fetchBoard() { - const [res, pipe] = await Promise.all([ - candidatesApi.list({ limit: BOARD_LIMIT }), - pipelineApi.listApplications(), - ]) - const rows = candidatesApi.toRows(res) - const manuals = Array.isArray(pipe?.data?.manual_upload) ? pipe.data.manual_upload : [] +async function fetchBoard(jobId) { + const res = await pipelineApi.listApplications({ jobId, limit: BOARD_LIMIT }) + const inbox = Array.isArray(res?.data?.inbox) ? res.data.inbox : [] + const manuals = Array.isArray(res?.data?.manual_upload) ? res.data.manual_upload : [] return { cards: [ - ...rows.map(pipelineApi.toBoardCard), - ...manuals.map(pipelineApi.toManualBoardCard), + ...inbox.map((row) => pipelineApi.toBoardCard(row)), + ...manuals.map((row) => pipelineApi.toManualBoardCard(row)), ], - total: (res?.total ?? rows.length) + manuals.length, + total: res?.total ?? 0, + stageCounts: pipelineApi.toStageCounts(res?.counts?.by_status), } } @@ -82,34 +74,39 @@ export default function Pipeline() { const navigate = useNavigate() const qc = useQueryClient() - const board = useQuery({ queryKey: BOARD_KEY, queryFn: fetchBoard }) - const { data: jobs = [] } = useQuery({ queryKey: qk.jobPosts.list({ top: JOB_LIMIT }), queryFn: fetchJobs }) - const [jobId, setJobId] = useState('') const [draggingId, setDraggingId] = useState(null) const [overStage, setOverStage] = useState(null) + const boardKey = useMemo( + () => qk.pipeline.board({ limit: BOARD_LIMIT, jobId: jobId || null }), + [jobId], + ) + + const board = useQuery({ + queryKey: boardKey, + queryFn: () => fetchBoard(jobId), + placeholderData: keepPreviousData, + }) + const { data: jobs = [] } = useQuery({ queryKey: qk.jobPosts.list({ top: JOB_LIMIT }), queryFn: fetchJobs }) + /* The route is behind pipeline.view, but the WRITE needs pipeline.edit — a viewer gets a read-only board instead of drags that 403 on drop. */ const canEdit = can('pipeline.edit') const candidates = board.data?.cards ?? [] const total = board.data?.total ?? 0 - - const list = useMemo( - () => (jobId ? candidates.filter((c) => c.jobId === jobId) : candidates), - [candidates, jobId], - ) + const stageCounts = board.data?.stageCounts ?? {} const byStage = useMemo(() => { const map = Object.fromEntries(KANBAN_STAGES.map((s) => [s.name, []])) - for (const c of list) if (map[c.stage]) map[c.stage].push(c) + for (const c of candidates) if (map[c.stage]) map[c.stage].push(c) return map - }, [list]) + }, [candidates]) /* Optimistic: a drag that only repaints after the round trip reads as a failed drop. The card snaps back on error and the server's own value wins on the - refetch in onSettled. */ + refetch in onSettled. Counts move with the card so badges do not lag. */ const move = useMutation({ mutationFn: ({ card, stage }) => pipelineApi.changeStage({ @@ -118,18 +115,26 @@ export default function Pipeline() { toStage: pipelineApi.STATUS_FROM_STAGE[stage], }), onMutate: async ({ card, stage }) => { - await qc.cancelQueries({ queryKey: BOARD_KEY }) - const previous = qc.getQueryData(BOARD_KEY) - qc.setQueryData(BOARD_KEY, (old) => - old && { + await qc.cancelQueries({ queryKey: boardKey }) + const previous = qc.getQueryData(boardKey) + qc.setQueryData(boardKey, (old) => { + if (!old) return old + const from = card.stage + const nextCounts = { ...old.stageCounts } + if (from && from !== stage) { + nextCounts[from] = Math.max(0, (nextCounts[from] ?? 0) - 1) + nextCounts[stage] = (nextCounts[stage] ?? 0) + 1 + } + return { ...old, cards: old.cards.map((c) => (c.id === card.id ? { ...c, stage } : c)), - }, - ) + stageCounts: nextCounts, + } + }) return { previous } }, onError: (err, _vars, ctx) => { - if (ctx?.previous) qc.setQueryData(BOARD_KEY, ctx.previous) + if (ctx?.previous) qc.setQueryData(boardKey, ctx.previous) toast(friendlyAuthError(err, 'Could not move the candidate.'), 'error') }, onSuccess: (_data, { card, stage }) => toast(`${card.name} moved to ${stage}`, 'success'), @@ -199,7 +204,7 @@ export default function Pipeline() {