RecruiterHub #14
|
|
@ -1,4 +1,5 @@
|
|||
# macOS
|
||||
|
||||
migrations/** */
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
|
|
|||
|
|
@ -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(...),
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 alias→Buffer-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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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])),
|
||||
|
|
|
|||
|
|
@ -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('')
|
||||
|
|
|
|||
|
|
@ -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="Couldn’t 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 recruiter’s 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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue