/ppelline completde

pull/15/head
ahmed.mujtaba 2026-08-13 18:34:52 +05:00
parent c1e44356e9
commit 664b33b94b
8 changed files with 225 additions and 105 deletions

View File

@ -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}%"

View File

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

View File

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

View File

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

View File

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

View File

@ -23,7 +23,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Belleza&family=Inter:wght@400;500;600;700;800&display=swap" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23004d43'/><g transform='translate(14 32) scale(0.72)'><path d='M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z' fill='%23ceff71'/></g></svg>" />
<script type="module" crossorigin src="/assets/index-CdtlKBsf.js"></script>
<script type="module" crossorigin src="/assets/index-D0GY3L2I.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CSn67wit.css">
</head>
<body>

View File

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

View File

@ -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() {
<div className="kanban-col-head">
<span className="k-dot" style={{ background: st.color }} />
<h4>{st.name}</h4>
<span className="k-count">{cards.length}</span>
<span className="k-count">{stageCounts[st.name] ?? 0}</span>
</div>
<div
className={`kanban-cards${overStage === st.name ? ' drag-over' : ''}`}