candidates are arrigving
parent
cf605ee3d6
commit
91bc1089b2
|
|
@ -2152,6 +2152,39 @@ class AtsResults(SQLModel, table=True):
|
||||||
grouped.setdefault(row.form_data_id, []).append(row)
|
grouped.setdefault(row.form_data_id, []).append(row)
|
||||||
return grouped
|
return grouped
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def latest_per_candidate_for_job(cls, session: AsyncSession, job_post_id):
|
||||||
|
"""Suggested candidates for one job: the newest score per person, best first.
|
||||||
|
|
||||||
|
A person is whichever identity the row carries — form_data_id,
|
||||||
|
candidate_id or user_id (one is set at a time) — so DISTINCT ON their
|
||||||
|
COALESCE, newest created_at winning. Rows carrying none are skipped.
|
||||||
|
Returns (row, user_name, user_email, form_name, form_email, candidate).
|
||||||
|
"""
|
||||||
|
from g_sheet.models import FormData
|
||||||
|
from job.candidate.models import Candidates
|
||||||
|
|
||||||
|
jid = cls._as_uuid(job_post_id)
|
||||||
|
if jid is None:
|
||||||
|
return []
|
||||||
|
identity = func.coalesce(cls.form_data_id, cls.candidate_id, cls.user_id)
|
||||||
|
latest = (
|
||||||
|
select(cls.id)
|
||||||
|
.where(cls.job_post_id == jid, identity.is_not(None))
|
||||||
|
.distinct(identity)
|
||||||
|
.order_by(identity.desc(), cls.created_at.desc())
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls, Users.name, Users.email, FormData.name, FormData.candidate_email, Candidates)
|
||||||
|
.join(latest, cls.id == latest.c.id)
|
||||||
|
.outerjoin(Users, cls.user_id == Users.id)
|
||||||
|
.outerjoin(FormData, cls.form_data_id == FormData.id)
|
||||||
|
.outerjoin(Candidates, cls.candidate_id == Candidates.id)
|
||||||
|
.order_by(cls.overall_score.desc(), cls.created_at.desc())
|
||||||
|
)
|
||||||
|
return list(result.all())
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def job_ids_by_emails(cls, session: AsyncSession, emails) -> dict:
|
async def job_ids_by_emails(cls, session: AsyncSession, emails) -> dict:
|
||||||
"""{email: {job_post_id, ...}} for any prior ATS score of these people."""
|
"""{email: {job_post_id, ...}} for any prior ATS score of these people."""
|
||||||
|
|
|
||||||
|
|
@ -983,6 +983,24 @@ async def fetch_jobs(
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/jobs/profile/fetch")
|
||||||
|
async def fetch_job_profile(
|
||||||
|
job_post_id: str = Query(...),
|
||||||
|
current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Job profile page: the job row, suggested candidates (newest ats_results row
|
||||||
|
per person, best score first) and the Suggested / Top Match header stats."""
|
||||||
|
try:
|
||||||
|
service=JobPost(session=session)
|
||||||
|
data=await service.fetch_job_profile(job_post_id,current_user=current_user)
|
||||||
|
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/jobs/export")
|
@router.get("/jobs/export")
|
||||||
async def export_jobs(
|
async def export_jobs(
|
||||||
search: str | None = Query(None),
|
search: str | None = Query(None),
|
||||||
|
|
|
||||||
|
|
@ -1143,6 +1143,28 @@ class Candidates(SQLModel, table=True):
|
||||||
)
|
)
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def latest_completed_for_job_by_emails(cls, session: AsyncSession, job_id, emails) -> dict:
|
||||||
|
"""{lower(email): newest completed row} against one job — the batch form of
|
||||||
|
get_completed_by_email_job, for scores whose identity is a user or form row."""
|
||||||
|
lowers = sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()})
|
||||||
|
jid = cls._as_uuid(job_id)
|
||||||
|
if not lowers or jid is None:
|
||||||
|
return {}
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls)
|
||||||
|
.where(
|
||||||
|
func.lower(cls.candidate_email).in_(lowers),
|
||||||
|
cls.job_id == jid,
|
||||||
|
cls.status == "completed",
|
||||||
|
)
|
||||||
|
.order_by(cls.updated_at.desc())
|
||||||
|
)
|
||||||
|
out = {}
|
||||||
|
for row in result.scalars():
|
||||||
|
out.setdefault((row.candidate_email or "").strip().lower(), row)
|
||||||
|
return out
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def list_by_emails(cls, session: AsyncSession, emails):
|
async def list_by_emails(cls, session: AsyncSession, emails):
|
||||||
"""Scored `candidates` rows for these addresses (ATS, not pipeline stage)."""
|
"""Scored `candidates` rows for these addresses (ATS, not pipeline stage)."""
|
||||||
|
|
|
||||||
|
|
@ -281,3 +281,57 @@ async def list_buffer_channels() -> list[dict]:
|
||||||
"organization_name": org.get("name"),
|
"organization_name": org.get("name"),
|
||||||
})
|
})
|
||||||
return channels
|
return channels
|
||||||
|
|
||||||
|
|
||||||
|
# ats_results.band labels, best first — the same cut-offs CandidateView._recommendation writes.
|
||||||
|
MATCH_BANDS = ("Strong Match", "Potential Match", "Weak Match")
|
||||||
|
TOP_MATCH_BAND = MATCH_BANDS[0]
|
||||||
|
|
||||||
|
|
||||||
|
def suggested_source(inbox_id, form_data_id, candidate_source=None) -> str:
|
||||||
|
"""Where a suggested candidate's score came from: inbox | form | upload | bank."""
|
||||||
|
if inbox_id is not None:
|
||||||
|
return "inbox"
|
||||||
|
if form_data_id is not None:
|
||||||
|
return "form"
|
||||||
|
return (candidate_source or "").strip() or "upload"
|
||||||
|
|
||||||
|
|
||||||
|
def _skill_key(value) -> str:
|
||||||
|
return " ".join(re.sub(r"[^a-z0-9+#]+", " ", str(value or "").lower()).split())
|
||||||
|
|
||||||
|
|
||||||
|
def optional_skill_hits(optional_skills, matched_keywords) -> list[str]:
|
||||||
|
"""Job optional skills the candidate's matched keywords cover, in the job's order.
|
||||||
|
|
||||||
|
A hit is an exact normalized match, or one side containing the other as whole
|
||||||
|
words ("Salesforce" covers "CRM (Salesforce)"). Keywords are verified against the
|
||||||
|
resume upstream, so this only has to line up two spellings of the same skill.
|
||||||
|
"""
|
||||||
|
keys = [k for k in (_skill_key(m) for m in matched_keywords or []) if k]
|
||||||
|
hits = []
|
||||||
|
for skill in optional_skills or []:
|
||||||
|
target = _skill_key(skill)
|
||||||
|
if not target or skill in hits:
|
||||||
|
continue
|
||||||
|
padded = f" {target} "
|
||||||
|
if any(k == target or f" {k} " in padded or padded in f" {k} " for k in keys):
|
||||||
|
hits.append(skill)
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
def suggested_summary(candidates) -> dict:
|
||||||
|
"""Header stats for a job's suggested candidates: count, top-band count, best score."""
|
||||||
|
bands = {band: 0 for band in MATCH_BANDS}
|
||||||
|
scores = []
|
||||||
|
for c in candidates or []:
|
||||||
|
if c.get("band") in bands:
|
||||||
|
bands[c["band"]] += 1
|
||||||
|
if c.get("match_score") is not None:
|
||||||
|
scores.append(c["match_score"])
|
||||||
|
return {
|
||||||
|
"suggested": len(candidates or []),
|
||||||
|
"top_match": bands[TOP_MATCH_BAND],
|
||||||
|
"top_score": max(scores) if scores else None,
|
||||||
|
"bands": bands,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -155,3 +155,30 @@ def serialize_status_history(row, *, changed_by_name=None) -> dict:
|
||||||
"actor_kind": row.actor_kind,
|
"actor_kind": row.actor_kind,
|
||||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_suggested_candidate(row, *, name=None, email=None, candidate=None, source=None, optional_matched=None) -> dict:
|
||||||
|
"""One suggested candidate on the job profile: the newest ats_results row for a
|
||||||
|
person, with profile fields from its `candidates` row when one exists."""
|
||||||
|
score = row.overall_score
|
||||||
|
return {
|
||||||
|
"id": str(row.id),
|
||||||
|
"user_id": str(row.user_id) if row.user_id else None,
|
||||||
|
"candidate_id": str(row.candidate_id) if row.candidate_id else None,
|
||||||
|
"form_data_id": str(row.form_data_id) if row.form_data_id else None,
|
||||||
|
"inbox_id": row.inbox_id,
|
||||||
|
"name": name or (candidate.candidate_name if candidate else None) or email or None,
|
||||||
|
"email": email or (candidate.candidate_email if candidate else None) or None,
|
||||||
|
"current_title": candidate.job_title if candidate else None,
|
||||||
|
"current_company": candidate.current_company if candidate else None,
|
||||||
|
"years_experience": candidate.years_experience if candidate else None,
|
||||||
|
"match_score": round(score) if score is not None else None,
|
||||||
|
"band": row.band or None,
|
||||||
|
"matched_keywords": list(candidate.matched_keywords or []) if candidate else [],
|
||||||
|
"missing_keywords": list(candidate.missing_keywords or []) if candidate else [],
|
||||||
|
"optional_matched": list(optional_matched or []),
|
||||||
|
"summary": (candidate.summary_critique if candidate else None) or row.professional_summary or None,
|
||||||
|
"source": source,
|
||||||
|
"scored_candidate_id": str(candidate.id) if candidate else None,
|
||||||
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,9 @@ from fastapi import HTTPException
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from pydantic import BaseModel, model_validator
|
from pydantic import BaseModel, model_validator
|
||||||
from inbox.models import Inbox_Messages
|
from inbox.models import AtsResults,Inbox_Messages
|
||||||
from job.assignment.views import Assignment
|
from job.assignment.views import Assignment
|
||||||
|
from job.candidate.models import Candidates
|
||||||
from job.job_post.enums import RequisitionStatus
|
from job.job_post.enums import RequisitionStatus
|
||||||
from job.job_post.models import JobPostImages,JobPostStatusHistory,JobPosts,SocialPlatform
|
from job.job_post.models import JobPostImages,JobPostStatusHistory,JobPosts,SocialPlatform
|
||||||
from role.models import EnumRoles
|
from role.models import EnumRoles
|
||||||
|
|
@ -23,11 +24,14 @@ from job.job_post.plugins import (
|
||||||
list_buffer_channels,
|
list_buffer_channels,
|
||||||
local_status,
|
local_status,
|
||||||
normalize_platform,
|
normalize_platform,
|
||||||
|
optional_skill_hits,
|
||||||
parse_buffer_datetime,
|
parse_buffer_datetime,
|
||||||
render_job_post,
|
render_job_post,
|
||||||
resolve_channel,
|
resolve_channel,
|
||||||
|
suggested_source,
|
||||||
|
suggested_summary,
|
||||||
)
|
)
|
||||||
from job.job_post.serializers import serialize_job_post, serialize_job_row, serialize_job_stats, serialize_status_history
|
from job.job_post.serializers import serialize_job_post, serialize_job_row, serialize_job_stats, serialize_status_history, serialize_suggested_candidate
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
logger=logging.getLogger("job.job_post")
|
logger=logging.getLogger("job.job_post")
|
||||||
|
|
@ -413,6 +417,52 @@ class JobPost:
|
||||||
for r in rows
|
for r in rows
|
||||||
],total
|
],total
|
||||||
|
|
||||||
|
async def fetch_job_profile(self,job_post_id,current_user=None):
|
||||||
|
"""Job profile page: the requisition row, its suggested candidates and the
|
||||||
|
Suggested / Top Match header stats — one round trip.
|
||||||
|
|
||||||
|
Suggested = newest ats_results row per person for this job. Profile fields
|
||||||
|
and keywords come from that score's candidates row; a user- or form-identity
|
||||||
|
score has none, so it borrows the newest completed row for the same email."""
|
||||||
|
uid=JobPosts._as_uuid(job_post_id)
|
||||||
|
if uid is None:
|
||||||
|
raise HTTPException(status_code=422,detail="job_post_id must be a UUID")
|
||||||
|
restrict=await self._restrict_ids_for_requisition_scope(current_user)
|
||||||
|
if restrict is not None and str(uid) not in {str(i) for i in restrict}:
|
||||||
|
raise HTTPException(status_code=404,detail="Job post not found")
|
||||||
|
job=await JobPosts.get_job_post_by_id(self.session,uid)
|
||||||
|
if not job or job.is_deleted:
|
||||||
|
raise HTTPException(status_code=404,detail="Job post not found")
|
||||||
|
|
||||||
|
names=await Users.names_by_ids(
|
||||||
|
self.session,
|
||||||
|
JobPosts.recruiter_ids_of(job)+[job.hiring_manager_id],
|
||||||
|
)
|
||||||
|
counts=await Inbox_Messages.counts_by_job_post_ids(self.session,[job.id])
|
||||||
|
job_payload=serialize_job_row(
|
||||||
|
job,
|
||||||
|
names=names,
|
||||||
|
hiring_manager_name=names.get(str(job.hiring_manager_id)),
|
||||||
|
applicant_count=counts.get(str(job.id),0),
|
||||||
|
)
|
||||||
|
|
||||||
|
rows=await AtsResults.latest_per_candidate_for_job(self.session,job.id)
|
||||||
|
emails=[user_email or form_email for row,_,user_email,_,form_email,candidate in rows if candidate is None]
|
||||||
|
by_email=await Candidates.latest_completed_for_job_by_emails(self.session,job.id,emails)
|
||||||
|
candidates=[]
|
||||||
|
for row,user_name,user_email,form_name,form_email,candidate in rows:
|
||||||
|
email=user_email or form_email
|
||||||
|
scored=candidate or by_email.get((email or "").strip().lower())
|
||||||
|
candidates.append(serialize_suggested_candidate(
|
||||||
|
row,
|
||||||
|
name=user_name or form_name,
|
||||||
|
email=email,
|
||||||
|
candidate=scored,
|
||||||
|
source=suggested_source(row.inbox_id,row.form_data_id,scored.source if scored else None),
|
||||||
|
optional_matched=optional_skill_hits(job.optional_skills,scored.matched_keywords if scored else []),
|
||||||
|
))
|
||||||
|
return {"job":job_payload,**suggested_summary(candidates),"candidates":candidates}
|
||||||
|
|
||||||
async def _job_row(self,row):
|
async def _job_row(self,row):
|
||||||
names=await Users.names_by_ids(
|
names=await Users.names_by_ids(
|
||||||
self.session,
|
self.session,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
"""Unit tests for the job-profile helpers in job/job_post/plugins.py — pure functions only."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from job.job_post import plugins
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- suggested_source
|
||||||
|
|
||||||
|
def test_inbox_score_is_email_sourced_even_with_a_candidates_row():
|
||||||
|
assert plugins.suggested_source(42, None, "upload") == "inbox"
|
||||||
|
|
||||||
|
|
||||||
|
def test_form_score_is_form_sourced():
|
||||||
|
assert plugins.suggested_source(None, "f-1", None) == "form"
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_score_uses_the_candidates_row_source():
|
||||||
|
assert plugins.suggested_source(None, None, "bank") == "bank"
|
||||||
|
assert plugins.suggested_source(None, None, None) == "upload"
|
||||||
|
assert plugins.suggested_source(None, None, " ") == "upload"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- optional_skill_hits
|
||||||
|
|
||||||
|
def test_exact_match_ignores_case_and_punctuation():
|
||||||
|
assert plugins.optional_skill_hits(["FMCG Background", "Arabic"], ["fmcg-background"]) == ["FMCG Background"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_keyword_inside_skill_counts_on_word_boundaries():
|
||||||
|
assert plugins.optional_skill_hits(["CRM (Salesforce)"], ["Salesforce"]) == ["CRM (Salesforce)"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_inside_keyword_counts():
|
||||||
|
assert plugins.optional_skill_hits(["Arabic"], ["Arabic language"]) == ["Arabic"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_words_do_not_count():
|
||||||
|
assert plugins.optional_skill_hits(["Java"], ["JavaScript"]) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_hits_keep_job_order_and_skip_duplicates():
|
||||||
|
hits = plugins.optional_skill_hits(["Travel", "Arabic", "Arabic"], ["arabic", "travel"])
|
||||||
|
assert hits == ["Travel", "Arabic"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_optional_skills_or_keywords_is_empty():
|
||||||
|
assert plugins.optional_skill_hits([], ["Python"]) == []
|
||||||
|
assert plugins.optional_skill_hits(["Python"], None) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- suggested_summary
|
||||||
|
|
||||||
|
def test_summary_counts_bands_and_top_match():
|
||||||
|
candidates = [
|
||||||
|
{"band": "Strong Match", "match_score": 91},
|
||||||
|
{"band": "Strong Match", "match_score": 85},
|
||||||
|
{"band": "Potential Match", "match_score": 70},
|
||||||
|
{"band": "Weak Match", "match_score": 40},
|
||||||
|
{"band": None, "match_score": None},
|
||||||
|
]
|
||||||
|
summary = plugins.suggested_summary(candidates)
|
||||||
|
assert summary["suggested"] == 5
|
||||||
|
assert summary["top_match"] == 2
|
||||||
|
assert summary["top_score"] == 91
|
||||||
|
assert summary["bands"] == {"Strong Match": 2, "Potential Match": 1, "Weak Match": 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_summary():
|
||||||
|
assert plugins.suggested_summary([]) == {
|
||||||
|
"suggested": 0,
|
||||||
|
"top_match": 0,
|
||||||
|
"top_score": None,
|
||||||
|
"bands": {"Strong Match": 0, "Potential Match": 0, "Weak Match": 0},
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,270 @@
|
||||||
|
/**
|
||||||
|
* Job profile page test — /job/:jobId rendered into jsdom against a mocked
|
||||||
|
* GET /jobs/profile/fetch.
|
||||||
|
*
|
||||||
|
* node job-profile.test.mjs
|
||||||
|
*
|
||||||
|
* Pins the design contract: employment type in the hero line, Suggested and
|
||||||
|
* Top Match stats from the one profile request, Optional Skills highlighted
|
||||||
|
* below Required Skills, and suggested-candidate cards ranked best → worst with
|
||||||
|
* matched optional skills highlighted on each card.
|
||||||
|
*/
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { pathToFileURL } from 'node:url'
|
||||||
|
|
||||||
|
import esbuild from 'esbuild'
|
||||||
|
import { JSDOM } from 'jsdom'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- environment
|
||||||
|
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
|
||||||
|
url: 'http://localhost:5173/',
|
||||||
|
pretendToBeVisual: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
globalThis.window = dom.window
|
||||||
|
globalThis.document = dom.window.document
|
||||||
|
Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true })
|
||||||
|
globalThis.HTMLElement = dom.window.HTMLElement
|
||||||
|
globalThis.Element = dom.window.Element
|
||||||
|
globalThis.Node = dom.window.Node
|
||||||
|
globalThis.getComputedStyle = dom.window.getComputedStyle
|
||||||
|
globalThis.localStorage = dom.window.localStorage
|
||||||
|
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 0)
|
||||||
|
globalThis.cancelAnimationFrame = clearTimeout
|
||||||
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||||
|
|
||||||
|
class RO { observe() {} unobserve() {} disconnect() {} }
|
||||||
|
class MO { observe() {} disconnect() {} takeRecords() { return [] } }
|
||||||
|
globalThis.ResizeObserver = RO
|
||||||
|
globalThis.MutationObserver = MO
|
||||||
|
dom.window.ResizeObserver = RO
|
||||||
|
dom.window.MutationObserver = MO
|
||||||
|
dom.window.matchMedia = () => ({
|
||||||
|
matches: false, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {},
|
||||||
|
})
|
||||||
|
dom.window.HTMLCanvasElement.prototype.getContext = () =>
|
||||||
|
new Proxy({}, { get: (_, k) => (k === 'canvas' ? {} : () => {}) })
|
||||||
|
|
||||||
|
const MODULES = ['dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments',
|
||||||
|
'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users', 'tasks', 'talent',
|
||||||
|
'requisitions']
|
||||||
|
const ACTIONS = ['view', 'create', 'edit', 'delete', 'approve', 'export', 'manage', 'configure']
|
||||||
|
const permissions = MODULES.flatMap((m) => ACTIONS.map((a) => `${m}.${a}`))
|
||||||
|
|
||||||
|
dom.window.localStorage.setItem('tf-auth', JSON.stringify({
|
||||||
|
access_token: 'test', refresh_token: 'test', expires_in: 1800,
|
||||||
|
expires_at: Date.now() + 1800_000,
|
||||||
|
data: { id: 1, name: 'Test User', email: 't@example.com', role_name: 'system_administrator', permissions },
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- fixtures
|
||||||
|
const JOB_ID = '11111111-2222-3333-4444-555555555555'
|
||||||
|
|
||||||
|
function jobRow(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: JOB_ID,
|
||||||
|
title: 'Regional Sales Executive',
|
||||||
|
department: 'GEO',
|
||||||
|
location: 'Multi-region',
|
||||||
|
employment_type: 'Permanent',
|
||||||
|
vacancies: 3,
|
||||||
|
platform: 'linkedin',
|
||||||
|
requisition_status: 'open',
|
||||||
|
status: 'draft',
|
||||||
|
experience_min: 3,
|
||||||
|
experience_max: 5,
|
||||||
|
requirements: ['B2B Sales', 'Distributor Management'],
|
||||||
|
optional_skills: ['Arabic', 'FMCG Background', 'Regional Travel'],
|
||||||
|
description: 'Own the sales pipeline across the GEO region.',
|
||||||
|
current_recruiter_ids: [],
|
||||||
|
recruiter_names: ['Nida Khan'],
|
||||||
|
hiring_manager_name: 'Amara Osei',
|
||||||
|
applicant_count: 42,
|
||||||
|
created_by_name: 'Nida Khan',
|
||||||
|
created_at: '2026-08-12T09:00:00Z',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function candidate(overrides) {
|
||||||
|
return {
|
||||||
|
id: overrides.id,
|
||||||
|
user_id: null, candidate_id: null, form_data_id: null, inbox_id: null,
|
||||||
|
email: null, current_title: null, current_company: null, years_experience: null,
|
||||||
|
matched_keywords: [], missing_keywords: [], optional_matched: [],
|
||||||
|
summary: null, source: 'upload', scored_candidate_id: null,
|
||||||
|
created_at: '2026-09-01T10:00:00Z',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CANDIDATES = [
|
||||||
|
candidate({
|
||||||
|
id: 'a1', name: 'Sana Iqbal', match_score: 91, band: 'Strong Match', source: 'inbox',
|
||||||
|
current_title: 'Regional Sales Manager', current_company: 'Unilever', years_experience: 6,
|
||||||
|
matched_keywords: ['B2B Sales', 'Distributor Management'], optional_matched: ['Arabic'],
|
||||||
|
summary: 'Six years leading distributor relationships across GCC.', created_at: '2026-09-01T10:00:00Z',
|
||||||
|
}),
|
||||||
|
candidate({
|
||||||
|
id: 'a2', name: 'Ayesha Noor', match_score: 78, band: 'Potential Match',
|
||||||
|
matched_keywords: ['B2B Sales'], missing_keywords: ['Distributor Management'],
|
||||||
|
created_at: '2026-09-05T10:00:00Z',
|
||||||
|
}),
|
||||||
|
candidate({
|
||||||
|
id: 'a3', name: 'Bilal Ahmed', match_score: 40, band: 'Weak Match', source: 'form',
|
||||||
|
created_at: '2026-09-03T10:00:00Z',
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
let profilePayload = null
|
||||||
|
const REQUESTS = []
|
||||||
|
|
||||||
|
globalThis.fetch = async (input) => {
|
||||||
|
const url = String(input?.url ?? input)
|
||||||
|
REQUESTS.push(url)
|
||||||
|
const body = url.includes('/jobs/profile/fetch')
|
||||||
|
? { data: profilePayload, total: 1, status_code: 200 }
|
||||||
|
: { data: [], status_code: 200 }
|
||||||
|
return { ok: true, status: 200, statusText: 'OK', text: async () => JSON.stringify(body) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- bundle
|
||||||
|
const outDir = mkdtempSync(join(tmpdir(), 'tf-jobprofile-'))
|
||||||
|
const outFile = join(outDir, 'entry.mjs')
|
||||||
|
|
||||||
|
await esbuild.build({
|
||||||
|
entryPoints: ['src/__smoke__/entry.jsx'],
|
||||||
|
outfile: outFile,
|
||||||
|
bundle: true,
|
||||||
|
format: 'esm',
|
||||||
|
platform: 'node',
|
||||||
|
target: 'node20',
|
||||||
|
jsx: 'automatic',
|
||||||
|
loader: { '.js': 'jsx', '.jsx': 'jsx' },
|
||||||
|
logLevel: 'error',
|
||||||
|
define: {
|
||||||
|
'process.env.NODE_ENV': '"development"',
|
||||||
|
'import.meta.env': JSON.stringify({ DEV: true, PROD: false, MODE: 'development', VITE_API_BASE: '' }),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- run
|
||||||
|
const errors = []
|
||||||
|
const origError = console.error
|
||||||
|
console.error = (...args) => {
|
||||||
|
const msg = args.map((a) => (a instanceof Error ? a.stack : String(a))).join(' ')
|
||||||
|
if (msg.includes('React Router Future Flag')) return
|
||||||
|
errors.push(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
let failed = 0
|
||||||
|
function check(name, ok, detail = '') {
|
||||||
|
if (ok) console.log(`ok ${name}`)
|
||||||
|
else {
|
||||||
|
failed++
|
||||||
|
console.log(`FAIL ${name}${detail ? `\n ${detail}` : ''}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mod = await import(pathToFileURL(outFile).href)
|
||||||
|
mod.boot()
|
||||||
|
|
||||||
|
// ------------------------------------------------ full profile
|
||||||
|
profilePayload = {
|
||||||
|
job: jobRow(),
|
||||||
|
suggested: 3,
|
||||||
|
top_match: 1,
|
||||||
|
top_score: 91,
|
||||||
|
bands: { 'Strong Match': 1, 'Potential Match': 1, 'Weak Match': 1 },
|
||||||
|
candidates: CANDIDATES,
|
||||||
|
}
|
||||||
|
let container = dom.window.document.createElement('div')
|
||||||
|
dom.window.document.body.appendChild(container)
|
||||||
|
let m = await mod.mountRoute(`/job/${JOB_ID}`, container)
|
||||||
|
await m.settle(60)
|
||||||
|
|
||||||
|
const profileCalls = REQUESTS.filter((u) => u.includes('/jobs/profile/fetch'))
|
||||||
|
check('one profile request feeds the page', profileCalls.length === 1, `calls=${profileCalls.length}`)
|
||||||
|
check('profile request carries the job id', profileCalls[0]?.includes(`job_post_id=${JOB_ID}`), profileCalls[0])
|
||||||
|
|
||||||
|
const role = m.find('.job-hero .ph-role')?.textContent || ''
|
||||||
|
check('hero line shows department, location and employment type', role === 'GEO · Multi-region · Permanent', `role="${role}"`)
|
||||||
|
|
||||||
|
const stats = [...container.querySelectorAll('.hero-stat')].map((el) => ({
|
||||||
|
v: el.querySelector('.v')?.textContent, l: el.querySelector('.l')?.textContent,
|
||||||
|
}))
|
||||||
|
check('Suggested stat reads 3', stats.some((s) => s.l === 'Suggested' && s.v === '3'), JSON.stringify(stats))
|
||||||
|
check('Top Match stat counts the Strong Match band', stats.some((s) => s.l === 'Top Match' && s.v === '1'), JSON.stringify(stats))
|
||||||
|
check('hero tags show vacancies and applicants', m.text().includes('3 Vacancies') && m.text().includes('42 Applicants'))
|
||||||
|
|
||||||
|
const html = m.html()
|
||||||
|
const reqAt = html.indexOf('Required Skills')
|
||||||
|
const optAt = html.indexOf('Optional Skills')
|
||||||
|
check('Optional Skills section sits below Required Skills', reqAt > -1 && optAt > reqAt, `req=${reqAt} opt=${optAt}`)
|
||||||
|
const optionalTags = [...container.querySelectorAll('.tag.tag-optional')].map((el) => el.textContent.trim())
|
||||||
|
check('every optional skill is a highlighted tag', optionalTags.join('|') === 'Arabic|FMCG Background|Regional Travel', optionalTags.join('|'))
|
||||||
|
check('created line joins date and author', m.text().includes('by Nida Khan'))
|
||||||
|
|
||||||
|
// ------------------------------------------------ suggested tab
|
||||||
|
await m.click(m.findByText('[role="tab"]', 'Suggested Candidates'))
|
||||||
|
let cards = [...container.querySelectorAll('.cand-card')]
|
||||||
|
check('one card per suggested candidate', cards.length === 3, `cards=${cards.length}`)
|
||||||
|
const names = () => [...container.querySelectorAll('.cand-card .cand-name')].map((el) => el.textContent.trim())
|
||||||
|
check('default sort is best → worst', names().join('|') === 'Sana Iqbal|Ayesha Noor|Bilal Ahmed', names().join('|'))
|
||||||
|
const ranks = [...container.querySelectorAll('.cand-rank')].map((el) => el.textContent)
|
||||||
|
check('cards are ranked 1..n', ranks.join(',') === '1,2,3', ranks.join(','))
|
||||||
|
check('sub line counts and marks the sort', m.text().includes('3 candidates suggested') && m.text().includes('Sorted: Best → Worst'))
|
||||||
|
|
||||||
|
const first = container.querySelector('.cand-card')
|
||||||
|
const optChips = [...first.querySelectorAll('.cand-chip.opt')].map((el) => el.textContent.trim())
|
||||||
|
check('matched optional skill is highlighted on its card', optChips.join('|') === 'Arabic', optChips.join('|'))
|
||||||
|
check('matched and missing chips render', first.querySelectorAll('.cand-chip.ok').length === 2
|
||||||
|
&& container.querySelectorAll('.cand-card')[1].querySelectorAll('.cand-chip.miss').length === 1)
|
||||||
|
check('source label maps inbox → Email', first.textContent.includes('Email'))
|
||||||
|
check('foot shows years and company', first.textContent.includes('6 yrs · Unilever'))
|
||||||
|
|
||||||
|
const sortSelect = container.querySelector('#suggested-sort')
|
||||||
|
await m.selectOption(sortSelect, 'name')
|
||||||
|
check('A → Z sort orders by name and drops ranks',
|
||||||
|
names().join('|') === 'Ayesha Noor|Bilal Ahmed|Sana Iqbal' && !container.querySelector('.cand-rank'), names().join('|'))
|
||||||
|
await m.selectOption(sortSelect, 'recent')
|
||||||
|
check('Most Recent sort orders by score time', names().join('|') === 'Ayesha Noor|Bilal Ahmed|Sana Iqbal', names().join('|'))
|
||||||
|
|
||||||
|
await m.selectOption(container.querySelector('select[aria-label="Match band"]'), 'Weak Match')
|
||||||
|
check('band filter narrows to that band', names().join('|') === 'Bilal Ahmed', names().join('|'))
|
||||||
|
await m.unmount()
|
||||||
|
container.remove()
|
||||||
|
|
||||||
|
// ------------------------------------------------ sparse job
|
||||||
|
// A different id: the query client is shared across mounts, so the first
|
||||||
|
// job's profile is still cached under its own key.
|
||||||
|
const SPARSE_ID = '99999999-2222-3333-4444-555555555555'
|
||||||
|
profilePayload = {
|
||||||
|
job: jobRow({ id: SPARSE_ID, employment_type: null, optional_skills: [] }),
|
||||||
|
suggested: 0, top_match: 0, top_score: null,
|
||||||
|
bands: { 'Strong Match': 0, 'Potential Match': 0, 'Weak Match': 0 },
|
||||||
|
candidates: [],
|
||||||
|
}
|
||||||
|
container = dom.window.document.createElement('div')
|
||||||
|
dom.window.document.body.appendChild(container)
|
||||||
|
m = await mod.mountRoute(`/job/${SPARSE_ID}?tab=suggested`, container)
|
||||||
|
await m.settle(60)
|
||||||
|
const sparseRole = m.find('.job-hero .ph-role')?.textContent || ''
|
||||||
|
check('no employment type → hero line omits it', sparseRole === 'GEO · Multi-region', `role="${sparseRole}"`)
|
||||||
|
check('?tab=suggested opens that tab, with an empty state', m.text().includes('No suggested candidates yet'))
|
||||||
|
await m.click(m.findByText('[role="tab"]', 'Details'))
|
||||||
|
check('no optional skills → no Optional Skills section', !m.text().includes('Optional Skills'))
|
||||||
|
await m.unmount()
|
||||||
|
container.remove()
|
||||||
|
|
||||||
|
check('no console errors', errors.length === 0, errors[0]?.split('\n').slice(0, 3).join(' | '))
|
||||||
|
} finally {
|
||||||
|
console.error = origError
|
||||||
|
rmSync(outDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(failed ? `\n${failed} job profile check(s) FAILED` : '\nAll job profile checks passed')
|
||||||
|
process.exit(failed ? 1 : 0)
|
||||||
|
|
@ -16,7 +16,8 @@
|
||||||
"test:candidates": "node candidates-table.test.mjs",
|
"test:candidates": "node candidates-table.test.mjs",
|
||||||
"test:cvbank": "node cvbank.test.mjs",
|
"test:cvbank": "node cvbank.test.mjs",
|
||||||
"test:mobile": "node mobile.test.mjs",
|
"test:mobile": "node mobile.test.mjs",
|
||||||
"verify": "vite build && node smoke.test.mjs && node token.test.mjs && node theme.test.mjs && node format.test.mjs && node inbox-loading.test.mjs && node candidates-table.test.mjs && node cvbank.test.mjs"
|
"test:jobprofile": "node job-profile.test.mjs",
|
||||||
|
"verify": "vite build && node smoke.test.mjs && node token.test.mjs && node theme.test.mjs && node format.test.mjs && node inbox-loading.test.mjs && node candidates-table.test.mjs && node cvbank.test.mjs && node job-profile.test.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.101.4",
|
"@tanstack/react-query": "^5.101.4",
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ const SCREENS = {
|
||||||
|
|
||||||
// Detail pages live outside the ROUTES table (no sidebar entry, parameterized path).
|
// Detail pages live outside the ROUTES table (no sidebar entry, parameterized path).
|
||||||
const CandidatePage = lazy(() => import('./screens/CandidatePage'))
|
const CandidatePage = lazy(() => import('./screens/CandidatePage'))
|
||||||
|
const JobProfile = lazy(() => import('./screens/JobProfile'))
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -95,6 +96,14 @@ export default function App() {
|
||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/job/:jobId"
|
||||||
|
element={
|
||||||
|
<RequireAuth permission="jobs.view">
|
||||||
|
<JobProfile />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ import Notifications from '../screens/Notifications'
|
||||||
import Rbac from '../screens/Rbac'
|
import Rbac from '../screens/Rbac'
|
||||||
import Settings from '../screens/Settings'
|
import Settings from '../screens/Settings'
|
||||||
import Help from '../screens/Help'
|
import Help from '../screens/Help'
|
||||||
|
import JobProfile from '../screens/JobProfile'
|
||||||
|
|
||||||
const SCREENS = {
|
const SCREENS = {
|
||||||
dashboard: Dashboard, inbox: Inbox, matching: Matching, jobs: Jobs, candidates: Candidates,
|
dashboard: Dashboard, inbox: Inbox, matching: Matching, jobs: Jobs, candidates: Candidates,
|
||||||
|
|
@ -68,6 +69,11 @@ const PAGES = {
|
||||||
'/auth/confirm-email': ConfirmEmail,
|
'/auth/confirm-email': ConfirmEmail,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detail pages live outside the ROUTES table (parameterized path), as in App.jsx.
|
||||||
|
const DETAIL_PAGES = [
|
||||||
|
{ pattern: '/job/:jobId', prefix: '/job/', Screen: JobProfile },
|
||||||
|
]
|
||||||
|
|
||||||
export const ALL_ROUTES = [
|
export const ALL_ROUTES = [
|
||||||
...Object.keys(PAGES),
|
...Object.keys(PAGES),
|
||||||
...TABLE.map((r) => `/${r.path}`),
|
...TABLE.map((r) => `/${r.path}`),
|
||||||
|
|
@ -129,15 +135,17 @@ export async function mountRoute(path, container) {
|
||||||
function routeTree(path) {
|
function routeTree(path) {
|
||||||
const h = React.createElement
|
const h = React.createElement
|
||||||
const isAuth = path.startsWith('/auth/')
|
const isAuth = path.startsWith('/auth/')
|
||||||
const def = TABLE.find((r) => `/${r.path}` === path)
|
const detail = DETAIL_PAGES.find((d) => path.startsWith(d.prefix))
|
||||||
const Screen = isAuth ? PAGES[path] : SCREENS[def.path]
|
const def = TABLE.find((r) => `/${r.path}` === path.split('?')[0])
|
||||||
|
const Screen = isAuth ? PAGES[path] : detail ? detail.Screen : SCREENS[def.path]
|
||||||
|
const routePath = detail ? detail.pattern : path.split('?')[0]
|
||||||
|
|
||||||
const inner = isAuth
|
const inner = isAuth
|
||||||
? h(Route, { path, element: h(Screen) })
|
? h(Route, { path, element: h(Screen) })
|
||||||
: h(
|
: h(
|
||||||
Route,
|
Route,
|
||||||
{ element: h(RequireAuth, null, h(AppLayout)) },
|
{ element: h(RequireAuth, null, h(AppLayout)) },
|
||||||
h(Route, { path, element: h(Screen) }),
|
h(Route, { path: routePath, element: h(Screen) }),
|
||||||
)
|
)
|
||||||
|
|
||||||
return h(
|
return h(
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,44 @@ export function setStatus(jobPostId, status) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job profile page — GET /jobs/profile/fetch. One round trip: the requisition
|
||||||
|
* row (same shape as /jobs/fetch, so toJobView applies), its suggested
|
||||||
|
* candidates (newest ats_results row per person, best score first) and the
|
||||||
|
* Suggested / Top Match header stats.
|
||||||
|
*/
|
||||||
|
export function fetchProfile(jobPostId) {
|
||||||
|
return request('/jobs/profile/fetch', { params: { job_post_id: jobPostId } })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ats_results.band labels, best first — the backend's MATCH_BANDS. */
|
||||||
|
export const MATCH_BANDS = ['Strong Match', 'Potential Match', 'Weak Match']
|
||||||
|
|
||||||
|
export const SUGGESTED_SOURCE_LABEL = { inbox: 'Email', form: 'Sheet Form', upload: 'Uploaded', bank: 'CV Bank' }
|
||||||
|
|
||||||
|
/** One suggested candidate -> what the job profile card renders. */
|
||||||
|
export function toSuggestedView(row) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
userId: row.user_id || null,
|
||||||
|
scoredCandidateId: row.scored_candidate_id || null,
|
||||||
|
name: row.name || row.email || 'Unknown',
|
||||||
|
email: row.email || null,
|
||||||
|
currentTitle: row.current_title || null,
|
||||||
|
currentCompany: row.current_company || null,
|
||||||
|
experience: row.years_experience ?? null,
|
||||||
|
score: row.match_score ?? null,
|
||||||
|
band: row.band || null,
|
||||||
|
matchedSkills: Array.isArray(row.matched_keywords) ? row.matched_keywords : [],
|
||||||
|
missingSkills: Array.isArray(row.missing_keywords) ? row.missing_keywords : [],
|
||||||
|
optionalMatched: Array.isArray(row.optional_matched) ? row.optional_matched : [],
|
||||||
|
summary: row.summary || null,
|
||||||
|
source: row.source || null,
|
||||||
|
sourceLabel: SUGGESTED_SOURCE_LABEL[row.source] ?? row.source ?? '—',
|
||||||
|
scoredAt: toDate(row.created_at),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Status-change audit for one requisition — GET /jobs/status-history/fetch. */
|
/** Status-change audit for one requisition — GET /jobs/status-history/fetch. */
|
||||||
export function listStatusHistory(jobPostId) {
|
export function listStatusHistory(jobPostId) {
|
||||||
return request('/jobs/status-history/fetch', { params: { job_post_id: jobPostId } })
|
return request('/jobs/status-history/fetch', { params: { job_post_id: jobPostId } })
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,7 @@ export const qk = {
|
||||||
list: (p = {}) => ['jobs', 'list', p],
|
list: (p = {}) => ['jobs', 'list', p],
|
||||||
requisitionStatuses: () => ['jobs', 'requisition-statuses'],
|
requisitionStatuses: () => ['jobs', 'requisition-statuses'],
|
||||||
statusHistory: (id) => ['jobs', 'status-history', id],
|
statusHistory: (id) => ['jobs', 'status-history', id],
|
||||||
|
profile: (id) => ['jobs', 'profile', id],
|
||||||
stats: (p = {}) => ['jobs', 'stats', p],
|
stats: (p = {}) => ['jobs', 'stats', p],
|
||||||
},
|
},
|
||||||
talent: {
|
talent: {
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ const SOURCE_LABEL = { upload: 'Upload', inbox: 'Email' }
|
||||||
const PAGE_SIZE_MAX = 100
|
const PAGE_SIZE_MAX = 100
|
||||||
|
|
||||||
/** Extraction keeps the resume's own casing; SHOUTED headers read badly on cards. */
|
/** Extraction keeps the resume's own casing; SHOUTED headers read badly on cards. */
|
||||||
function displayName(name) {
|
export function displayName(name) {
|
||||||
if (!name || /[a-z]/.test(name)) return name
|
if (!name || /[a-z]/.test(name)) return name
|
||||||
return name.toLowerCase().replace(/\p{L}+/gu, (w) => w[0].toUpperCase() + w.slice(1))
|
return name.toLowerCase().replace(/\p{L}+/gu, (w) => w[0].toUpperCase() + w.slice(1))
|
||||||
}
|
}
|
||||||
|
|
@ -42,7 +42,7 @@ function ringColor(score) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The 120px .ats-ring shrunk to card size — same conic trick, no new CSS. */
|
/** The 120px .ats-ring shrunk to card size — same conic trick, no new CSS. */
|
||||||
function MiniRing({ score, size = 46 }) {
|
export function MiniRing({ score, size = 46 }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,485 @@
|
||||||
|
/* ============================================================
|
||||||
|
JobProfile — full-page job profile at /job/:jobId (replaces the Jobs
|
||||||
|
board's detail modal, per the "Job Profile" design).
|
||||||
|
|
||||||
|
One request, GET /jobs/profile/fetch, feeds the whole page: the requisition
|
||||||
|
row, the suggested candidates and the Suggested / Top Match header stats.
|
||||||
|
Suggested = the newest ats_results score per person for this job; Top Match
|
||||||
|
= how many of them sit in the Strong Match band. Sorting, band filter and
|
||||||
|
search run client-side over that one list.
|
||||||
|
|
||||||
|
Tabs: Details · Suggested Candidates · History (?tab= deep-links a tab).
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import { Tabs } from '../ui/Tabs'
|
||||||
|
import { Avatar, Badge, EmptyState, Icon, SkeletonRows } from '../ui/primitives'
|
||||||
|
import { useToast } from '../ui/Toast'
|
||||||
|
import { useAuth } from '../auth/AuthContext'
|
||||||
|
import { qk } from '../lib/queryKeys'
|
||||||
|
import { friendlyAuthError } from '../lib/errors'
|
||||||
|
import { platformLabel } from '../lib/platforms'
|
||||||
|
import { fmtShort } from '../lib/format'
|
||||||
|
import { avatarColor, initials as initialsOf } from '../data/seed'
|
||||||
|
import * as jobsApi from '../api/jobs'
|
||||||
|
import * as assignmentsApi from '../api/assignments'
|
||||||
|
import * as offersApi from '../api/offers'
|
||||||
|
import { EditJobForm, JobCover, JobHistory, JobOwnership, SECTION_LABEL, deptLabel, deptValue } from './Jobs'
|
||||||
|
import { MiniRing, ScoredCandidateDetail, displayName } from './JobCandidates'
|
||||||
|
|
||||||
|
const TAB_KEYS = ['details', 'suggested', 'history']
|
||||||
|
|
||||||
|
const SORTS = [
|
||||||
|
{ value: 'score', label: 'Best Match → Worst Match' },
|
||||||
|
{ value: 'recent', label: 'Most Recent' },
|
||||||
|
{ value: 'name', label: 'A → Z' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function JobProfile() {
|
||||||
|
const { jobId } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const { toast } = useToast()
|
||||||
|
const { can } = useAuth()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
const canEdit = can('jobs.edit')
|
||||||
|
const canDelete = can('jobs.delete')
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
|
||||||
|
const requestedTab = String(searchParams.get('tab') || '').toLowerCase()
|
||||||
|
const tab = TAB_KEYS.includes(requestedTab) ? requestedTab : 'details'
|
||||||
|
const setTab = (next) => {
|
||||||
|
const params = new URLSearchParams(searchParams)
|
||||||
|
if (next === 'details') params.delete('tab')
|
||||||
|
else params.set('tab', next)
|
||||||
|
setSearchParams(params, { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileQuery = useQuery({
|
||||||
|
queryKey: qk.jobs.profile(jobId),
|
||||||
|
queryFn: async () => (await jobsApi.fetchProfile(jobId))?.data ?? null,
|
||||||
|
enabled: Boolean(jobId),
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
const profile = profileQuery.data
|
||||||
|
const job = useMemo(() => (profile?.job ? jobsApi.toJobView(profile.job) : null), [profile])
|
||||||
|
const suggested = useMemo(
|
||||||
|
() => (Array.isArray(profile?.candidates) ? profile.candidates.map(jobsApi.toSuggestedView) : []),
|
||||||
|
[profile],
|
||||||
|
)
|
||||||
|
|
||||||
|
const statusesQuery = useQuery({
|
||||||
|
queryKey: qk.jobs.requisitionStatuses(),
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await jobsApi.listRequisitionStatuses()
|
||||||
|
const rows = Array.isArray(res?.data) ? res.data : []
|
||||||
|
return rows.length ? rows : jobsApi.REQUISITION_STATUSES
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const statusLabels = (statusesQuery.data ?? jobsApi.REQUISITION_STATUSES).map((s) => s.label)
|
||||||
|
|
||||||
|
// Shared key documented on jobsApi.fetchDepartmentOptions — same fetcher everywhere.
|
||||||
|
const departmentsQuery = useQuery({
|
||||||
|
queryKey: qk.jobs.list({ scope: 'departments' }),
|
||||||
|
queryFn: jobsApi.fetchDepartmentOptions,
|
||||||
|
enabled: editing,
|
||||||
|
})
|
||||||
|
|
||||||
|
const historyQuery = useQuery({
|
||||||
|
queryKey: qk.assignments.job(jobId),
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await assignmentsApi.listJob(jobId, { currentOnly: false })
|
||||||
|
const rows = Array.isArray(res?.data) ? res.data : []
|
||||||
|
return rows.map((r) => assignmentsApi.toAssignmentView(r))
|
||||||
|
},
|
||||||
|
enabled: Boolean(jobId),
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
const statusQuery = useQuery({
|
||||||
|
queryKey: qk.jobs.statusHistory(jobId),
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await jobsApi.listStatusHistory(jobId)
|
||||||
|
return Array.isArray(res?.data) ? res.data : []
|
||||||
|
},
|
||||||
|
enabled: Boolean(jobId),
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
const offersQuery = useQuery({
|
||||||
|
queryKey: qk.offers.list({ jobPostId: jobId, top: 200 }),
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await offersApi.list({ jobPostId: jobId, top: 200 })
|
||||||
|
return Array.isArray(res?.data) ? res.data : []
|
||||||
|
},
|
||||||
|
enabled: Boolean(jobId),
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
const historyCount = (historyQuery.data?.length ?? 0) + (statusQuery.data?.length ?? 0) + (offersQuery.data?.length ?? 0)
|
||||||
|
|
||||||
|
const updateJob = useMutation({
|
||||||
|
mutationFn: (body) => jobsApi.update(jobId, body),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
||||||
|
qc.invalidateQueries({ queryKey: qk.requisitions.all() })
|
||||||
|
setEditing(false)
|
||||||
|
toast('Job updated', 'success')
|
||||||
|
},
|
||||||
|
onError: (err) => toast(friendlyAuthError(err, 'Could not update the job'), 'error'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const setJobStatus = useMutation({
|
||||||
|
mutationFn: (status) => jobsApi.setStatus(jobId, status),
|
||||||
|
onSuccess: (_d, status) => {
|
||||||
|
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
||||||
|
qc.invalidateQueries({ queryKey: qk.notifications.all() })
|
||||||
|
toast(`Status set to ${status}`, 'success')
|
||||||
|
},
|
||||||
|
onError: (err) => toast(friendlyAuthError(err, 'Could not update status'), 'error'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteJob = useMutation({
|
||||||
|
mutationFn: () => jobsApi.remove(jobId),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
||||||
|
qc.invalidateQueries({ queryKey: qk.requisitions.all() })
|
||||||
|
toast('Job deleted', 'success')
|
||||||
|
navigate('/jobs', { replace: true })
|
||||||
|
},
|
||||||
|
onError: (err) => toast(friendlyAuthError(err, 'Could not delete the job'), 'error'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const goBack = () => (window.history.length > 1 ? navigate(-1) : navigate('/jobs'))
|
||||||
|
|
||||||
|
if (profileQuery.isPending || profileQuery.isError || !job) {
|
||||||
|
return (
|
||||||
|
<div className="cand-page">
|
||||||
|
<div className="cand-page-bar">
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={goBack}><Icon name="chevron-left" /> Back</button>
|
||||||
|
<div className="cand-page-crumb"><Link to="/jobs">Jobs</Link></div>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body">
|
||||||
|
{profileQuery.isPending ? (
|
||||||
|
<SkeletonRows rows={6} />
|
||||||
|
) : (
|
||||||
|
<EmptyState icon="briefcase" title="Couldn’t load this job">
|
||||||
|
{friendlyAuthError(profileQuery.error, 'The job may have been deleted or is outside your scope.')}
|
||||||
|
</EmptyState>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleLine = [deptValue(job), job.location, job.type].filter(Boolean).join(' · ')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="cand-page">
|
||||||
|
<div className="cand-page-bar">
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={goBack}><Icon name="chevron-left" /> Back</button>
|
||||||
|
<div className="cand-page-crumb">
|
||||||
|
<Link to="/jobs">Jobs</Link> <span>›</span> <strong>{job.title}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="profile-hero job-hero">
|
||||||
|
<span className="job-hero-icn"><Icon name="briefcase" /></span>
|
||||||
|
<div className="job-hero-id">
|
||||||
|
<div className="ph-name">{job.title}</div>
|
||||||
|
<div className="ph-role">{roleLine || '—'}</div>
|
||||||
|
<div className="ph-tags">
|
||||||
|
<Badge>{job.status}</Badge>
|
||||||
|
<Badge className="b-gray">{job.vacancies ?? 0} {job.vacancies === 1 ? 'Vacancy' : 'Vacancies'}</Badge>
|
||||||
|
<Badge className="b-gray">{job.applicantCount} {job.applicantCount === 1 ? 'Applicant' : 'Applicants'}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="job-hero-actions">
|
||||||
|
<div className="hero-stat">
|
||||||
|
<div className="v">{profile.suggested ?? 0}</div>
|
||||||
|
<div className="l">Suggested</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="hero-stat"
|
||||||
|
title={profile.top_score != null ? `Candidates in the Strong Match band · best score ${profile.top_score}` : 'Candidates in the Strong Match band'}
|
||||||
|
>
|
||||||
|
<div className="v" style={{ color: profile.top_match ? 'var(--success)' : undefined }}>{profile.top_match ?? 0}</div>
|
||||||
|
<div className="l">Top Match</div>
|
||||||
|
</div>
|
||||||
|
{canEdit ? (
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
aria-label="Requisition status"
|
||||||
|
value={job.status}
|
||||||
|
disabled={setJobStatus.isPending}
|
||||||
|
onChange={(e) => setJobStatus.mutate(e.target.value)}
|
||||||
|
>
|
||||||
|
{statusLabels.map((s) => <option key={s}>{s}</option>)}
|
||||||
|
</select>
|
||||||
|
) : null}
|
||||||
|
{canDelete && (
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ color: 'var(--danger)' }}
|
||||||
|
disabled={deleteJob.isPending}
|
||||||
|
onClick={() => { if (window.confirm(`Delete “${job.title}”?`)) deleteJob.mutate() }}
|
||||||
|
>
|
||||||
|
<Icon name="trash" /> {deleteJob.isPending ? 'Deleting…' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canEdit && (
|
||||||
|
<button className="btn btn-secondary" onClick={() => setEditing(true)}><Icon name="edit" /> Edit</button>
|
||||||
|
)}
|
||||||
|
<button className="btn btn-primary" onClick={() => navigate('/jobboard', { state: { publishJob: job.id } })}>
|
||||||
|
<Icon name="send" /> Publish
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
value={tab}
|
||||||
|
onChange={setTab}
|
||||||
|
tabs={[
|
||||||
|
{ key: 'details', label: 'Details' },
|
||||||
|
{ key: 'suggested', label: 'Suggested Candidates', count: profile.suggested || undefined },
|
||||||
|
{ key: 'history', label: 'History', count: historyCount || undefined },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="tab-pane active">
|
||||||
|
{tab === 'details' && <JobDetailsTab job={job} canEdit={canEdit} />}
|
||||||
|
{tab === 'suggested' && <SuggestedCandidatesTab job={job} profile={profile} candidates={suggested} />}
|
||||||
|
{tab === 'history' && <JobHistory historyQuery={historyQuery} statusQuery={statusQuery} offersQuery={offersQuery} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<EditJobForm
|
||||||
|
job={job}
|
||||||
|
departmentOptions={departmentsQuery.data ?? []}
|
||||||
|
busy={updateJob.isPending}
|
||||||
|
onClose={() => setEditing(false)}
|
||||||
|
onSubmit={(body) => updateJob.mutate(body)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function JobDetailsTab({ job: j, canEdit }) {
|
||||||
|
const created = [j.created ? fmtShort(j.created) : null, j.createdByName ? `by ${j.createdByName}` : null]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<JobCover jobId={j.id} />
|
||||||
|
|
||||||
|
<div className="info-grid mb-18">
|
||||||
|
<div className="info-item"><div className="il">Department</div><div className="iv">{deptLabel(j)}</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">{platformLabel(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">Experience</div><div className="iv">{j.experience || '—'}</div></div>
|
||||||
|
<div className="info-item"><div className="il">Created</div><div className="iv">{created || '—'}</div></div>
|
||||||
|
<div className="info-item"><div className="il">Requisition</div><div className="iv">{j.requisitionLabel || '—'}</div></div>
|
||||||
|
<div className="info-item"><div className="il">Hiring Manager</div><div className="iv">{j.hiringManager || '—'}</div></div>
|
||||||
|
<div className="info-item"><div className="il">Assigned Recruiters</div><div className="iv">{j.recruiter || '—'}</div></div>
|
||||||
|
{j.closedAt && (
|
||||||
|
<div className="info-item"><div className="il">Closed at</div><div className="iv">{fmtShort(j.closedAt)}</div></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<JobOwnership job={j} canEdit={canEdit} />
|
||||||
|
|
||||||
|
{j.description && (
|
||||||
|
<>
|
||||||
|
<div className="divider" />
|
||||||
|
<div className="mb-16">
|
||||||
|
<div style={SECTION_LABEL}>Description</div>
|
||||||
|
<p style={{ color: 'var(--text-2)' }}>{j.description}</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{j.skills.length > 0 && (
|
||||||
|
<div className="mb-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>
|
||||||
|
)}
|
||||||
|
{j.optionalSkills.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div style={SECTION_LABEL}>Optional Skills</div>
|
||||||
|
<div className="k-tags">
|
||||||
|
{j.optionalSkills.map((s) => (
|
||||||
|
<span className="tag tag-optional" key={s}><Icon name="star" /> {s}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SuggestedCandidatesTab({ job, profile, candidates }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
const [band, setBand] = useState('')
|
||||||
|
const [sort, setSort] = useState('score')
|
||||||
|
const [viewing, setViewing] = useState(null)
|
||||||
|
|
||||||
|
const list = useMemo(() => {
|
||||||
|
const term = q.trim().toLowerCase()
|
||||||
|
let rows = candidates.filter((c) => {
|
||||||
|
if (band && c.band !== band) return false
|
||||||
|
if (!term) return true
|
||||||
|
const hay = [
|
||||||
|
c.name, c.email, c.currentTitle, c.currentCompany,
|
||||||
|
c.matchedSkills.join(' '), c.optionalMatched.join(' '),
|
||||||
|
].filter(Boolean).join(' ').toLowerCase()
|
||||||
|
return hay.includes(term)
|
||||||
|
})
|
||||||
|
if (sort === 'name') rows = [...rows].sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
else if (sort === 'recent') rows = [...rows].sort((a, b) => (b.scoredAt?.getTime() ?? 0) - (a.scoredAt?.getTime() ?? 0))
|
||||||
|
else rows = [...rows].sort((a, b) => (b.score ?? -1) - (a.score ?? -1))
|
||||||
|
return rows
|
||||||
|
}, [candidates, q, band, sort])
|
||||||
|
|
||||||
|
const scored = candidates.filter((c) => c.score != null).length
|
||||||
|
const bands = profile.bands || {}
|
||||||
|
|
||||||
|
function open(c) {
|
||||||
|
if (c.userId) {
|
||||||
|
navigate(`/candidate/${c.userId}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setViewing(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="cand-sub">
|
||||||
|
{profile.suggested} candidate{profile.suggested === 1 ? '' : 's'} suggested · {scored} scored · vs {job.title}
|
||||||
|
{sort === 'score' && <span className="auto-tag">Sorted: Best → Worst</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar">
|
||||||
|
<div className="toolbar-search">
|
||||||
|
<Icon name="search" />
|
||||||
|
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search name, skill, company…" />
|
||||||
|
</div>
|
||||||
|
<select className="select" aria-label="Match band" value={band} onChange={(e) => setBand(e.target.value)}>
|
||||||
|
<option value="">All results</option>
|
||||||
|
{jobsApi.MATCH_BANDS.map((b) => (
|
||||||
|
<option key={b} value={b}>{b} ({bands[b] ?? 0})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="spacer" />
|
||||||
|
<div className="flex items-center gap-8">
|
||||||
|
<label className="text-muted text-sm" htmlFor="suggested-sort">Sort:</label>
|
||||||
|
<select
|
||||||
|
id="suggested-sort"
|
||||||
|
className={`select${sort === 'score' ? ' active-filter' : ''}`}
|
||||||
|
value={sort}
|
||||||
|
onChange={(e) => setSort(e.target.value)}
|
||||||
|
>
|
||||||
|
{SORTS.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{list.length === 0 ? (
|
||||||
|
candidates.length === 0 ? (
|
||||||
|
<EmptyState icon="users" title="No suggested candidates yet">
|
||||||
|
Candidates appear here once their CVs are ATS-scored against this job.
|
||||||
|
</EmptyState>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="No matches">Try a different search or band.</EmptyState>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="grid g-3">
|
||||||
|
{list.map((c, i) => (
|
||||||
|
<SuggestedCard key={c.id} c={c} rank={sort === 'score' ? i + 1 : null} onOpen={() => open(c)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{viewing && (
|
||||||
|
<ScoredCandidateDetail
|
||||||
|
candidate={{
|
||||||
|
id: viewing.id,
|
||||||
|
name: viewing.name,
|
||||||
|
filename: viewing.email,
|
||||||
|
source: viewing.sourceLabel,
|
||||||
|
currentTitle: viewing.currentTitle,
|
||||||
|
currentCompany: viewing.currentCompany,
|
||||||
|
experience: viewing.experience,
|
||||||
|
aiScore: viewing.score,
|
||||||
|
matchedSkills: viewing.matchedSkills,
|
||||||
|
missingSkills: viewing.missingSkills,
|
||||||
|
critique: viewing.summary,
|
||||||
|
scoringStatus: 'completed',
|
||||||
|
applied: viewing.scoredAt,
|
||||||
|
}}
|
||||||
|
jobTitle={job.title}
|
||||||
|
onClose={() => setViewing(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SuggestedCard({ c, rank, onOpen }) {
|
||||||
|
const matched = c.matchedSkills.slice(0, 3)
|
||||||
|
const missing = c.missingSkills.slice(0, 2)
|
||||||
|
const optional = c.optionalMatched.slice(0, 3)
|
||||||
|
const more = (c.matchedSkills.length - matched.length)
|
||||||
|
+ (c.missingSkills.length - missing.length)
|
||||||
|
+ (c.optionalMatched.length - optional.length)
|
||||||
|
const roleLine = [c.currentTitle, c.currentCompany].filter(Boolean).join(' at ')
|
||||||
|
const foot = [c.experience != null ? `${c.experience} yrs` : null, c.currentCompany].filter(Boolean).join(' · ')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`card cand-card${rank ? ' ranked' : ''}`}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={onOpen}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpen() } }}
|
||||||
|
>
|
||||||
|
<div className="card-body">
|
||||||
|
{rank && <span className="cand-rank">{rank}</span>}
|
||||||
|
<div className="cand-head">
|
||||||
|
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} className="avatar-lg" />
|
||||||
|
<div className="cand-id">
|
||||||
|
<div className="cand-name">{displayName(c.name)}</div>
|
||||||
|
<div className="cand-role">{roleLine || c.email || '—'}</div>
|
||||||
|
</div>
|
||||||
|
{c.score != null && <MiniRing score={c.score} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="cand-skills">
|
||||||
|
{matched.map((s) => <span className="cand-chip ok" key={`m-${s}`}><Icon name="check" /> {s}</span>)}
|
||||||
|
{missing.map((s) => <span className="cand-chip miss" key={`x-${s}`}><Icon name="x" /> {s}</span>)}
|
||||||
|
{optional.map((s) => (
|
||||||
|
<span className="cand-chip opt" key={`o-${s}`} title="Optional skill from the job post"><Icon name="star" /> {s}</span>
|
||||||
|
))}
|
||||||
|
{more > 0 && <span className="cand-chip more">+{more} more</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="cand-crit">{c.summary || <span className="text-muted">No summary</span>}</p>
|
||||||
|
|
||||||
|
<div className="cand-foot">
|
||||||
|
<span className="cand-company">{foot || '—'}</span>
|
||||||
|
<Badge className="b-gray">{c.sourceLabel}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,6 @@ import AiFieldAssist from '../ui/AiFieldAssist'
|
||||||
import DataTable from '../ui/DataTable'
|
import DataTable from '../ui/DataTable'
|
||||||
import Modal from '../ui/Modal'
|
import Modal from '../ui/Modal'
|
||||||
import PageHeader from '../ui/PageHeader'
|
import PageHeader from '../ui/PageHeader'
|
||||||
import { Tabs } from '../ui/Tabs'
|
|
||||||
import { Badge, EmptyState, FieldError, Icon, SkeletonRows } from '../ui/primitives'
|
import { Badge, EmptyState, FieldError, Icon, SkeletonRows } from '../ui/primitives'
|
||||||
import { useToast } from '../ui/Toast'
|
import { useToast } from '../ui/Toast'
|
||||||
import { useAuth } from '../auth/AuthContext'
|
import { useAuth } from '../auth/AuthContext'
|
||||||
|
|
@ -25,11 +24,9 @@ import { friendlyAuthError } from '../lib/errors'
|
||||||
import { platformLabel } from '../lib/platforms'
|
import { platformLabel } from '../lib/platforms'
|
||||||
import * as jobsApi from '../api/jobs'
|
import * as jobsApi from '../api/jobs'
|
||||||
import * as jobPostsApi from '../api/jobPosts'
|
import * as jobPostsApi from '../api/jobPosts'
|
||||||
import * as assignmentsApi from '../api/assignments'
|
|
||||||
import * as tasksApi from '../api/tasks'
|
import * as tasksApi from '../api/tasks'
|
||||||
import * as usersApi from '../api/users'
|
import * as usersApi from '../api/users'
|
||||||
import * as requisitionsApi from '../api/requisitions'
|
import * as requisitionsApi from '../api/requisitions'
|
||||||
import * as offersApi from '../api/offers'
|
|
||||||
import { fmtDate, fmtDateTime, fmtShort, toDate } from '../lib/format'
|
import { fmtDate, fmtDateTime, fmtShort, toDate } from '../lib/format'
|
||||||
import { empTypes } from '../data/seed'
|
import { empTypes } from '../data/seed'
|
||||||
|
|
||||||
|
|
@ -42,11 +39,11 @@ async function fetchJobs() {
|
||||||
return rows.map(jobsApi.toJobView)
|
return rows.map(jobsApi.toJobView)
|
||||||
}
|
}
|
||||||
|
|
||||||
function deptValue(j) {
|
export function deptValue(j) {
|
||||||
return String(j.requisitionDepartment || j.department || '').trim()
|
return String(j.requisitionDepartment || j.department || '').trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
function deptLabel(j) {
|
export function deptLabel(j) {
|
||||||
return deptValue(j) || '—'
|
return deptValue(j) || '—'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,31 +104,27 @@ export default function Jobs() {
|
||||||
const [status, setStatus] = useState('')
|
const [status, setStatus] = useState('')
|
||||||
const [type, setType] = useState('')
|
const [type, setType] = useState('')
|
||||||
|
|
||||||
const [viewing, setViewing] = useState(null)
|
|
||||||
const [viewingTab, setViewingTab] = useState('details')
|
|
||||||
const [editing, setEditing] = useState(null)
|
const [editing, setEditing] = useState(null)
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
|
|
||||||
const canEdit = can('jobs.edit')
|
const canEdit = can('jobs.edit')
|
||||||
const canDelete = can('jobs.delete')
|
const openJob = (id, tab) => navigate(`/job/${id}${tab === 'history' ? '?tab=history' : ''}`)
|
||||||
|
|
||||||
// Deep-link intents from notifications, global search, the dashboard and the
|
// Deep-link intents from notifications, global search, the dashboard and the
|
||||||
// manager portal. Consume once and replace history: jobs refetch after a
|
// manager portal. Consume once and replace history: jobs refetch after a
|
||||||
// status PATCH used to replay openCreate and pop the create modal over the
|
// status PATCH used to replay openCreate and pop the create modal. A job
|
||||||
// detail view. `/jobs?job=` / `?tab=history` is the notification target.
|
// target (`/jobs?job=` / `?tab=history`, or state.openJob) now redirects to
|
||||||
|
// the full job profile page at /job/:jobId.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const st = location.state
|
const st = location.state
|
||||||
const jobId = searchParams.get('job') || st?.openJob
|
const jobId = searchParams.get('job') || st?.openJob
|
||||||
const tab = String(searchParams.get('tab') || '').toLowerCase()
|
const tab = String(searchParams.get('tab') || '').toLowerCase()
|
||||||
if (!st?.openCreate && !jobId) return
|
if (!st?.openCreate && !jobId) return
|
||||||
if (st?.openCreate) setCreating(true)
|
|
||||||
if (jobId) {
|
if (jobId) {
|
||||||
const job = jobs.find((j) => j.id === jobId)
|
navigate(`/job/${jobId}${tab === 'history' ? '?tab=history' : ''}`, { replace: true })
|
||||||
if (job) {
|
return
|
||||||
setViewing(job)
|
|
||||||
setViewingTab(tab === 'history' ? 'history' : 'details')
|
|
||||||
} else if (!jobsQuery.isSuccess) return
|
|
||||||
}
|
}
|
||||||
|
if (st?.openCreate) setCreating(true)
|
||||||
const next = new URLSearchParams(searchParams)
|
const next = new URLSearchParams(searchParams)
|
||||||
let queryChanged = false
|
let queryChanged = false
|
||||||
if (next.has('job')) {
|
if (next.has('job')) {
|
||||||
|
|
@ -143,15 +136,8 @@ export default function Jobs() {
|
||||||
queryChanged = true
|
queryChanged = true
|
||||||
}
|
}
|
||||||
if (queryChanged) setSearchParams(next, { replace: true })
|
if (queryChanged) setSearchParams(next, { replace: true })
|
||||||
if (st?.openJob || st?.openCreate) navigate('.', { replace: true, state: null })
|
if (st?.openCreate) navigate('.', { replace: true, state: null })
|
||||||
}, [location.state, searchParams, jobs, jobsQuery.isSuccess, navigate, setSearchParams])
|
}, [location.state, searchParams, navigate, setSearchParams])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!viewing) return
|
|
||||||
const fresh = jobs.find((j) => j.id === viewing.id)
|
|
||||||
if (fresh) setViewing(fresh)
|
|
||||||
else if (jobsQuery.isSuccess) setViewing(null)
|
|
||||||
}, [jobs]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
const createJob = useMutation({
|
const createJob = useMutation({
|
||||||
mutationFn: async ({ payload, imageFile }) => {
|
mutationFn: async ({ payload, imageFile }) => {
|
||||||
|
|
@ -210,17 +196,6 @@ export default function Jobs() {
|
||||||
onError: (err) => toast(friendlyAuthError(err, 'Could not update status'), 'error'),
|
onError: (err) => toast(friendlyAuthError(err, 'Could not update status'), 'error'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const deleteJob = useMutation({
|
|
||||||
mutationFn: (id) => jobsApi.remove(id),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
|
||||||
qc.invalidateQueries({ queryKey: qk.requisitions.all() })
|
|
||||||
setViewing(null)
|
|
||||||
toast('Job deleted', 'success')
|
|
||||||
},
|
|
||||||
onError: (err) => toast(friendlyAuthError(err, 'Could not delete the job'), 'error'),
|
|
||||||
})
|
|
||||||
|
|
||||||
const departmentOptions = useMemo(
|
const departmentOptions = useMemo(
|
||||||
() => [...new Set(jobs.map(deptValue).filter(Boolean))].sort(),
|
() => [...new Set(jobs.map(deptValue).filter(Boolean))].sort(),
|
||||||
[jobs],
|
[jobs],
|
||||||
|
|
@ -292,7 +267,7 @@ export default function Jobs() {
|
||||||
key: '_a', label: 'Actions', align: 'right',
|
key: '_a', label: 'Actions', align: 'right',
|
||||||
render: (j) => (
|
render: (j) => (
|
||||||
<div className="row-actions">
|
<div className="row-actions">
|
||||||
<button className="act-btn" data-tip="View" aria-label="View job" onClick={(e) => { e.stopPropagation(); setViewing(j) }}><Icon name="eye" /></button>
|
<button className="act-btn" data-tip="View" aria-label="View job" onClick={(e) => { e.stopPropagation(); openJob(j.id) }}><Icon name="eye" /></button>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<button className="act-btn" data-tip="Edit" aria-label="Edit job" onClick={(e) => { e.stopPropagation(); setEditing(j) }}><Icon name="edit" /></button>
|
<button className="act-btn" data-tip="Edit" aria-label="Edit job" onClick={(e) => { e.stopPropagation(); setEditing(j) }}><Icon name="edit" /></button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -381,32 +356,12 @@ export default function Jobs() {
|
||||||
rows={rows}
|
rows={rows}
|
||||||
pageSize={50}
|
pageSize={50}
|
||||||
empty="No requisitions match these filters."
|
empty="No requisitions match these filters."
|
||||||
onRowClick={(j) => setViewing(j)}
|
onRowClick={(j) => openJob(j.id)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{viewing && (
|
|
||||||
<JobDetail
|
|
||||||
key={viewing.id}
|
|
||||||
job={viewing}
|
|
||||||
initialTab={viewingTab}
|
|
||||||
canEdit={canEdit}
|
|
||||||
canDelete={canDelete}
|
|
||||||
statusBusy={setJobStatus.isPending}
|
|
||||||
deleteBusy={deleteJob.isPending}
|
|
||||||
onClose={() => { setViewing(null); setViewingTab('details') }}
|
|
||||||
onPublish={() => { const id = viewing.id; setViewing(null); navigate('/jobboard', { state: { publishJob: id } }) }}
|
|
||||||
onEdit={() => { setEditing(viewing); setViewing(null) }}
|
|
||||||
onStatus={(s) => setJobStatus.mutate({ id: viewing.id, status: s })}
|
|
||||||
statusLabels={statusLabels}
|
|
||||||
onDelete={() => {
|
|
||||||
if (window.confirm(`Delete “${viewing.title}”?`)) deleteJob.mutate(viewing.id)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{editing && (
|
{editing && (
|
||||||
<EditJobForm
|
<EditJobForm
|
||||||
job={editing}
|
job={editing}
|
||||||
|
|
@ -429,7 +384,7 @@ export default function Jobs() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const SECTION_LABEL = {
|
export const SECTION_LABEL = {
|
||||||
fontSize: 12, color: 'var(--text-3)', fontWeight: 600,
|
fontSize: 12, color: 'var(--text-3)', fontWeight: 600,
|
||||||
textTransform: 'uppercase', marginBottom: 6,
|
textTransform: 'uppercase', marginBottom: 6,
|
||||||
}
|
}
|
||||||
|
|
@ -1059,7 +1014,7 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
export function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
||||||
const managersQuery = useManagerDirectory()
|
const managersQuery = useManagerDirectory()
|
||||||
const recruitersQuery = useRecruiterDirectory()
|
const recruitersQuery = useRecruiterDirectory()
|
||||||
const { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq } = useRequisitionPicker(
|
const { requisitionOptions, requisitionsQuery, setReqQ, setPickedReq } = useRequisitionPicker(
|
||||||
|
|
@ -1260,7 +1215,7 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
||||||
* Hiring-manager + recruiter pointers on one requisition.
|
* Hiring-manager + recruiter pointers on one requisition.
|
||||||
* Writes go through PATCH /jobs/update; job_assignments keeps the interval log.
|
* Writes go through PATCH /jobs/update; job_assignments keeps the interval log.
|
||||||
*/
|
*/
|
||||||
function JobOwnership({ job, canEdit }) {
|
export function JobOwnership({ job, canEdit }) {
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const managersQuery = useManagerDirectory()
|
const managersQuery = useManagerDirectory()
|
||||||
|
|
@ -1333,7 +1288,7 @@ function JobOwnership({ job, canEdit }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function JobHistory({ historyQuery, statusQuery, offersQuery }) {
|
export function JobHistory({ historyQuery, statusQuery, offersQuery }) {
|
||||||
const assignments = historyQuery.data ?? []
|
const assignments = historyQuery.data ?? []
|
||||||
const statusRows = statusQuery.data ?? []
|
const statusRows = statusQuery.data ?? []
|
||||||
const offerRows = offersQuery?.data ?? []
|
const offerRows = offersQuery?.data ?? []
|
||||||
|
|
@ -1459,7 +1414,7 @@ function AssignmentHistoryRow({ row }) {
|
||||||
/* Cover image, when the post has one — fetched with the bearer token into an
|
/* Cover image, when the post has one — fetched with the bearer token into an
|
||||||
object URL, because a bare <img src> cannot carry auth headers. null (404)
|
object URL, because a bare <img src> cannot carry auth headers. null (404)
|
||||||
simply renders nothing. */
|
simply renders nothing. */
|
||||||
function JobCover({ jobId }) {
|
export function JobCover({ jobId }) {
|
||||||
const [url, setUrl] = useState(null)
|
const [url, setUrl] = useState(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true
|
let alive = true
|
||||||
|
|
@ -1476,136 +1431,3 @@ function JobCover({ jobId }) {
|
||||||
if (!url) return null
|
if (!url) return null
|
||||||
return <img src={url} alt="Job cover" className="job-cover" />
|
return <img src={url} alt="Job cover" className="job-cover" />
|
||||||
}
|
}
|
||||||
|
|
||||||
function JobDetail({
|
|
||||||
job: j, initialTab = 'details', canEdit, canDelete, statusBusy, deleteBusy, onClose, onPublish, onEdit, onStatus, onDelete,
|
|
||||||
statusLabels = jobsApi.JOB_STATUSES,
|
|
||||||
}) {
|
|
||||||
const [tab, setTab] = useState(initialTab === 'history' ? 'history' : 'details')
|
|
||||||
const historyQuery = useQuery({
|
|
||||||
queryKey: qk.assignments.job(j.id),
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await assignmentsApi.listJob(j.id, { currentOnly: false })
|
|
||||||
const rows = Array.isArray(res?.data) ? res.data : []
|
|
||||||
return rows.map((r) => assignmentsApi.toAssignmentView(r))
|
|
||||||
},
|
|
||||||
enabled: Boolean(j.id),
|
|
||||||
retry: false,
|
|
||||||
})
|
|
||||||
const statusQuery = useQuery({
|
|
||||||
queryKey: qk.jobs.statusHistory(j.id),
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await jobsApi.listStatusHistory(j.id)
|
|
||||||
return Array.isArray(res?.data) ? res.data : []
|
|
||||||
},
|
|
||||||
enabled: Boolean(j.id),
|
|
||||||
retry: false,
|
|
||||||
})
|
|
||||||
const offersQuery = useQuery({
|
|
||||||
queryKey: qk.offers.list({ jobPostId: j.id, top: 200 }),
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await offersApi.list({ jobPostId: j.id, top: 200 })
|
|
||||||
return Array.isArray(res?.data) ? res.data : []
|
|
||||||
},
|
|
||||||
enabled: Boolean(j.id),
|
|
||||||
retry: false,
|
|
||||||
})
|
|
||||||
const historyCount = (historyQuery.data?.length ?? 0) + (statusQuery.data?.length ?? 0) + (offersQuery.data?.length ?? 0)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
title="Job Details"
|
|
||||||
subtitle={deptValue(j) || undefined}
|
|
||||||
size="modal-lg"
|
|
||||||
onClose={onClose}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
{canDelete && (
|
|
||||||
<button className="btn btn-ghost" style={{ color: 'var(--danger)', marginRight: 'auto' }} onClick={onDelete} disabled={deleteBusy}>
|
|
||||||
<Icon name="trash" /> {deleteBusy ? 'Deleting…' : 'Delete'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button className="btn btn-secondary" onClick={onClose}>Close</button>
|
|
||||||
{canEdit && (
|
|
||||||
<button className="btn btn-secondary" onClick={onEdit}><Icon name="edit" /> Edit</button>
|
|
||||||
)}
|
|
||||||
<button className="btn btn-secondary" onClick={onPublish}><Icon name="send" /> Publish</button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<JobCover jobId={j.id} />
|
|
||||||
|
|
||||||
<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">{[deptValue(j), j.location].filter(Boolean).join(' · ') || '—'}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ marginLeft: 'auto' }}>
|
|
||||||
{canEdit ? (
|
|
||||||
<select
|
|
||||||
className="select"
|
|
||||||
value={j.status}
|
|
||||||
disabled={statusBusy}
|
|
||||||
onChange={(e) => onStatus(e.target.value)}
|
|
||||||
>
|
|
||||||
{statusLabels.map((s) => <option key={s}>{s}</option>)}
|
|
||||||
</select>
|
|
||||||
) : (
|
|
||||||
<Badge>{j.status}</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs
|
|
||||||
value={tab}
|
|
||||||
onChange={setTab}
|
|
||||||
tabs={[
|
|
||||||
{ key: 'details', label: 'Details' },
|
|
||||||
{ key: 'history', label: 'History', count: historyCount || undefined },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{tab === 'details' && (
|
|
||||||
<>
|
|
||||||
<div className="info-grid mb-18">
|
|
||||||
<div className="info-item"><div className="il">Department</div><div className="iv">{deptLabel(j)}</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">{platformLabel(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">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">Requisition</div><div className="iv">{j.requisitionLabel || '—'}</div></div>
|
|
||||||
<div className="info-item"><div className="il">Hiring Manager</div><div className="iv">{j.hiringManager || '—'}</div></div>
|
|
||||||
<div className="info-item"><div className="il">Assigned Recruiters</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>
|
|
||||||
|
|
||||||
<JobOwnership job={j} canEdit={canEdit} />
|
|
||||||
|
|
||||||
{j.description && (
|
|
||||||
<>
|
|
||||||
<div className="divider" />
|
|
||||||
<div className="mb-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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === 'history' && <JobHistory historyQuery={historyQuery} statusQuery={statusQuery} offersQuery={offersQuery} />}
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1422,6 +1422,30 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
||||||
.cand-meta svg { width: 13px; height: 13px; }
|
.cand-meta svg { width: 13px; height: 13px; }
|
||||||
.cand-company { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: var(--text-3); }
|
.cand-company { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: var(--text-3); }
|
||||||
|
|
||||||
|
/* Job profile page (JobProfile.jsx) — hero stats, ranked suggested-candidate
|
||||||
|
cards and the optional-skill highlight shared by cards and the Details tab. */
|
||||||
|
.cand-page-crumb a { color: var(--text-3); }
|
||||||
|
.cand-page-crumb a:hover { color: var(--text); }
|
||||||
|
.job-hero { flex-wrap: wrap; margin-bottom: 18px; }
|
||||||
|
.job-hero-icn { width: 60px; height: 60px; border-radius: 14px; flex: none; display: grid; place-items: center; background: var(--primary-soft); color: var(--primary); }
|
||||||
|
.job-hero-icn svg { width: 26px; height: 26px; }
|
||||||
|
.job-hero-id { min-width: 0; }
|
||||||
|
.job-hero .ph-name { overflow-wrap: anywhere; }
|
||||||
|
.job-hero-actions { margin-left: auto; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||||
|
.hero-stat { text-align: center; min-width: 60px; }
|
||||||
|
.hero-stat .v { font-family: var(--font-display); font-size: 22px; font-weight: 600; line-height: 1.1; }
|
||||||
|
.hero-stat .l { font-size: 11px; color: var(--text-3); margin-top: 2px; }
|
||||||
|
.cand-sub { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 16px 0 12px; font-size: 13px; color: var(--text-2); }
|
||||||
|
.auto-tag { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .3px; padding: 2px 7px; border-radius: 6px; background: var(--primary-soft); color: var(--primary); }
|
||||||
|
.select.active-filter { border-color: var(--primary); color: var(--primary); font-weight: 600; }
|
||||||
|
.cand-card .card-body { position: relative; }
|
||||||
|
.cand-rank { position: absolute; top: 12px; left: 12px; width: 20px; height: 20px; border-radius: 50%; display: grid; place-items: center; background: var(--bg-sunken); color: var(--text-2); font-size: 11px; font-weight: 700; }
|
||||||
|
.cand-card.ranked .cand-head { padding-left: 22px; }
|
||||||
|
.cand-chip.ok { background: var(--success-soft); color: var(--success); }
|
||||||
|
.cand-chip.opt { background: var(--purple-soft); color: var(--purple); }
|
||||||
|
.tag.tag-optional { display: inline-flex; align-items: center; gap: 4px; background: var(--purple-soft); color: var(--purple); }
|
||||||
|
.tag.tag-optional svg { width: 11px; height: 11px; }
|
||||||
|
|
||||||
/* Find Talent toolbar (Talent.jsx): the job picker takes the slack, the
|
/* Find Talent toolbar (Talent.jsx): the job picker takes the slack, the
|
||||||
location controls hold a readable fixed width. The widths live here, not
|
location controls hold a readable fixed width. The widths live here, not
|
||||||
inline, so the ≤640 block can stack everything full-width. */
|
inline, so the ≤640 block can stack everything full-width. */
|
||||||
|
|
@ -2278,6 +2302,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.g-kpi-7 { grid-template-columns: 1fr; }
|
.g-kpi-7 { grid-template-columns: 1fr; }
|
||||||
.cand-page-actions { width: 100%; }
|
.cand-page-actions { width: 100%; }
|
||||||
|
.job-hero-actions { width: 100%; margin-left: 0; }
|
||||||
.cand-page-actions .btn { flex: 1 1 auto; justify-content: center; }
|
.cand-page-actions .btn { flex: 1 1 auto; justify-content: center; }
|
||||||
.hf-sign-grid { grid-template-columns: 1fr; }
|
.hf-sign-grid { grid-template-columns: 1fr; }
|
||||||
.hf-summary { grid-template-columns: repeat(2, 1fr); }
|
.hf-summary { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue