pull/15/head
parent
0899342c34
commit
c1e44356e9
|
|
@ -84,6 +84,7 @@ class Inbox(SQLModel, table=True):
|
|||
Users.email,
|
||||
Inbox_Messages.candidate_phone_number.label("phone"),
|
||||
Inbox_Messages.assigned_job_post_id,
|
||||
Inbox_Messages.application_status,
|
||||
JobPosts.title,
|
||||
AtsResults.id.label("ats_result_id"),
|
||||
AtsResults.overall_score,
|
||||
|
|
@ -121,6 +122,7 @@ class Inbox(SQLModel, table=True):
|
|||
"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,
|
||||
"phone":row["phone"],
|
||||
"assigned_job_post_id":str(row["assigned_job_post_id"]) if row["assigned_job_post_id"] else None,
|
||||
"title":row["title"],
|
||||
|
|
|
|||
|
|
@ -90,7 +90,8 @@ class FeedbackUpdate(BaseModel):
|
|||
|
||||
|
||||
class StageChange(BaseModel):
|
||||
inbox_id: int
|
||||
inbox_id: int | None = None
|
||||
manual_upload_id: UUID | None = None
|
||||
to_stage: str
|
||||
change_reason: str | None = None
|
||||
|
||||
|
|
@ -667,7 +668,8 @@ async def change_candidate_stage(
|
|||
try:
|
||||
service=Pipeline(session=session)
|
||||
data=await service.change_stage(
|
||||
payload.inbox_id,payload.to_stage,current_user,change_reason=payload.change_reason,
|
||||
payload.to_stage,current_user,inbox_id=payload.inbox_id,
|
||||
manual_upload_id=payload.manual_upload_id,change_reason=payload.change_reason,
|
||||
)
|
||||
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||
except HTTPException:
|
||||
|
|
@ -699,12 +701,13 @@ async def fetch_pipeline_candidates(
|
|||
async def fetch_pipeline_transitions(
|
||||
transition_id:str=Query(None),
|
||||
inbox_id:int=Query(None),
|
||||
manual_upload_id:UUID=Query(None),
|
||||
current_user: dict = Depends(require_permission(PermissionTag.PIPELINE_VIEW)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=Pipeline(session=session)
|
||||
data=await service.get_transitions(inbox_id=inbox_id,transition_id=transition_id)
|
||||
data=await service.get_transitions(inbox_id=inbox_id,transition_id=transition_id,manual_upload_id=manual_upload_id)
|
||||
total=1 if isinstance(data,dict) else len(data)
|
||||
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
||||
except HTTPException:
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
platform: str = Field(default="")
|
||||
created_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
||||
experience: str = Field(default="")
|
||||
# Candidate_application_Status value (PENDING, SCREENING, …). Empty reads as Applied.
|
||||
status: str = Field(default="")
|
||||
# Free text, not a users FK: a referrer is often someone outside the system
|
||||
referral_by: str = Field(default="", sa_column_kwargs={"server_default": ""})
|
||||
|
|
@ -66,6 +67,10 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
Users.name,
|
||||
cls.candidate_phone,
|
||||
JobPosts.title,
|
||||
cls.status,
|
||||
cls.current_company,
|
||||
cls.current_position,
|
||||
cls.experience,
|
||||
cls.created_at,
|
||||
cls.updated_at,
|
||||
AtsResults.id.label("ats_result_id"),
|
||||
|
|
@ -109,6 +114,10 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
"name":row["name"],
|
||||
"candidate_phone":row["candidate_phone"],
|
||||
"title":row["title"],
|
||||
"application_status":row["status"] or None,
|
||||
"current_company":row["current_company"] or None,
|
||||
"current_position":row["current_position"] or None,
|
||||
"experience":row["experience"] or None,
|
||||
"created_at":row["created_at"].isoformat() if row["created_at"] else None,
|
||||
"updated_at":row["updated_at"].isoformat() if row["updated_at"] else None,
|
||||
"ats_result":ats,
|
||||
|
|
@ -162,7 +171,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
platform=(fields.get("platform") or "").strip(),
|
||||
created_by=cls._as_uuid(fields.get("created_by")),
|
||||
experience=(fields.get("experience") or "").strip(),
|
||||
status=(fields.get("status") or "").strip(),
|
||||
status=(fields.get("status") or "").strip() or "PENDING",
|
||||
referral_by=(fields.get("referral_by") or "").strip(),
|
||||
file_name=(fields.get("file_name") or "").strip(),
|
||||
file_path=(fields.get("file_path") or "").strip(),
|
||||
|
|
@ -182,6 +191,14 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
)
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
async def get_by_id(cls, session: AsyncSession, record_id):
|
||||
uid = cls._as_uuid(record_id)
|
||||
if uid is None:
|
||||
return None
|
||||
result = await session.execute(select(cls).where(cls.id == uid))
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
class Candidates(SQLModel, table=True):
|
||||
|
||||
|
|
@ -590,16 +607,20 @@ class Feedback(SQLModel, table=True):
|
|||
|
||||
|
||||
class ApplicationStageTransitions(SQLModel, table=True):
|
||||
"""Temporal history of inbox_messages.application_status changes.
|
||||
"""Temporal history of application stage changes.
|
||||
|
||||
valid_from / valid_to make time-in-stage a subtraction rather than a window
|
||||
function. NULL valid_to means the stage is still current.
|
||||
Inbox moves write inbox_messages.application_status; manual-upload moves
|
||||
write manual_upload_candidate.status. Exactly one of inbox_id /
|
||||
manual_upload_candidate_id is set. NULL valid_to means the stage is current.
|
||||
"""
|
||||
|
||||
__tablename__ = "application_stage_transitions"
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
inbox_id: int = Field(index=True, foreign_key="inbox.id")
|
||||
inbox_id: int | None = Field(default=None, index=True, foreign_key="inbox.id")
|
||||
manual_upload_candidate_id: uuid.UUID | None = Field(
|
||||
default=None, index=True, foreign_key="manual_upload_candidate.id"
|
||||
)
|
||||
from_stage: str | None = Field(default=None)
|
||||
to_stage: str
|
||||
valid_from: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
|
|
@ -634,12 +655,28 @@ class ApplicationStageTransitions(SQLModel, table=True):
|
|||
return list(result.scalars().all())
|
||||
|
||||
@classmethod
|
||||
async def get_open_transition(cls, session: AsyncSession, inbox_id: int):
|
||||
async def fetch_by_manual(cls, session: AsyncSession, record_id):
|
||||
uid = cls._as_uuid(record_id)
|
||||
if uid is None:
|
||||
return []
|
||||
result = await session.execute(
|
||||
select(cls)
|
||||
.where(cls.inbox_id == int(inbox_id), cls.valid_to.is_(None))
|
||||
.order_by(cls.valid_from.desc())
|
||||
select(cls).where(cls.manual_upload_candidate_id == uid).order_by(cls.valid_from.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@classmethod
|
||||
async def get_open_transition(cls, session: AsyncSession, inbox_id=None, manual_upload_candidate_id=None):
|
||||
statement = select(cls).where(cls.valid_to.is_(None)).order_by(cls.valid_from.desc())
|
||||
if inbox_id is not None:
|
||||
statement = statement.where(cls.inbox_id == int(inbox_id))
|
||||
elif manual_upload_candidate_id is not None:
|
||||
uid = cls._as_uuid(manual_upload_candidate_id)
|
||||
if uid is None:
|
||||
return None
|
||||
statement = statement.where(cls.manual_upload_candidate_id == uid)
|
||||
else:
|
||||
return None
|
||||
result = await session.execute(statement)
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
|
|
@ -652,8 +689,10 @@ class ApplicationStageTransitions(SQLModel, table=True):
|
|||
return row
|
||||
|
||||
@classmethod
|
||||
async def close_open(cls, session: AsyncSession, inbox_id: int, *, at: datetime | None = None, commit: bool = False):
|
||||
row = await cls.get_open_transition(session, inbox_id)
|
||||
async def close_open(cls, session: AsyncSession, inbox_id=None, *, manual_upload_candidate_id=None, at: datetime | None = None, commit: bool = False):
|
||||
row = await cls.get_open_transition(
|
||||
session, inbox_id=inbox_id, manual_upload_candidate_id=manual_upload_candidate_id
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
row.valid_to = at or _now()
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ def serialize_stage_transition(row) -> dict:
|
|||
return {
|
||||
"id": str(row.id),
|
||||
"inbox_id": row.inbox_id,
|
||||
"manual_upload_candidate_id": str(row.manual_upload_candidate_id) if row.manual_upload_candidate_id else None,
|
||||
"from_stage": row.from_stage,
|
||||
"to_stage": row.to_stage,
|
||||
"valid_from": row.valid_from.isoformat() if row.valid_from else None,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ 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
|
||||
from job.candidate.models import ApplicationStageTransitions, Manual_UPLOAD_CANDIDATE, _now
|
||||
from job.pipeline.serializers import serialize_stage_transition
|
||||
from inbox.plugins import get_ats_score_for_manual_user, get_ats_score_for_user
|
||||
|
||||
|
|
@ -29,49 +29,57 @@ class Pipeline:
|
|||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
async def get_transitions(self,inbox_id=None,transition_id=None):
|
||||
async def get_transitions(self,inbox_id=None,transition_id=None,manual_upload_id=None):
|
||||
if transition_id:
|
||||
row=await ApplicationStageTransitions.get_by_id(self.session,transition_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404,detail="Transition not found")
|
||||
|
||||
|
||||
return serialize_stage_transition(row)
|
||||
if inbox_id is None:
|
||||
raise HTTPException(status_code=400,detail="transition_id or inbox_id is required")
|
||||
rows=await ApplicationStageTransitions.fetch_by_inbox(self.session,int(inbox_id))
|
||||
return [serialize_stage_transition(r) for r in rows]
|
||||
if inbox_id is not None:
|
||||
rows=await ApplicationStageTransitions.fetch_by_inbox(self.session,int(inbox_id))
|
||||
return [serialize_stage_transition(r) for r in rows]
|
||||
if manual_upload_id is not None:
|
||||
rows=await ApplicationStageTransitions.fetch_by_manual(self.session,manual_upload_id)
|
||||
return [serialize_stage_transition(r) for r in rows]
|
||||
raise HTTPException(status_code=400,detail="transition_id, inbox_id or manual_upload_id is required")
|
||||
|
||||
async def change_stage(self,inbox_id,to_stage,current_user,change_reason=None):
|
||||
async def change_stage(self,to_stage,current_user,inbox_id=None,manual_upload_id=None,change_reason=None):
|
||||
if (inbox_id is None)==(manual_upload_id is None):
|
||||
raise HTTPException(status_code=400,detail="inbox_id or manual_upload_id is required")
|
||||
try:
|
||||
stage=Candidate_application_Status(to_stage)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422,detail="Invalid to_stage")
|
||||
changed_by=None
|
||||
if isinstance(current_user,dict) and current_user.get("id"):
|
||||
changed_by=ApplicationStageTransitions._as_uuid(current_user.get("id"))
|
||||
if inbox_id is not None:
|
||||
return await self._change_inbox_stage(inbox_id,stage,changed_by,change_reason)
|
||||
return await self._change_manual_stage(manual_upload_id,stage,changed_by,change_reason)
|
||||
|
||||
async def _change_inbox_stage(self,inbox_id,stage,changed_by,change_reason):
|
||||
inbox=await Inbox.get_inbox_with_message(self.session,inbox_id)
|
||||
if not inbox:
|
||||
raise HTTPException(status_code=404,detail="Inbox not found")
|
||||
message=inbox.messages
|
||||
if not message:
|
||||
raise HTTPException(status_code=404,detail="Inbox message not found")
|
||||
try:
|
||||
stage=Candidate_application_Status(to_stage)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422,detail="Invalid to_stage")
|
||||
current=message.application_status
|
||||
from_stage=current.value if isinstance(current,Candidate_application_Status) else str(current)
|
||||
if from_stage==stage.value:
|
||||
raise HTTPException(status_code=400,detail="already at stage")
|
||||
changed_by=None
|
||||
if isinstance(current_user,dict) and current_user.get("id"):
|
||||
changed_by=ApplicationStageTransitions._as_uuid(current_user.get("id"))
|
||||
await ApplicationStageTransitions.close_open(self.session,inbox.id,commit=False)
|
||||
transition_data={
|
||||
transition=await ApplicationStageTransitions.insert_transition(
|
||||
self.session,
|
||||
{
|
||||
"inbox_id":inbox.id,
|
||||
"manual_upload_candidate_id":None,
|
||||
"from_stage":from_stage,
|
||||
"to_stage":stage.value,
|
||||
"changed_by":changed_by,
|
||||
"actor_kind":"user",
|
||||
"change_reason":change_reason,
|
||||
}
|
||||
transition=await ApplicationStageTransitions.insert_transition(
|
||||
self.session,
|
||||
transition_data,
|
||||
},
|
||||
commit=False,
|
||||
)
|
||||
message.application_status=stage
|
||||
|
|
@ -79,6 +87,41 @@ class Pipeline:
|
|||
await self.session.commit()
|
||||
return {
|
||||
"inbox_id":inbox.id,
|
||||
"manual_upload_id":None,
|
||||
"application_status":stage.value,
|
||||
"transition":serialize_stage_transition(transition),
|
||||
}
|
||||
|
||||
async def _change_manual_stage(self,manual_upload_id,stage,changed_by,change_reason):
|
||||
row=await Manual_UPLOAD_CANDIDATE.get_by_id(self.session,manual_upload_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404,detail="Manual upload candidate not found")
|
||||
from_stage=(row.status or "").strip() or None
|
||||
if from_stage==stage.value:
|
||||
raise HTTPException(status_code=400,detail="already at stage")
|
||||
await ApplicationStageTransitions.close_open(
|
||||
self.session,manual_upload_candidate_id=row.id,commit=False,
|
||||
)
|
||||
transition=await ApplicationStageTransitions.insert_transition(
|
||||
self.session,
|
||||
{
|
||||
"inbox_id":None,
|
||||
"manual_upload_candidate_id":row.id,
|
||||
"from_stage":from_stage,
|
||||
"to_stage":stage.value,
|
||||
"changed_by":changed_by,
|
||||
"actor_kind":"user",
|
||||
"change_reason":change_reason,
|
||||
},
|
||||
commit=False,
|
||||
)
|
||||
row.status=stage.value
|
||||
row.updated_at=_now()
|
||||
self.session.add(row)
|
||||
await self.session.commit()
|
||||
return {
|
||||
"inbox_id":None,
|
||||
"manual_upload_id":str(row.id),
|
||||
"application_status":stage.value,
|
||||
"transition":serialize_stage_transition(transition),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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-CnIht1Bn.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CdtlKBsf.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CSn67wit.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,16 @@
|
|||
/* ============================================================
|
||||
pipeline.js — the kanban board's endpoints (backend/job/app.py).
|
||||
|
||||
The board is stitched from two modules, because there is no pipeline-specific
|
||||
READ endpoint:
|
||||
The board is stitched from two reads plus one write:
|
||||
|
||||
- rows come from GET /candidate/fetch (api/candidates.js `list`), the
|
||||
inbox -> users -> roles join, which is the only list payload carrying BOTH
|
||||
`application_status` (the stage) and `inbox_id` (what the write below needs);
|
||||
- the write is PATCH /candidate/stage, here.
|
||||
- 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.
|
||||
|
||||
Stage lives on inbox_messages.application_status and the transition history in
|
||||
application_stage_transitions; the server closes the open interval and opens a
|
||||
new one in the same commit, so the board never has to touch history itself.
|
||||
Inbox stage lives on inbox_messages.application_status; manual stage lives
|
||||
on manual_upload_candidate.status (same Candidate_application_Status values).
|
||||
History is application_stage_transitions in both cases.
|
||||
============================================================ */
|
||||
|
||||
import { request } from '../lib/apiClient'
|
||||
|
|
@ -60,31 +59,42 @@ export const STATUS_FROM_STAGE = {
|
|||
/**
|
||||
* Move one application to another stage. Requires pipeline.edit.
|
||||
*
|
||||
* `inboxId` is the INTEGER inbox.id — the row the candidate profile returns as
|
||||
* `inbox_id`, not the inbox_messages uuid the Inbox screen calls `id`; the route
|
||||
* runs int() on it and 404s on anything else.
|
||||
*
|
||||
* The server rejects a no-op move with 400 ("already at stage"), so callers must
|
||||
* not fire on a drop into the card's current column.
|
||||
* Inbox cards send `inboxId` (INTEGER inbox.id). Manual-upload cards send
|
||||
* `manualUploadId` (manual_upload_candidate.id UUID). The server 400s if both
|
||||
* or neither are present, and 400s a no-op move ("already at stage").
|
||||
*/
|
||||
export function changeStage({ inboxId, toStage, changeReason }) {
|
||||
export function changeStage({ inboxId, manualUploadId, toStage, changeReason }) {
|
||||
return request('/candidate/stage', {
|
||||
method: 'PATCH',
|
||||
body: { inbox_id: inboxId, to_stage: toStage, change_reason: changeReason ?? null },
|
||||
body: {
|
||||
...(inboxId != null ? { inbox_id: inboxId } : {}),
|
||||
...(manualUploadId != null ? { manual_upload_id: manualUploadId } : {}),
|
||||
to_stage: toStage,
|
||||
change_reason: changeReason ?? null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Inbox + manual-upload applications for the board — GET /pipeline/candidates/fetch
|
||||
* (pipeline.view). Envelope `data` is `{ inbox, manual_upload }`.
|
||||
*/
|
||||
export function listApplications() {
|
||||
return request('/pipeline/candidates/fetch')
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage history for one application — GET /pipeline/transitions/fetch
|
||||
* (pipeline.view). Rows are valid-time intervals: `valid_to` null is the stage
|
||||
* the candidate is in now. The board itself does not render history; this is the
|
||||
* feed behind a stage timeline on the profile.
|
||||
*
|
||||
* One of inboxId / transitionId is required — the route 400s with neither.
|
||||
* One of inboxId / manualUploadId / transitionId is required — the route 400s
|
||||
* with none of them.
|
||||
*/
|
||||
export function listTransitions({ inboxId, transitionId } = {}) {
|
||||
export function listTransitions({ inboxId, manualUploadId, transitionId } = {}) {
|
||||
return request('/pipeline/transitions/fetch', {
|
||||
params: { inbox_id: inboxId, transition_id: transitionId },
|
||||
params: { inbox_id: inboxId, manual_upload_id: manualUploadId, transition_id: transitionId },
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -105,6 +115,7 @@ export function toBoardCard(row) {
|
|||
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,
|
||||
|
|
@ -120,3 +131,30 @@ export function toBoardCard(row) {
|
|||
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) {
|
||||
return {
|
||||
id: `manual:${row.id}`,
|
||||
inboxId: null,
|
||||
manualUploadId: row.id,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
/* ============================================================
|
||||
Pipeline — the kanban board, on live backend data.
|
||||
|
||||
Cards come from GET /candidate/fetch (the inbox -> users -> roles join), the
|
||||
only list payload that carries the stage (`application_status`) together with
|
||||
the `inbox_id` that PATCH /candidate/stage writes against. Dropping a card
|
||||
fires that PATCH; the server closes the open application_stage_transitions
|
||||
interval and opens a new one in the same commit.
|
||||
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.
|
||||
|
||||
The job filter reads live posts from GET /job/fetch and matches on
|
||||
`assigned_job_post_id`, so an application nobody has assigned to a post shows
|
||||
under All Jobs only.
|
||||
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 card's skill tags are gone: the list payload has ai_score but no
|
||||
matched_keywords, and the Candidates screen set the precedent that a column
|
||||
|
|
@ -49,9 +47,19 @@ const BOARD_KEY = qk.pipeline.board({ limit: BOARD_LIMIT })
|
|||
const JOB_LIMIT = 100
|
||||
|
||||
async function fetchBoard() {
|
||||
const res = await candidatesApi.list({ limit: BOARD_LIMIT })
|
||||
const [res, pipe] = await Promise.all([
|
||||
candidatesApi.list({ limit: BOARD_LIMIT }),
|
||||
pipelineApi.listApplications(),
|
||||
])
|
||||
const rows = candidatesApi.toRows(res)
|
||||
return { cards: rows.map(pipelineApi.toBoardCard), total: res?.total ?? rows.length }
|
||||
const manuals = Array.isArray(pipe?.data?.manual_upload) ? pipe.data.manual_upload : []
|
||||
return {
|
||||
cards: [
|
||||
...rows.map(pipelineApi.toBoardCard),
|
||||
...manuals.map(pipelineApi.toManualBoardCard),
|
||||
],
|
||||
total: (res?.total ?? rows.length) + manuals.length,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJobs() {
|
||||
|
|
@ -106,6 +114,7 @@ export default function Pipeline() {
|
|||
mutationFn: ({ card, stage }) =>
|
||||
pipelineApi.changeStage({
|
||||
inboxId: card.inboxId,
|
||||
manualUploadId: card.manualUploadId,
|
||||
toStage: pipelineApi.STATUS_FROM_STAGE[stage],
|
||||
}),
|
||||
onMutate: async ({ card, stage }) => {
|
||||
|
|
@ -114,7 +123,7 @@ export default function Pipeline() {
|
|||
qc.setQueryData(BOARD_KEY, (old) =>
|
||||
old && {
|
||||
...old,
|
||||
cards: old.cards.map((c) => (c.inboxId === card.inboxId ? { ...c, stage } : c)),
|
||||
cards: old.cards.map((c) => (c.id === card.id ? { ...c, stage } : c)),
|
||||
},
|
||||
)
|
||||
return { previous }
|
||||
|
|
@ -140,7 +149,7 @@ export default function Pipeline() {
|
|||
const cand = candidates.find((c) => c.id === id)
|
||||
// A no-op move is a 400 server-side ("already at stage"), so it never leaves.
|
||||
if (!cand || cand.stage === stage) return
|
||||
if (cand.inboxId == null) {
|
||||
if (cand.inboxId == null && cand.manualUploadId == null) {
|
||||
toast(`${cand.name} has no application to move`, 'warning')
|
||||
return
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue