Merge pull request 'RecruiterHub' (#14) from RecruiterHub into main

Reviewed-on: #14
pull/15/head^2
ahmed.mujtaba 2026-08-13 08:31:35 +00:00
commit 2bdf82afc3
18 changed files with 1015 additions and 492 deletions

3
.gitignore vendored
View File

@ -1,4 +1,5 @@
# macOS
migrations/** */
.DS_Store
.AppleDouble
.LSOverride

View File

@ -13,11 +13,9 @@ from job.cost.views import HiringCost
from sqlalchemy.ext.asyncio import AsyncSession
from users.permissions import PermissionTag, require_permission
from job.job_post.views import JobPost,JobPostCreate
from job.job_post.models import JobPosts
from job.job_post.serializers import serialize_job_post
import logging
from users.views import User
from job.job_post.plugins import PlatformAlias
from job.job_post.models import SocialPlatform
from fastapi import UploadFile, File, Form
from dotenv import load_dotenv
from datetime import datetime, time, timezone
@ -118,9 +116,9 @@ class HiringCostCreate(BaseModel):
@router.get("/jobs/alias")
async def get_job_alias():
async def get_job_alias(session: AsyncSession = Depends(get_session)):
try:
alias_lst=[k.name for k in PlatformAlias]
alias_lst=await SocialPlatform.list_aliases(session)
return JSONResponse(content={"data":alias_lst,"status_code":200})
except HTTPException:
raise
@ -245,7 +243,7 @@ async def post_job(
try:
service=JobPost(session=session)
data=payload.model_dump()
if data['mode']=="customScheduled":
if data['mode']=="customScheduled" and data.get('scheduler_date'):
data['due_at']=datetime.combine(
data['scheduler_date'],
data['scheduler_time'] or time(0, 0, 0),
@ -365,6 +363,34 @@ async def fetch_job_posts(
raise HTTPException(status_code=500,detail=str(e))
@router.get("/jobs/fetch")
async def fetch_jobs(
search: str | None = Query(None),
department: str | None = Query(None),
requisition_status: str | None = Query(None),
employment_type: str | None = Query(None),
top: int | None = Query(None),
skip: int = Query(0, ge=0),
# Defaults False, unlike /job/fetch: a requisition list must show CLOSED
# requisitions, and those carry is_active = false. Soft-deleted rows are still
# excluded by the include_deleted branch in fetch_job_posts.
active_only: bool = Query(False),
current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=JobPost(session=session)
data,total=await service.fetch_jobs(
search=search,department=department,requisition_status=requisition_status,
employment_type=employment_type,top=top,skip=skip,active_only=active_only,
)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/candidate/fetch_by_id")
async def fetch_candidate_by_id(
candidate_id: str = Query(...),

View File

@ -667,38 +667,72 @@ class CandidateView:
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@staticmethod
def _attach_job_post(data,payload,*,as_assigned=False):
"""Merge one serialized job post onto a candidate payload.
Pure, so the per-id path and the batched one below cannot drift. A missing
post attaches nothing, matching the old early return.
"""
if not isinstance(data,dict) or not payload:
return payload
if as_assigned:
data["assigned_job_post"]=payload
if payload.get("created_by_name"):
data["recruiter"]=payload.get("created_by_name")
data["recruiter_id"]=payload.get("created_by")
if payload.get("title"):
data["job_title"]=payload.get("title")
else:
data.setdefault("job_posts",[]).append(payload)
if data.get("recruiter") is None and payload.get("created_by_name"):
data["recruiter"]=payload.get("created_by_name")
data["recruiter_id"]=payload.get("created_by")
if data.get("job_title") is None and payload.get("title"):
data["job_title"]=payload.get("title")
return payload
async def _job_posts_by_id(self,ids):
"""Serialized job posts keyed by id — one query for a whole page of rows.
active_only=False mirrors get_job_post_by_id, which filters neither flag:
an application assigned to a closed post must keep its title.
"""
wanted=[]
seen=set()
for raw in ids or []:
key=str(raw) if raw else None
if not key or key in seen:
continue
seen.add(key)
wanted.append(key)
if not wanted:
return {}
rows=await JobPosts.get_by_ids(self.session,wanted,active_only=False)
return {str(r.id):serialize_job_post(r) for r in rows}
async def get_job_post_by_id(self,record_id,data=None,*,as_assigned=False):
"""Load full job_posts row and optionally append it onto a candidate payload."""
try:
job_post_data=await JobPosts.get_job_post_by_id(session=self.session,record_id=record_id)
if not job_post_data:
return None
payload=serialize_job_post(job_post_data)
if isinstance(data,dict):
if as_assigned:
data["assigned_job_post"]=payload
if payload.get("created_by_name"):
data["recruiter"]=payload.get("created_by_name")
data["recruiter_id"]=payload.get("created_by")
if payload.get("title"):
data["job_title"]=payload.get("title")
else:
data.setdefault("job_posts",[]).append(payload)
if data.get("recruiter") is None and payload.get("created_by_name"):
data["recruiter"]=payload.get("created_by_name")
data["recruiter_id"]=payload.get("created_by")
if data.get("job_title") is None and payload.get("title"):
data["job_title"]=payload.get("title")
return payload
return self._attach_job_post(data,serialize_job_post(job_post_data),as_assigned=as_assigned)
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
async def attach_job_posts(self,data):
"""Normalize list/single, serialize each record, attach full job_posts rows."""
"""Normalize list/single, serialize each record, attach full job_posts rows.
Two batched queries per page, not two per row a 200-row pipeline board
issued 600+ sequential job_posts round trips before.
"""
single=not isinstance(data,list)
records=[data] if single else list(data or [])
scores=await self._scores_by_message([getattr(r,"message_id",None) for r in records])
enriched=[]
payloads=[]
wanted=[]
for record in records:
payload=serialize_candidate_profile(record)
payload["job_posts"]=[]
@ -707,13 +741,23 @@ class CandidateView:
if scored:
payload["ai_score"]=scored[0].match_score
payload["recommendation"]=self._recommendation(scored[0].match_score)
payloads.append(payload)
if payload.get("assigned_job_post_id"):
wanted.append(payload["assigned_job_post_id"])
wanted.extend(payload.get("suggested_job_post_ids") or [])
posts=await self._job_posts_by_id(wanted)
# Copy per attach: two candidates on the same post held independent dicts
# back when every row re-serialized its own.
for payload in payloads:
assigned_id=payload.get("assigned_job_post_id")
if assigned_id:
await self.get_job_post_by_id(record_id=assigned_id,data=payload,as_assigned=True)
post=posts.get(str(assigned_id))
self._attach_job_post(payload,dict(post) if post else None,as_assigned=True)
for job_id in payload.get("suggested_job_post_ids") or []:
await self.get_job_post_by_id(record_id=job_id,data=payload)
enriched.append(payload)
return enriched[0] if single else enriched
post=posts.get(str(job_id))
self._attach_job_post(payload,dict(post) if post else None)
return payloads[0] if single else payloads
async def attach_profile_detail(self,data):
"""Detail mode: flatten child collections across every Inbox row for the candidate."""

View File

@ -111,6 +111,10 @@ class JobPosts(SQLModel, table=True):
skip: int = 0,
ids: list[str] | None = None,
active_only: bool = True,
include_deleted: bool = False,
department: str | None = None,
requisition_status: str | None = None,
employment_type: str | None = None,
):
if ids:
rows = await cls.get_by_ids(session, ids, active_only=active_only)
@ -119,11 +123,19 @@ class JobPosts(SQLModel, table=True):
statement = select(cls)
if active_only:
statement = statement.where(cls.is_active == True, cls.is_deleted == False) # noqa: E712
elif not include_deleted:
statement = statement.where(cls.is_deleted == False) # noqa: E712
if search:
like = f"%{search.strip()}%"
statement = statement.where(
or_(cls.title.ilike(like), cls.location.ilike(like))
or_(cls.title.ilike(like), cls.location.ilike(like), cls.department.ilike(like))
)
if department:
statement = statement.where(cls.department == department)
if requisition_status:
statement = statement.where(cls.requisition_status == requisition_status)
if employment_type:
statement = statement.where(cls.employment_type == employment_type)
count_statement = select(func.count()).select_from(statement.subquery())
total = (await session.execute(count_statement)).scalar_one()
statement = statement.order_by(cls.created_at.desc())
@ -134,6 +146,23 @@ class JobPosts(SQLModel, table=True):
result = await session.execute(statement)
return list(result.scalars().all()), total
@classmethod
async def recruiter_names(cls, session: AsyncSession, recruiter_ids) -> dict[str, str]:
"""Resolve {recruiter_id: name} for a page of rows in a single query."""
# Local import and COLUMN select, both load-bearing: users.models imports
# this module at its top, so a module-level import here is a startup cycle;
# and a Users *entity* would drag in its five selectin relations for what is
# a two-column lookup.
from users.models import Users
uids = {u for u in (recruiter_ids or []) if u}
if not uids:
return {}
result = await session.execute(
select(Users.id, Users.name).where(Users.id.in_(uids))
)
return {str(uid): name for uid, name in result.all()}
@classmethod
async def insert_job_post(cls, session: AsyncSession, fields: dict):
row = cls(**fields)
@ -186,4 +215,45 @@ class JobPosts(SQLModel, table=True):
await session.refresh(row)
return row
import users.models as _users_models
class SocialPlatform(SQLModel, table=True):
"""Buffer publish aliases (fb → facebook). Spelling tolerance + UI list, not an allowlist."""
__tablename__ = "social_platforms"
id: int | None = Field(default=None, primary_key=True)
alias: str = Field(max_length=40, unique=True, index=True)
buffer_service: str
label: str
is_active: bool = Field(default=True)
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
@classmethod
async def get_by_alias(cls, session: AsyncSession, alias: str):
key = (alias or "").strip().lower()
if not key:
return None
result = await session.execute(select(cls).where(cls.alias == key))
return result.scalars().first()
@classmethod
async def list_active(cls, session: AsyncSession):
result = await session.execute(
select(cls).where(cls.is_active == True).order_by(cls.id.asc()) # noqa: E712
)
return list(result.scalars().all())
@classmethod
async def list_aliases(cls, session: AsyncSession):
rows = await cls.list_active(session)
return [r.alias for r in rows]
@classmethod
async def alias_map(cls, session: AsyncSession) -> dict[str, str]:
"""alias → Buffer service name for normalize_platform / resolve_channel."""
rows = await cls.list_active(session)
return {r.alias: r.buffer_service for r in rows}
import users.models as _users_models

View File

@ -9,7 +9,6 @@ import json
import os
import re
from datetime import datetime
from enum import Enum
import httpx
from dotenv import load_dotenv
@ -40,38 +39,20 @@ def local_status(buffer_status) -> str:
return BUFFER_STATUS_TO_LOCAL.get(buffer_status or "", "scheduled")
class PlatformAlias(str, Enum):
"""Shorthands people type, mapped to Buffer's own `Service` values.
def normalize_platform(value, aliases=None) -> str:
"""Case/punctuation-insensitive key for comparing a requested platform.
Member name = what arrives in the request, value = what Buffer calls it. This is
spelling tolerance only, *not* the list of supported networks: anything absent here
still resolves, because `resolve_channel` matches against the services Buffer
actually reports. A newly connected network needs no entry.
Names that share a value (ig/insta) become Enum aliases, which is exactly the
intent -- lookup is by member name via `__members__`.
`aliases` is an optional aliasBuffer-service map (from SocialPlatform). Missing
keys fall through to the normalized key so Buffer's live channel list remains the
real allowlist DB rows are spelling tolerance only.
"""
fb = "facebook"
ig = "instagram"
insta = "instagram"
li = "linkedin"
x = "twitter"
tweet = "twitter"
yt = "youtube"
gbp = "googlebusiness"
google = "googlebusiness"
googlebusinessprofile = "googlebusiness"
def normalize_platform(value) -> str:
"""Case/punctuation-insensitive key for comparing a requested platform."""
key = re.sub(r"[^a-z0-9]", "", str(value or "").lower())
alias = PlatformAlias.__members__.get(key)
return alias.value if alias else key
if aliases:
return aliases.get(key, key)
return key
async def resolve_channel(platform, channels=None) -> dict:
async def resolve_channel(platform, channels=None, aliases=None) -> dict:
"""Return the connected channel for `platform`.
Nothing is special-cased per network: the request is matched against the services
@ -79,14 +60,14 @@ async def resolve_channel(platform, channels=None) -> dict:
resolve. Falls back to matching the channel's handle or display name so
"ahmedmujtababaig" works as well as "linkedin".
"""
wanted = normalize_platform(platform)
wanted = normalize_platform(platform, aliases)
if not wanted:
raise BufferError("No platform given")
if channels is None:
channels = await list_buffer_channels()
for field in ("service", "name", "displayName"):
for channel in channels:
if normalize_platform(channel.get(field)) == wanted:
if normalize_platform(channel.get(field), aliases) == wanted:
return channel
available = sorted({c.get("service") for c in channels if c.get("service")})
raise BufferError(

View File

@ -25,3 +25,39 @@ def serialize_job_post(row) -> dict:
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
def serialize_job_row(row, *, recruiter_name=None) -> dict:
"""Requisition view of a job post, for the Jobs screen.
Deliberately separate from serialize_job_post: that payload is shared by the
inbox, candidate and matching paths, and widening it would change five
response shapes at once.
"""
return {
"id": str(row.id),
"title": row.title,
"department": row.department or None,
"location": row.location,
"employment_type": row.employment_type,
"vacancies": row.vacancies,
"platform": row.platform or None,
# Two different lifecycles, never conflate: requisition_status is hiring
# (open/closed/on_hold), status is Buffer publishing (draft/scheduled/...).
"requisition_status": row.requisition_status,
"status": row.status,
"experience_min": row.experience_min,
"experience_max": row.experience_max,
"salary": row.salary,
"requirements": list(row.requirements or []),
"optional_skills": list(row.optional_skills or []),
"description": row.description,
"is_active": row.is_active,
"closed_at": row.closed_at.isoformat() if row.closed_at else None,
"current_recruiter_id": str(row.current_recruiter_id) if row.current_recruiter_id else None,
"recruiter_name": recruiter_name,
"created_by": str(row.created_by) if row.created_by else None,
"created_by_name": row.user.name if getattr(row, "user", None) else None,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}

View File

@ -6,7 +6,7 @@ from dotenv import load_dotenv
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, model_validator
from job.job_post.models import JobPosts
from job.job_post.models import JobPosts,SocialPlatform
from job.job_post.plugins import (
BufferError,
create_buffer_post,
@ -17,7 +17,7 @@ from job.job_post.plugins import (
render_job_post,
resolve_channel,
)
from job.job_post.serializers import serialize_job_post
from job.job_post.serializers import serialize_job_post, serialize_job_row
load_dotenv()
@ -31,7 +31,8 @@ class JobPostCreate(BaseModel):
salary: str = "Anonymous"
location: str | None = None
employment_type: str | None = None
platform: str = "linkedin"
department: str | None = None
vacancies: int = 1
description: str | None = None
platform: str | None = None
channel_id: str | None = None
@ -45,8 +46,8 @@ class JobPostCreate(BaseModel):
allowed = {"addToQueue", "shareNow", "customScheduled"}
if self.mode not in allowed:
raise ValueError(f"mode must be one of {sorted(allowed)}")
if self.mode == "customScheduled" and not self.due_at:
raise ValueError("due_at is required when mode is customScheduled")
if self.mode == "customScheduled" and not self.due_at and not self.scheduler_date:
raise ValueError("due_at or scheduler_date is required when mode is customScheduled")
return self
class JobPost:
@ -55,7 +56,7 @@ class JobPost:
self.buffer_api=os.getenv("BUFFER_API")
self.channel_id=os.getenv("BUFFER_CHANNEL_ID")
async def _resolve_target(self,payload):
async def _resolve_target(self,payload,aliases=None):
"""Pick the Buffer channel to post to, and the service it belongs to.
Precedence: an explicit channel_id, then the requested platform, then the
@ -65,7 +66,7 @@ class JobPost:
if payload.get("channel_id"):
return payload["channel_id"],None
if payload.get("platform"):
channel=await resolve_channel(payload["platform"])
channel=await resolve_channel(payload["platform"],aliases=aliases)
return channel["id"],channel.get("service")
if self.channel_id:
return self.channel_id,None
@ -75,8 +76,9 @@ class JobPost:
)
async def post_job(self,payload,current_user):
aliases=await SocialPlatform.alias_map(self.session)
try:
channel_id,service=await self._resolve_target(payload)
channel_id,service=await self._resolve_target(payload,aliases)
except (httpx.HTTPError,BufferError,RuntimeError) as e:
raise HTTPException(status_code=502,detail=f"Failed to resolve Buffer channel: {e}") from e
@ -90,6 +92,9 @@ class JobPost:
"requirements":list(payload.get("requirements") or []),
"optional_skills":list(payload.get("optional_skills") or []),
"salary":payload.get("salary") or "Anonymous",
# department is NOT NULL with a server_default of "" — pass "", never None.
"department":payload.get("department") or "",
"vacancies":payload.get("vacancies") or 1,
"description":payload.get("description"),
"post_text":text,
"channel_id":channel_id,
@ -99,7 +104,7 @@ class JobPost:
# Only set platform when it is actually known: passing None would override the
# column default and break the NOT NULL constraint. Buffer's channelService
# replaces this with the authoritative value once the post is created.
known_platform=service or normalize_platform(payload.get("platform"))
known_platform=service or normalize_platform(payload.get("platform"),aliases)
if known_platform:
fields["platform"]=known_platform
row=await JobPosts.insert_job_post(self.session,fields)
@ -143,3 +148,18 @@ class JobPost:
active_only=active_only,
)
return [serialize_job_post(r) for r in rows],total
async def fetch_jobs(self,search=None,department=None,requisition_status=None,
employment_type=None,top=None,skip=0,active_only=True):
rows,total=await JobPosts.fetch_job_posts(
self.session,search=search,top=top,skip=skip,active_only=active_only,
department=department,requisition_status=requisition_status,
employment_type=employment_type,
)
names=await JobPosts.recruiter_names(
self.session,[r.current_recruiter_id for r in rows],
)
return [
serialize_job_row(r,recruiter_name=names.get(str(r.current_recruiter_id)))
for r in rows
],total

View File

@ -0,0 +1,22 @@
-- 003_seed_social_platforms.sql
-- Manual one-shot: seed Buffer publish aliases formerly hardcoded in
-- PlatformAlias (job/job_post/plugins.py). Spelling tolerance + /jobs/alias UI
-- list only — missing aliases still resolve via Buffer's live channel list.
--
-- Order: (1) alembic upgrade / autogenerate so app.social_platforms exists,
-- (2) this file. Applied automatically at startup by alembic_setup.run_manual_sql()
-- once the schema is at head; recorded in manual_migrations. Safe to re-run by hand.
INSERT INTO app.social_platforms (alias, buffer_service, label, is_active, created_at, updated_at)
VALUES
('fb', 'facebook', 'Facebook', true, NOW(), NOW()),
('ig', 'instagram', 'Instagram', true, NOW(), NOW()),
('insta', 'instagram', 'Instagram', true, NOW(), NOW()),
('li', 'linkedin', 'LinkedIn', true, NOW(), NOW()),
('x', 'twitter', 'Twitter/X', true, NOW(), NOW()),
('tweet', 'twitter', 'Twitter/X', true, NOW(), NOW()),
('yt', 'youtube', 'YouTube', true, NOW(), NOW()),
('gbp', 'googlebusiness', 'Google Business', true, NOW(), NOW()),
('google', 'googlebusiness', 'Google Business', true, NOW(), NOW()),
('googlebusinessprofile', 'googlebusiness', 'Google Business', true, NOW(), NOW())
ON CONFLICT (alias) DO NOTHING;

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-D3fSq-cJ.js"></script>
<script type="module" crossorigin src="/assets/index-D1YVNnju.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CSn67wit.css">
</head>
<body>

View File

@ -186,8 +186,8 @@ export function toRows(res) {
/**
* favorite/rating live on the `inbox` row, not on the user, so the server applies
* the change to EVERY application belonging to the candidate and hands back the
* refreshed detail payload. Pipeline stage is not writable here no endpoint
* updates inbox_messages.application_status yet.
* refreshed detail payload. Pipeline stage is not writable here it moves one
* APPLICATION at a time through PATCH /candidate/stage (api/pipeline.js).
*/
export function update(userId, payload) {
return request('/candidate/update', { method: 'PATCH', params: { user_id: userId }, body: payload })

View File

@ -18,3 +18,19 @@ export function list({ search, top, skip, ids, activeOnly = true } = {}) {
},
})
}
/**
* Create a job post backend/job/app.py `POST /job/post-job` (job_board.create).
*
* CREATES the row AND publishes it through Buffer; there is no draft-only path.
* A 502 means the row was created but the Buffer post failed (post_job marks it
* status="failed" before re-raising), so do not report it as "nothing happened".
*/
export function create(payload) {
return request('/job/post-job', { method: 'POST', body: payload })
}
/** Connected Buffer channels — GET /job/buffer/channels (job_board.view). */
export function listChannels() {
return request('/job/buffer/channels')
}

61
frontend/src/api/jobs.js Normal file
View File

@ -0,0 +1,61 @@
import { request } from '../lib/apiClient'
/**
* Job requisitions backend/job/app.py `GET /jobs/fetch`.
*
* Distinct from api/jobPosts.js on purpose: that serves the Matching and CV-import
* PICKERS off /job/fetch (job_board.view). This is the requisition list behind
* jobs.view, and carries department / vacancies / requisition_status, which the
* picker payload does not.
*/
export function list({ search, department, requisitionStatus, employmentType,
top, skip, activeOnly } = {}) {
return request('/jobs/fetch', {
params: {
search,
department,
requisition_status: requisitionStatus,
employment_type: employmentType,
top,
skip,
active_only: activeOnly,
},
})
}
/* requisition_status is the HIRING lifecycle. The row's separate `status` field is
the Buffer publishing lifecycle never map the two onto one badge. */
const REQ_STATUS_LABEL = { open: 'Open', closed: 'Closed', on_hold: 'On Hold' }
export const JOB_STATUSES = Object.values(REQ_STATUS_LABEL)
function experienceLabel(min, max) {
if (min == null && max == null) return null
if (min != null && max != null) return `${min}${max} years`
return `${min ?? max}+ years`
}
/** API row -> what the Jobs table and detail modal render. */
export function toJobView(row) {
return {
id: row.id,
title: row.title,
department: row.department,
location: row.location,
type: row.employment_type,
vacancies: row.vacancies,
platform: row.platform || null,
status: REQ_STATUS_LABEL[row.requisition_status] ?? row.requisition_status,
publishStatus: row.status,
recruiter: row.recruiter_name,
recruiterId: row.current_recruiter_id,
createdByName: row.created_by_name,
// A real Date, so the existing `sortValue: j => j.created.getTime()` keeps working.
created: row.created_at ? new Date(row.created_at) : null,
closedAt: row.closed_at ? new Date(row.closed_at) : null,
experience: experienceLabel(row.experience_min, row.experience_max),
salary: row.salary,
skills: row.requirements ?? [],
optionalSkills: row.optional_skills ?? [],
description: row.description,
}
}

View File

@ -0,0 +1,122 @@
/* ============================================================
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:
- 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.
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.
============================================================ */
import { request } from '../lib/apiClient'
/**
* Candidate_application_Status (backend/inbox/enums.py) -> the board column.
*
* The enum has 11 values and the board 7 columns, so this is deliberately
* many-to-one: CLOSED is the column DEFAULT (untriaged, never mailed anywhere)
* and reads as Applied rather than as an outcome, ONHOLD parks in Screening, and
* APPROVED is the pre-HIRED spelling of a hire.
*
* Anything unmapped falls through to Applied rather than vanishing from the
* board a card with no column is a candidate nobody sees.
*/
export const STAGE_FROM_STATUS = {
PENDING: 'Applied',
CLOSED: 'Applied',
PROCESS: 'Screening',
ONHOLD: 'Screening',
SCREENING: 'Screening',
ASSESSMENT: 'Assessment',
INTERVIEW: 'Interview',
OFFER: 'Offer',
HIRED: 'Hired',
APPROVED: 'Hired',
REJECTED: 'Rejected',
}
/**
* Column -> the status WRITTEN on a drop. Not the inverse of the map above: the
* legacy spellings (PROCESS, ONHOLD, APPROVED, CLOSED) are readable but are
* never written, so the vocabulary converges on the canonical value as cards get
* moved. Applied writes PENDING because the enum has no APPLIED member.
*/
export const STATUS_FROM_STAGE = {
Applied: 'PENDING',
Screening: 'SCREENING',
Assessment: 'ASSESSMENT',
Interview: 'INTERVIEW',
Offer: 'OFFER',
Hired: 'HIRED',
Rejected: 'REJECTED',
}
/**
* 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.
*/
export function changeStage({ inboxId, toStage, changeReason }) {
return request('/candidate/stage', {
method: 'PATCH',
body: { inbox_id: inboxId, to_stage: toStage, change_reason: changeReason ?? null },
})
}
/**
* 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.
*/
export function listTransitions({ inboxId, transitionId } = {}) {
return request('/pipeline/transitions/fetch', {
params: { inbox_id: inboxId, transition_id: 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,
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,
}
}

View File

@ -29,13 +29,21 @@ export const qk = {
},
jobs: {
all: () => ['jobs'],
list: () => ['jobs', 'list'],
list: (p = {}) => ['jobs', 'list', p],
},
candidates: {
all: () => ['candidates'],
list: (p = {}) => ['candidates', 'list', p],
detail: (id) => ['candidates', 'detail', id],
},
// Board rows come from the same endpoint as qk.candidates.list but are cached
// MAPPED (kanban cards, not the raw envelope), so they need their own key —
// sharing one would poison whichever screen mounted first.
pipeline: {
all: () => ['pipeline'],
board: (p = {}) => ['pipeline', 'board', p],
transitions: (inboxId) => ['pipeline', 'transitions', inboxId],
},
analytics: {
all: () => ['analytics'],
kpis: (p = {}) => ['analytics', 'kpis', p],

View File

@ -94,7 +94,7 @@ export default function Candidates() {
const navigate = useNavigate()
const candidatesQuery = useQuery({ queryKey: qk.candidates.list(), queryFn: fetchCandidates })
const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs })
const jobsQuery = useQuery({ queryKey: qk.jobPosts.list(), queryFn: fetchJobs })
const candidates = useMemo(() => candidatesQuery.data ?? [], [candidatesQuery.data])
const jobsById = useMemo(
() => Object.fromEntries((jobsQuery.data ?? []).map((j) => [j.id, j])),

View File

@ -42,7 +42,7 @@ let rowSeq = 0
export default function CvImport() {
const { toast } = useToast()
const qc = useQueryClient()
const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs })
const jobsQuery = useQuery({ queryKey: qk.jobPosts.list(), queryFn: fetchJobs })
const jobs = jobsQuery.data ?? []
const [jobId, setJobId] = useState('')

View File

@ -1,33 +1,54 @@
/* ============================================================
Jobs the reference CRUD pattern for the app: filtered DataTable plus
view / reassign / create-edit / delete modals. The other CRUD screens follow
this shape.
Jobs requisition list on live backend data (GET /jobs/fetch).
Facets, columns and actions that had no backing column are gone rather than
rendered as placeholders the Candidates / Inbox screens set that precedent.
Create is wired to POST /job/post-job (create + Buffer publish). Edit / delete
/ reassign stay off until real write endpoints exist; publishing still routes
to /jobboard.
============================================================ */
import { useEffect, useMemo, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import DataTable from '../ui/DataTable'
import Modal from '../ui/Modal'
import { Avatar, Badge, FieldError, Icon, ProgressBar } from '../ui/primitives'
import { Badge, EmptyState, FieldError, Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
import { useFormState } from '../components/AuthLayout'
import { seedQuery, useSeedMutation } from '../data/seedQueries'
import {
businessUnits, departments, educationLevels, empTypes, fmtDate, fmtShort,
getRecruiterByName, grades, jobStatuses, locations, moneyK, TODAY,
} from '../data/seed'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as jobsApi from '../api/jobs'
import * as jobPostsApi from '../api/jobPosts'
import { JOB_STATUSES } from '../api/jobs'
import { empTypes, fmtShort } from '../data/seed'
const JOB_LIMIT = 200
async function fetchJobs() {
const res = await jobsApi.list({ top: JOB_LIMIT })
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map(jobsApi.toJobView)
}
function splitLines(text) {
return String(text || '')
.split('\n')
.map((s) => s.trim())
.filter(Boolean)
}
export default function Jobs() {
const { toast } = useToast()
const { can } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const qc = useQueryClient()
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
const { data: managers = [] } = useQuery(seedQuery('managers'))
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
const updateJobs = useSeedMutation('jobs')
const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs })
const jobs = useMemo(() => jobsQuery.data ?? [], [jobsQuery.data])
const [q, setQ] = useState('')
const [dept, setDept] = useState('')
@ -35,18 +56,51 @@ export default function Jobs() {
const [type, setType] = useState('')
const [viewing, setViewing] = useState(null)
const [editing, setEditing] = useState(undefined) // undefined = closed, null = create
const [reassigning, setReassigning] = useState(null)
const [deleting, setDeleting] = useState(null)
const [creating, setCreating] = useState(false)
// Deep-link intents from global search, the dashboard and the manager portal.
useEffect(() => {
const st = location.state
if (!st) return
if (st.openCreate) setEditing(null)
if (st.openCreate) setCreating(true)
if (st.openJob) setViewing(jobs.find((j) => j.id === st.openJob) ?? null)
}, [location.state, jobs])
const channelsQuery = useQuery({
queryKey: qk.jobPosts.all(),
queryFn: async () => (await jobPostsApi.listChannels())?.data ?? [],
enabled: creating,
})
const createJob = useMutation({
mutationFn: (payload) => jobPostsApi.create(payload),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.jobs.all() })
setCreating(false)
toast('Job created and sent to the channel', 'success')
},
onError: (err) => {
// 502: row was created but Buffer publish failed refresh the board and
// say so; a flat "create failed" toast would be wrong.
qc.invalidateQueries({ queryKey: qk.jobs.all() })
if (err?.status === 502) {
setCreating(false)
toast('Job created, but publishing failed — see its status on the board.', 'error')
return
}
toast(friendlyAuthError(err, 'Could not create the job'), 'error')
},
})
const departmentOptions = useMemo(
() => [...new Set(jobs.map((j) => j.department).filter(Boolean))].sort(),
[jobs],
)
const typeOptions = useMemo(
() => [...new Set(jobs.map((j) => j.type).filter(Boolean))].sort(),
[jobs],
)
const rows = useMemo(
() =>
jobs.filter((j) => {
@ -55,7 +109,10 @@ export default function Jobs() {
if (type && j.type !== type) return false
if (q) {
const term = q.toLowerCase()
const hay = (j.title + j.id + j.department + j.manager + j.recruiter + j.location).toLowerCase()
const hay = [j.title, j.department, j.recruiter, j.location]
.filter(Boolean)
.join(' ')
.toLowerCase()
if (!hay.includes(term)) return false
}
return true
@ -66,36 +123,32 @@ export default function Jobs() {
const openCount = jobs.filter((j) => j.status === 'Open').length
const columns = [
{ key: 'id', label: 'Job ID', sortable: true, render: (j) => <span className="cell-mono">{j.id}</span> },
{
key: 'title', label: 'Job Title', sortable: true,
render: (j) => (
<>
<div className="cell-primary">{j.title}</div>
<div className="cell-sub">{j.businessUnit} · {j.grade}</div>
<div className="cell-sub">{j.department || '—'}</div>
</>
),
},
{ key: 'department', label: 'Department', sortable: true },
{
key: 'manager', label: 'Hiring Manager', sortable: true,
render: (j) => (
<div className="user-cell"><Avatar name={j.manager} /><span>{j.manager}</span></div>
),
},
{ key: 'location', label: 'Location', sortable: true, render: (j) => <span className="text-muted">{j.location}</span> },
{ key: 'type', label: 'Type', render: (j) => <Badge className="b-gray">{j.type}</Badge> },
{ key: 'applications', label: 'Apps', sortable: true, align: 'center', render: (j) => <b>{j.applications}</b> },
{ key: 'department', label: 'Department', sortable: true, render: (j) => j.department || '—' },
{ key: 'location', label: 'Location', sortable: true, render: (j) => <span className="text-muted">{j.location || '—'}</span> },
{ key: 'type', label: 'Type', render: (j) => j.type ? <Badge className="b-gray">{j.type}</Badge> : '—' },
{ key: 'platform', label: 'Platform', sortable: true, render: (j) => j.platform ? <Badge className="b-gray">{j.platform}</Badge> : '—' },
{ key: 'vacancies', label: 'Vacancies', sortable: true, align: 'center', render: (j) => <b>{j.vacancies ?? '—'}</b> },
{ key: 'status', label: 'Status', sortable: true, render: (j) => <Badge>{j.status}</Badge> },
{ key: 'created', label: 'Created', sortable: true, sortValue: (j) => j.created.getTime(), render: (j) => <span className="text-muted">{fmtShort(j.created)}</span> },
{
key: 'created', label: 'Created', sortable: true,
sortValue: (j) => (j.created ? j.created.getTime() : 0),
render: (j) => <span className="text-muted">{j.created ? fmtShort(j.created) : '—'}</span>,
},
{
key: '_a', label: 'Actions', align: 'right',
render: (j) => (
<div className="row-actions">
<button className="act-btn" data-tip="View" onClick={() => setViewing(j)}><Icon name="eye" /></button>
<button className="act-btn" data-tip="Publish" onClick={() => navigate('/jobboard', { state: { publishJob: j.id } })}><Icon name="send" /></button>
<button className="act-btn" data-tip="Edit" onClick={() => setEditing(j)}><Icon name="edit" /></button>
<button className="act-btn danger" data-tip="Delete" onClick={() => setDeleting(j)}><Icon name="trash" /></button>
</div>
),
},
@ -112,109 +165,78 @@ export default function Jobs() {
<button className="btn btn-secondary" onClick={() => toast('Jobs exported to CSV', 'success')}>
<Icon name="download" /> Export
</button>
<button className="btn btn-primary" onClick={() => setEditing(null)}>
<Icon name="plus" /> Create Job
</button>
{can('job_board.create') && (
<button className="btn btn-primary" onClick={() => setCreating(true)}>
<Icon name="plus" /> Create Job
</button>
)}
</div>
</div>
<div className="card">
<div className="card-body" style={{ paddingBottom: 0 }}>
<div className="toolbar">
<div className="toolbar-search">
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search jobs, IDs, managers…" />
</div>
<select className="select" value={dept} onChange={(e) => setDept(e.target.value)}>
<option value="">All Departments</option>
{departments.map((d) => <option key={d}>{d}</option>)}
</select>
<select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">All Status</option>
{jobStatuses.map((s) => <option key={s}>{s}</option>)}
</select>
<select className="select" value={type} onChange={(e) => setType(e.target.value)}>
<option value="">All Types</option>
{empTypes.map((t) => <option key={t}>{t}</option>)}
</select>
{jobsQuery.isPending && (
<div className="card-body">
<EmptyState icon="briefcase" title="Loading…">Fetching requisitions from the server.</EmptyState>
</div>
</div>
<DataTable columns={columns} rows={rows} pageSize={8} />
)}
{jobsQuery.isError && (
<div className="card-body">
<EmptyState icon="briefcase" title="Couldnt load jobs">
{friendlyAuthError(jobsQuery.error, 'Request failed')}
</EmptyState>
</div>
)}
{!jobsQuery.isPending && !jobsQuery.isError && (
<>
<div className="card-body" style={{ paddingBottom: 0 }}>
<div className="toolbar">
<div className="toolbar-search">
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search title, department, location…" />
</div>
<select className="select" value={dept} onChange={(e) => setDept(e.target.value)}>
<option value="">All Departments</option>
{departmentOptions.map((d) => <option key={d}>{d}</option>)}
</select>
<select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">All Status</option>
{JOB_STATUSES.map((s) => <option key={s}>{s}</option>)}
</select>
<select className="select" value={type} onChange={(e) => setType(e.target.value)}>
<option value="">All Types</option>
{typeOptions.map((t) => <option key={t}>{t}</option>)}
</select>
</div>
</div>
<DataTable
columns={columns}
rows={rows}
pageSize={8}
empty="No requisitions match these filters."
/>
</>
)}
</div>
{viewing && (
<JobDetail
job={viewing}
onClose={() => setViewing(null)}
onEdit={() => { const j = viewing; setViewing(null); setEditing(j) }}
onPublish={() => { const id = viewing.id; setViewing(null); navigate('/jobboard', { state: { publishJob: id } }) }}
onReassign={() => { const j = viewing; setViewing(null); setReassigning(j) }}
/>
)}
{editing !== undefined && (
{creating && (
<JobForm
job={editing}
managers={managers}
recruiters={recruiters}
count={jobs.length}
onClose={() => setEditing(undefined)}
onSave={(next, isEdit) => {
updateJobs((js) => (isEdit ? js.map((j) => (j.id === next.id ? next : j)) : [next, ...js]))
setEditing(undefined)
toast(isEdit ? 'Job updated successfully' : 'Job created successfully', 'success')
}}
onInvalid={() => toast('Please fix the highlighted fields', 'error')}
departmentOptions={departmentOptions}
channels={channelsQuery.data ?? []}
channelsLoading={channelsQuery.isPending}
channelsError={channelsQuery.isError}
busy={createJob.isPending}
onClose={() => setCreating(false)}
onSubmit={(payload) => createJob.mutate(payload)}
/>
)}
{reassigning && (
<Reassign
job={reassigning}
recruiters={recruiters}
onClose={() => setReassigning(null)}
onSave={(name) => {
updateJobs((js) => js.map((j) => (j.id === reassigning.id ? { ...j, recruiter: name } : j)))
setReassigning(null)
toast('Recruiter reassigned', 'success')
}}
/>
)}
{deleting && (
<Modal
title="Confirm Deletion"
onClose={() => setDeleting(null)}
footer={
<>
<button className="btn btn-secondary" onClick={() => setDeleting(null)}>Cancel</button>
<button
className="btn btn-danger"
onClick={() => {
updateJobs((js) => js.filter((j) => j.id !== deleting.id))
setDeleting(null)
toast('Job deleted', 'success')
}}
>
<Icon name="trash" /> Delete Job
</button>
</>
}
>
<div className="flex gap-16 items-center">
<span className="kpi-icn i-red" style={{ width: 48, height: 48, borderRadius: 12, flexShrink: 0 }}>
<Icon name="trash" />
</span>
<div>
<p style={{ fontWeight: 600, fontSize: 15 }}>Delete {deleting.title}?</p>
<p className="text-muted" style={{ marginTop: 4 }}>
This will permanently remove requisition {deleting.id} and its {deleting.applications} applications.
This action cannot be undone.
</p>
</div>
</div>
</Modal>
)}
</div>
)
}
@ -224,189 +246,97 @@ const SECTION_LABEL = {
textTransform: 'uppercase', marginBottom: 6,
}
function JobDetail({ job: j, onClose, onEdit, onPublish, onReassign }) {
const r = getRecruiterByName(j.recruiter)
const loadCls = r ? (r.workload > 80 ? 'b-red' : r.workload > 60 ? 'b-amber' : 'b-green') : ''
return (
<Modal
title="Job Details"
subtitle={j.id}
size="modal-lg"
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Close</button>
<button className="btn btn-secondary" onClick={onPublish}><Icon name="send" /> Publish</button>
<button className="btn btn-primary" onClick={onEdit}><Icon name="edit" /> Edit Job</button>
</>
}
>
<div className="flex items-center gap-16 mb-18">
<span className="kpi-icn i-indigo" style={{ width: 52, height: 52, borderRadius: 14 }}>
<Icon name="briefcase" />
</span>
<div>
<div style={{ fontSize: 19, fontWeight: 700 }}>{j.title}</div>
<div className="text-muted">{j.id} · {j.department} · {j.businessUnit}</div>
</div>
<div style={{ marginLeft: 'auto' }}><Badge>{j.status}</Badge></div>
</div>
<div className="info-grid mb-18">
<div className="info-item"><div className="il">Hiring Manager</div><div className="iv">{j.manager}</div></div>
<div className="info-item">
<div className="il">Assigned Recruiter</div>
<div className="iv flex items-center gap-8">
{j.recruiter}
{r && <span className={`badge ${loadCls} badge-plain`} style={{ fontSize: 10 }}>{r.workload}% load</span>}
<button className="link-btn" onClick={onReassign}>Reassign</button>
</div>
</div>
<div className="info-item"><div className="il">Location</div><div className="iv">{j.location}</div></div>
<div className="info-item"><div className="il">Employment Type</div><div className="iv">{j.type}</div></div>
<div className="info-item"><div className="il">Grade</div><div className="iv">{j.grade}</div></div>
<div className="info-item"><div className="il">Vacancies</div><div className="iv">{j.vacancies}</div></div>
<div className="info-item"><div className="il">Salary Range</div><div className="iv">{moneyK(j.salaryMin)} {moneyK(j.salaryMax)}</div></div>
<div className="info-item"><div className="il">Experience</div><div className="iv">{j.experience}</div></div>
<div className="info-item"><div className="il">Education</div><div className="iv">{j.education}</div></div>
<div className="info-item"><div className="il">Deadline</div><div className="iv">{fmtDate(j.deadline)}</div></div>
</div>
<div className="divider" />
<div style={{ marginBottom: 16 }}>
<div style={SECTION_LABEL}>Description</div>
<p style={{ color: 'var(--text-2)' }}>{j.description}</p>
</div>
<div style={{ marginBottom: 16 }}>
<div style={SECTION_LABEL}>Key Responsibilities</div>
<ul style={{ paddingLeft: 18, color: 'var(--text-2)' }}>
{j.responsibilities.map((x) => <li key={x}>{x}</li>)}
</ul>
</div>
<div style={{ marginBottom: 16 }}>
<div style={SECTION_LABEL}>Required Skills</div>
<div className="k-tags">{j.skills.map((s) => <span className="tag" key={s}>{s}</span>)}</div>
</div>
<div>
<div style={SECTION_LABEL}>Benefits</div>
<div className="k-tags">{j.benefits.map((s) => <span className="tag" key={s}>{s}</span>)}</div>
</div>
<div className="divider" />
<div className="flex items-center gap-12">
<span className="text-muted text-sm">Hiring progress</span>
<div style={{ flex: 1 }}><ProgressBar pct={j.progress} /></div>
<span className="fw-600">{j.progress}%</span>
</div>
</Modal>
)
function channelLabel(ch) {
const name = ch.displayName || ch.name || ch.id
const service = ch.service ? String(ch.service) : ''
return service ? `${name} (${service})` : name
}
function Reassign({ job, recruiters, onClose, onSave }) {
const [name, setName] = useState(job.recruiter)
return (
<Modal
title="Reassign Recruiter"
subtitle={job.title}
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={() => onSave(name)}>Reassign</button>
</>
}
>
<div className="form-field">
<label>Assigned Recruiter</label>
<select value={name} onChange={(e) => setName(e.target.value)}>
{recruiters.map((r) => (
<option key={r.id} value={r.name}>{r.name} {r.workload}% load</option>
))}
</select>
</div>
<p className="text-muted text-sm" style={{ marginTop: 10 }}>
Workload is recalculated automatically across the recruiters assigned requisitions.
</p>
</Modal>
)
function publishConsequence(channel, mode) {
const service = channel?.service
? String(channel.service).charAt(0).toUpperCase() + String(channel.service).slice(1)
: (channel?.displayName || channel?.name || 'the selected channel')
if (mode === 'shareNow') return `Publishes to ${service} — posts immediately`
if (mode === 'customScheduled') return `Publishes to ${service} — scheduled for later`
return `Publishes to ${service} — added to queue`
}
function JobForm({ job, managers, recruiters, count, onClose, onSave, onInvalid }) {
const isEdit = Boolean(job)
function JobForm({
departmentOptions, channels, channelsLoading, channelsError, busy, onClose, onSubmit,
}) {
const form = useFormState({
title: job?.title ?? '',
department: job?.department ?? departments[0],
businessUnit: job?.businessUnit ?? businessUnits[0],
grade: job?.grade ?? grades[0],
type: job?.type ?? empTypes[0],
manager: job?.manager ?? managers[0]?.name ?? '',
recruiter: job?.recruiter ?? recruiters[0]?.name ?? '',
salaryMin: job?.salaryMin ?? '',
salaryMax: job?.salaryMax ?? '',
experience: job?.experience ?? '',
education: job?.education ?? educationLevels[0],
location: job?.location ?? locations[0],
vacancies: job?.vacancies ?? 1,
description: job?.description ?? '',
responsibilities: job ? job.responsibilities.join('\n') : '',
skills: job ? job.skills.join(', ') : '',
benefits: job ? job.benefits.join(', ') : '',
deadline: '',
status: job?.status ?? 'Open',
title: '',
department: '',
location: '',
employment_type: empTypes[0] || 'Full-time',
vacancies: '1',
experience_min: '',
experience_max: '',
salary: '',
requirements: '',
optional_skills: '',
description: '',
channel_id: '',
mode: 'addToQueue',
scheduler_date: '',
scheduler_time: '09:00',
})
// Default the channel once the list arrives same derivation pattern as
// Candidates.jsx's job-post picker (avoid an effect loop on setField).
const channelId = form.values.channel_id || (channels[0] ? String(channels[0].id) : '')
const selectedChannel = channels.find((c) => String(c.id) === String(channelId))
function submit() {
if (busy) return
const v = form.values
const errors = {}
if (!v.title.trim()) errors.title = 'Job title is required'
if (!v.description.trim()) errors.description = 'Description is required'
if (!v.salaryMin || Number(v.salaryMin) <= 0) errors.salaryMin = 'Enter a valid amount'
const vacancies = Number(v.vacancies)
if (!Number.isFinite(vacancies) || vacancies < 1) errors.vacancies = 'Vacancies must be at least 1'
const expMin = v.experience_min === '' ? null : Number(v.experience_min)
const expMax = v.experience_max === '' ? null : Number(v.experience_max)
if (expMin != null && !Number.isFinite(expMin)) errors.experience_min = 'Enter a valid number'
if (expMax != null && !Number.isFinite(expMax)) errors.experience_max = 'Enter a valid number'
if (
expMin != null && expMax != null
&& Number.isFinite(expMin) && Number.isFinite(expMax)
&& expMin > expMax
) {
errors.experience_max = 'Must be greater than or equal to minimum'
}
if (!channelId) errors.channel_id = 'Select a channel'
if (v.mode === 'customScheduled' && !v.scheduler_date) {
errors.scheduler_date = 'Date is required when scheduling for later'
}
form.setErrors(errors)
if (Object.keys(errors).length) {
onInvalid()
return
if (Object.keys(errors).length) return
const payload = {
title: v.title.trim(),
department: v.department.trim() || null,
location: v.location.trim() || null,
employment_type: v.employment_type || null,
vacancies,
experience_min: expMin,
experience_max: expMax,
salary: v.salary.trim() || 'Anonymous',
requirements: splitLines(v.requirements),
optional_skills: splitLines(v.optional_skills),
description: v.description.trim() || null,
channel_id: channelId,
mode: v.mode,
}
const skills = v.skills.split(',').map((s) => s.trim()).filter(Boolean)
const benefits = v.benefits.split(',').map((s) => s.trim()).filter(Boolean)
const responsibilities = v.responsibilities.split('\n').map((s) => s.trim()).filter(Boolean)
const salaryMin = Number(v.salaryMin)
const salaryMax = Number(v.salaryMax) || salaryMin + 20000
if (isEdit) {
onSave(
{
...job,
title: v.title, department: v.department, businessUnit: v.businessUnit, grade: v.grade,
type: v.type, manager: v.manager, recruiter: v.recruiter, salaryMin, salaryMax,
experience: v.experience, education: v.education, location: v.location,
vacancies: Number(v.vacancies) || 1, description: v.description, responsibilities,
skills: skills.length ? skills : job.skills,
benefits: benefits.length ? benefits : job.benefits,
status: v.status,
},
true,
)
return
if (v.mode === 'customScheduled') {
payload.scheduler_date = v.scheduler_date
if (v.scheduler_time) {
// Backend expects a time; "HH:MM" is enough for FastAPI's time parser.
payload.scheduler_time = v.scheduler_time.length === 5
? `${v.scheduler_time}:00`
: v.scheduler_time
}
}
onSave(
{
id: `JOB-${1001 + count}`,
title: v.title, department: v.department, businessUnit: v.businessUnit, grade: v.grade,
manager: v.manager, managerId: '', recruiter: v.recruiter, recruiterId: '',
location: v.location, type: v.type, vacancies: Number(v.vacancies) || 1,
applications: 0, status: v.status, created: new Date(TODAY),
deadline: v.deadline ? new Date(v.deadline) : new Date('2026-08-09'),
salaryMin, salaryMax,
experience: v.experience || '3+ years', education: v.education,
skills, benefits, description: v.description,
responsibilities: responsibilities.length ? responsibilities : ['Own key projects'],
progress: 0,
},
false,
)
onSubmit(payload)
}
const field = (name) => ({
@ -416,15 +346,15 @@ function JobForm({ job, managers, recruiters, count, onClose, onSave, onInvalid
return (
<Modal
title={isEdit ? 'Edit Job' : 'Create New Job'}
subtitle={isEdit ? job.id : 'Fill in the details to post a requisition'}
title="Create New Job"
subtitle="Creates the requisition and publishes it to Buffer"
size="modal-lg"
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={submit}>
<Icon name="check" /> {isEdit ? 'Save Changes' : 'Create Job'}
<button className="btn btn-secondary" onClick={onClose} disabled={busy}>Cancel</button>
<button className="btn btn-primary" onClick={submit} disabled={busy}>
<Icon name="check" /> {busy ? 'Creating…' : 'Create Job'}
</button>
</>
}
@ -432,89 +362,175 @@ function JobForm({ job, managers, recruiters, count, onClose, onSave, onInvalid
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
<div className="form-grid">
<div className="form-field col-span-2">
<label>Job Title <span className="req">*</span></label>
<input {...field('title')} className={form.errors.title ? 'err' : ''} placeholder="e.g. Senior Product Designer" />
<label>Title <span className="req">*</span></label>
<input {...field('title')} className={form.errors.title ? 'err' : ''} placeholder="e.g. Senior Backend Engineer" />
<FieldError>{form.errors.title}</FieldError>
</div>
<div className="form-field">
<label>Department <span className="req">*</span></label>
<select {...field('department')}>{departments.map((d) => <option key={d}>{d}</option>)}</select>
<label>Department</label>
<input
{...field('department')}
list="job-department-options"
placeholder="e.g. Engineering"
/>
<datalist id="job-department-options">
{departmentOptions.map((d) => <option key={d} value={d} />)}
</datalist>
</div>
<div className="form-field">
<label>Business Unit</label>
<select {...field('businessUnit')}>{businessUnits.map((b) => <option key={b}>{b}</option>)}</select>
</div>
<div className="form-field">
<label>Grade</label>
<select {...field('grade')}>{grades.map((g) => <option key={g}>{g}</option>)}</select>
<label>Location</label>
<input {...field('location')} placeholder="e.g. Remote / New York" />
</div>
<div className="form-field">
<label>Employment Type</label>
<select {...field('type')}>{empTypes.map((t) => <option key={t}>{t}</option>)}</select>
</div>
<div className="form-field">
<label>Hiring Manager <span className="req">*</span></label>
<select {...field('manager')}>{managers.map((m) => <option key={m.id}>{m.name}</option>)}</select>
</div>
<div className="form-field">
<label>Recruiter <span className="req">*</span></label>
<select {...field('recruiter')}>{recruiters.map((r) => <option key={r.id}>{r.name}</option>)}</select>
</div>
<div className="form-field">
<label>Salary Min ($) <span className="req">*</span></label>
<input type="number" {...field('salaryMin')} className={form.errors.salaryMin ? 'err' : ''} placeholder="90000" />
<FieldError>{form.errors.salaryMin}</FieldError>
</div>
<div className="form-field">
<label>Salary Max ($)</label>
<input type="number" {...field('salaryMax')} placeholder="130000" />
</div>
<div className="form-field">
<label>Experience</label>
<input {...field('experience')} placeholder="5+ years" />
</div>
<div className="form-field">
<label>Education</label>
<select {...field('education')}>{educationLevels.map((e) => <option key={e}>{e}</option>)}</select>
</div>
<div className="form-field">
<label>Location <span className="req">*</span></label>
<select {...field('location')}>{locations.map((l) => <option key={l}>{l}</option>)}</select>
<select {...field('employment_type')}>
{empTypes.map((t) => <option key={t}>{t}</option>)}
</select>
</div>
<div className="form-field">
<label>Vacancies</label>
<input type="number" min="1" {...field('vacancies')} />
<input type="number" min="1" {...field('vacancies')} className={form.errors.vacancies ? 'err' : ''} />
<FieldError>{form.errors.vacancies}</FieldError>
</div>
<div className="form-field">
<label>Experience min</label>
<input type="number" min="0" {...field('experience_min')} className={form.errors.experience_min ? 'err' : ''} placeholder="0" />
<FieldError>{form.errors.experience_min}</FieldError>
</div>
<div className="form-field">
<label>Experience max</label>
<input type="number" min="0" {...field('experience_max')} className={form.errors.experience_max ? 'err' : ''} placeholder="5" />
<FieldError>{form.errors.experience_max}</FieldError>
</div>
<div className="form-field col-span-2">
<label>Salary</label>
<input {...field('salary')} placeholder="Anonymous" />
</div>
<div className="form-field col-span-2">
<label>Job Description <span className="req">*</span></label>
<textarea {...field('description')} className={form.errors.description ? 'err' : ''} placeholder="Describe the role…" />
<FieldError>{form.errors.description}</FieldError>
<label>Requirements</label>
<textarea {...field('requirements')} placeholder="One requirement per line…" rows={3} />
</div>
<div className="form-field col-span-2">
<label>Responsibilities</label>
<textarea {...field('responsibilities')} placeholder="One per line…" />
<label>Nice to have</label>
<textarea {...field('optional_skills')} placeholder="One skill per line…" rows={2} />
</div>
<div className="form-field col-span-2">
<label>Required Skills</label>
<input {...field('skills')} placeholder="React, TypeScript, System Design" />
<label>Description</label>
<textarea {...field('description')} placeholder="Describe the role…" rows={4} />
</div>
<div className="form-field col-span-2">
<label>Benefits</label>
<input {...field('benefits')} placeholder="Equity, 401(k), Unlimited PTO" />
<label>Channel <span className="req">*</span></label>
<select
value={channelId}
className={form.errors.channel_id ? 'err' : ''}
onChange={(e) => form.setField('channel_id', e.target.value)}
disabled={channelsLoading || channelsError || channels.length === 0}
>
{channelsLoading && <option value="">Loading channels</option>}
{channelsError && <option value="">Could not load channels</option>}
{!channelsLoading && !channelsError && channels.length === 0 && (
<option value="">No channels connected</option>
)}
{channels.map((ch) => (
<option key={ch.id} value={ch.id}>{channelLabel(ch)}</option>
))}
</select>
<FieldError>{form.errors.channel_id}</FieldError>
</div>
<div className="form-field">
<label>Deadline</label>
<input type="date" {...field('deadline')} />
</div>
<div className="form-field">
<label>Status</label>
<select {...field('status')}>{jobStatuses.map((s) => <option key={s}>{s}</option>)}</select>
<label>When</label>
<select {...field('mode')}>
<option value="addToQueue">Add to queue</option>
<option value="shareNow">Post now</option>
<option value="customScheduled">Schedule for later</option>
</select>
</div>
{form.values.mode === 'customScheduled' && (
<>
<div className="form-field">
<label>Date <span className="req">*</span></label>
<input
type="date"
{...field('scheduler_date')}
className={form.errors.scheduler_date ? 'err' : ''}
/>
<FieldError>{form.errors.scheduler_date}</FieldError>
</div>
<div className="form-field">
<label>Time</label>
<input type="time" {...field('scheduler_time')} />
</div>
</>
)}
</div>
<p className="text-muted" style={{ marginTop: 16, fontSize: 13 }}>
{publishConsequence(selectedChannel, form.values.mode)}
</p>
</form>
</Modal>
)
}
function JobDetail({ job: j, onClose, onPublish }) {
return (
<Modal
title="Job Details"
subtitle={j.department || undefined}
size="modal-lg"
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Close</button>
<button className="btn btn-secondary" onClick={onPublish}><Icon name="send" /> Publish</button>
</>
}
>
<div className="flex items-center gap-16 mb-18">
<span className="kpi-icn i-indigo" style={{ width: 52, height: 52, borderRadius: 14 }}>
<Icon name="briefcase" />
</span>
<div>
<div style={{ fontSize: 19, fontWeight: 700 }}>{j.title}</div>
<div className="text-muted">{[j.department, j.location].filter(Boolean).join(' · ') || '—'}</div>
</div>
<div style={{ marginLeft: 'auto' }}><Badge>{j.status}</Badge></div>
</div>
<div className="info-grid mb-18">
<div className="info-item"><div className="il">Department</div><div className="iv">{j.department || '—'}</div></div>
<div className="info-item"><div className="il">Location</div><div className="iv">{j.location || '—'}</div></div>
<div className="info-item"><div className="il">Employment Type</div><div className="iv">{j.type || '—'}</div></div>
<div className="info-item"><div className="il">Platform</div><div className="iv">{j.platform || '—'}</div></div>
<div className="info-item"><div className="il">Vacancies</div><div className="iv">{j.vacancies ?? '—'}</div></div>
<div className="info-item"><div className="il">Salary</div><div className="iv">{j.salary || '—'}</div></div>
<div className="info-item"><div className="il">Experience</div><div className="iv">{j.experience || '—'}</div></div>
<div className="info-item"><div className="il">Created</div><div className="iv">{j.created ? fmtShort(j.created) : '—'}</div></div>
<div className="info-item"><div className="il">Created by</div><div className="iv">{j.createdByName || '—'}</div></div>
<div className="info-item"><div className="il">Assigned Recruiter</div><div className="iv">{j.recruiter || '—'}</div></div>
<div className="info-item"><div className="il">Closed at</div><div className="iv">{j.closedAt ? fmtShort(j.closedAt) : '—'}</div></div>
</div>
{j.description && (
<>
<div className="divider" />
<div style={{ marginBottom: 16 }}>
<div style={SECTION_LABEL}>Description</div>
<p style={{ color: 'var(--text-2)' }}>{j.description}</p>
</div>
</>
)}
{!!(j.skills && j.skills.length) && (
<div>
<div style={SECTION_LABEL}>Required Skills</div>
<div className="k-tags">{j.skills.map((s) => <span className="tag" key={s}>{s}</span>)}</div>
</div>
)}
</Modal>
)
}

View File

@ -1,10 +1,33 @@
/* ============================================================
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.
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 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
with no source is dropped rather than rendered as blanks.
============================================================ */
import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Avatar, Icon, ScoreChip } from '../ui/primitives'
import { Avatar, EmptyState, Icon, ScoreChip } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { seedQuery, useSeedMutation } from '../data/seedQueries'
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'
/* Stage colours reference CSS tokens so the board re-tints with the theme. */
export const KANBAN_STAGES = [
@ -17,6 +40,26 @@ 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 = await candidatesApi.list({ limit: BOARD_LIMIT })
const rows = candidatesApi.toRows(res)
return { cards: rows.map(pipelineApi.toBoardCard), total: res?.total ?? rows.length }
}
async function fetchJobs() {
const res = await jobPostsApi.list({ activeOnly: true, top: JOB_LIMIT })
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map((row) => ({ id: row.id, title: row.title }))
}
/**
* Native HTML5 drag-and-drop, kept rather than swapped for a library. React
* supports draggable/onDragStart/onDragOver/onDrop as props, the frozen CSS
@ -27,15 +70,24 @@ export const KANBAN_STAGES = [
*/
export default function Pipeline() {
const { toast } = useToast()
const { can } = useAuth()
const navigate = useNavigate()
const { data: candidates = [] } = useQuery(seedQuery('candidates'))
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
const updateCandidates = useSeedMutation('candidates')
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)
/* 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],
@ -47,17 +99,52 @@ export default function Pipeline() {
return map
}, [list])
/* 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. */
const move = useMutation({
mutationFn: ({ card, stage }) =>
pipelineApi.changeStage({
inboxId: card.inboxId,
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 && {
...old,
cards: old.cards.map((c) => (c.inboxId === card.inboxId ? { ...c, stage } : c)),
},
)
return { previous }
},
onError: (err, _vars, ctx) => {
if (ctx?.previous) qc.setQueryData(BOARD_KEY, ctx.previous)
toast(friendlyAuthError(err, 'Could not move the candidate.'), 'error')
},
onSuccess: (_data, { card, stage }) => toast(`${card.name} moved to ${stage}`, 'success'),
onSettled: () => {
qc.invalidateQueries({ queryKey: qk.pipeline.all() })
// Stage lives on the inbox row every candidate screen reads, so their
// caches are stale too the moment this lands.
qc.invalidateQueries({ queryKey: qk.candidates.all() })
},
})
function onDrop(stage) {
setOverStage(null)
const id = draggingId
setDraggingId(null)
if (!id) return
if (id == null || !canEdit) return
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
// Mutating the cache re-renders every screen reading candidates, so the
// move is visible on Candidates and Talent Pool too.
updateCandidates((cs) => cs.map((c) => (c.id === id ? { ...c, stage, status: stage } : c)))
toast(`${cand.name} moved to ${stage}`, 'success')
if (cand.inboxId == null) {
toast(`${cand.name} has no application to move`, 'warning')
return
}
move.mutate({ card: cand, stage })
}
return (
@ -65,12 +152,17 @@ export default function Pipeline() {
<div className="page-head">
<div>
<h1 className="page-title">Pipeline</h1>
<p className="page-sub">Drag candidates between stages to update their status</p>
<p className="page-sub">
{canEdit
? 'Drag candidates between stages to update their status'
: 'Read-only — moving a candidate needs the pipeline.edit permission'}
{total > candidates.length && ` · showing ${candidates.length} of ${total} applications`}
</p>
</div>
<div className="page-head-actions">
<select className="select" value={jobId} onChange={(e) => setJobId(e.target.value)}>
<option value="">All Jobs</option>
{jobs.filter((j) => j.status === 'Open').map((j) => (
{jobs.map((j) => (
<option key={j.id} value={j.id}>{j.title}</option>
))}
</select>
@ -83,61 +175,69 @@ export default function Pipeline() {
</div>
</div>
<div className="kanban">
{KANBAN_STAGES.map((st) => {
const cards = byStage[st.name] ?? []
return (
<div className="kanban-col" key={st.name}>
<div className="kanban-col-head">
<span className="k-dot" style={{ background: st.color }} />
<h4>{st.name}</h4>
<span className="k-count">{cards.length}</span>
</div>
<div
className={`kanban-cards${overStage === st.name ? ' drag-over' : ''}`}
onDragOver={(e) => { e.preventDefault(); setOverStage(st.name) }}
onDragLeave={() => setOverStage((s) => (s === st.name ? null : s))}
onDrop={(e) => { e.preventDefault(); onDrop(st.name) }}
>
{cards.map((c) => (
<div
key={c.id}
className={`k-card${draggingId === c.id ? ' dragging' : ''}`}
draggable
onDragStart={(e) => {
setDraggingId(c.id)
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', c.id)
}}
onDragEnd={() => { setDraggingId(null); setOverStage(null) }}
onClick={() => {
// Don't open the profile on the click that ends a drag.
if (draggingId) return
navigate('/candidates', { state: { openCandidate: c.id } })
}}
>
<div className="k-card-top">
<Avatar name={c.name} initials={c.initials} color={c.color} />
<div>
<div className="kc-name">{c.name}</div>
<div className="kc-role">{c.currentTitle}</div>
{board.isError ? (
<EmptyState title="Could not load the pipeline">
{friendlyAuthError(board.error, 'Please try again.')}
</EmptyState>
) : board.isPending ? (
<EmptyState title="Loading pipeline…">Fetching applications.</EmptyState>
) : (
<div className="kanban">
{KANBAN_STAGES.map((st) => {
const cards = byStage[st.name] ?? []
return (
<div className="kanban-col" key={st.name}>
<div className="kanban-col-head">
<span className="k-dot" style={{ background: st.color }} />
<h4>{st.name}</h4>
<span className="k-count">{cards.length}</span>
</div>
<div
className={`kanban-cards${overStage === st.name ? ' drag-over' : ''}`}
onDragOver={(e) => { e.preventDefault(); setOverStage(st.name) }}
onDragLeave={() => setOverStage((s) => (s === st.name ? null : s))}
onDrop={(e) => { e.preventDefault(); onDrop(st.name) }}
>
{cards.map((c) => (
<div
key={c.id}
className={`k-card${draggingId === c.id ? ' dragging' : ''}`}
draggable={canEdit}
onDragStart={(e) => {
setDraggingId(c.id)
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', String(c.id))
}}
onDragEnd={() => { setDraggingId(null); setOverStage(null) }}
onClick={() => {
// Don't open the profile on the click that ends a drag.
if (draggingId) return
// The Candidates screen keys its rows by users.id, so an
// application with no linked account cannot deep-link.
if (!c.userId) return
navigate('/candidates', { state: { openCandidate: c.userId } })
}}
>
<div className="k-card-top">
<Avatar name={c.name} />
<div>
<div className="kc-name">{c.name}</div>
<div className="kc-role">{c.currentTitle}</div>
</div>
</div>
<div className="kc-role">{c.jobTitle}</div>
<div className="k-card-meta">
<span className="cell-sub">{c.currentCompany}</span>
{c.aiScore != null && <ScoreChip score={c.aiScore} />}
</div>
</div>
<div className="kc-role">{c.jobTitle}</div>
<div className="k-tags">
{c.skills.slice(0, 3).map((s) => <span className="tag" key={s}>{s}</span>)}
</div>
<div className="k-card-meta">
<span className="cell-sub">{c.currentCompany}</span>
<ScoreChip score={c.aiScore} />
</div>
</div>
))}
))}
</div>
</div>
</div>
)
})}
</div>
)
})}
</div>
)}
</div>
)
}