/ppelline completde
parent
c1e44356e9
commit
664b33b94b
|
|
@ -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}%"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
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)
|
||||
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})
|
||||
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -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, ""):
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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,
|
||||
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.
|
||||
*
|
||||
* `id` is prefixed so it cannot collide with an integer inbox id. Stage is
|
||||
* `manual_upload_candidate.status`, exposed as `application_status`.
|
||||
*/
|
||||
export function toManualBoardCard(row) {
|
||||
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,
|
||||
jobId: row.assigned_job_post_id ?? null,
|
||||
jobTitle: row.title ?? null,
|
||||
currentTitle: row.current_title || null,
|
||||
currentCompany: row.current_employment || null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pipeline inbox or manual-upload row -> one kanban card.
|
||||
*
|
||||
* 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 toBoardCard(row, kind = 'inbox') {
|
||||
const src = sourceFields(row, kind)
|
||||
return {
|
||||
...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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' : ''}`}
|
||||
|
|
|
|||
Loading…
Reference in New Issue