candidates are arrigving
parent
cf605ee3d6
commit
91bc1089b2
|
|
@ -2152,6 +2152,39 @@ class AtsResults(SQLModel, table=True):
|
|||
grouped.setdefault(row.form_data_id, []).append(row)
|
||||
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
|
||||
async def job_ids_by_emails(cls, session: AsyncSession, emails) -> dict:
|
||||
"""{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))
|
||||
|
||||
|
||||
@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")
|
||||
async def export_jobs(
|
||||
search: str | None = Query(None),
|
||||
|
|
|
|||
|
|
@ -1143,6 +1143,28 @@ class Candidates(SQLModel, table=True):
|
|||
)
|
||||
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
|
||||
async def list_by_emails(cls, session: AsyncSession, emails):
|
||||
"""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"),
|
||||
})
|
||||
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,
|
||||
"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.ext.asyncio import AsyncSession
|
||||
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.candidate.models import Candidates
|
||||
from job.job_post.enums import RequisitionStatus
|
||||
from job.job_post.models import JobPostImages,JobPostStatusHistory,JobPosts,SocialPlatform
|
||||
from role.models import EnumRoles
|
||||
|
|
@ -23,11 +24,14 @@ from job.job_post.plugins import (
|
|||
list_buffer_channels,
|
||||
local_status,
|
||||
normalize_platform,
|
||||
optional_skill_hits,
|
||||
parse_buffer_datetime,
|
||||
render_job_post,
|
||||
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()
|
||||
logger=logging.getLogger("job.job_post")
|
||||
|
|
@ -413,6 +417,52 @@ class JobPost:
|
|||
for r in rows
|
||||
],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):
|
||||
names=await Users.names_by_ids(
|
||||
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:cvbank": "node cvbank.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": {
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ const SCREENS = {
|
|||
|
||||
// Detail pages live outside the ROUTES table (no sidebar entry, parameterized path).
|
||||
const CandidatePage = lazy(() => import('./screens/CandidatePage'))
|
||||
const JobProfile = lazy(() => import('./screens/JobProfile'))
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
|
|
@ -95,6 +96,14 @@ export default function App() {
|
|||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/job/:jobId"
|
||||
element={
|
||||
<RequireAuth permission="jobs.view">
|
||||
<JobProfile />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import Notifications from '../screens/Notifications'
|
|||
import Rbac from '../screens/Rbac'
|
||||
import Settings from '../screens/Settings'
|
||||
import Help from '../screens/Help'
|
||||
import JobProfile from '../screens/JobProfile'
|
||||
|
||||
const SCREENS = {
|
||||
dashboard: Dashboard, inbox: Inbox, matching: Matching, jobs: Jobs, candidates: Candidates,
|
||||
|
|
@ -68,6 +69,11 @@ const PAGES = {
|
|||
'/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 = [
|
||||
...Object.keys(PAGES),
|
||||
...TABLE.map((r) => `/${r.path}`),
|
||||
|
|
@ -129,15 +135,17 @@ export async function mountRoute(path, container) {
|
|||
function routeTree(path) {
|
||||
const h = React.createElement
|
||||
const isAuth = path.startsWith('/auth/')
|
||||
const def = TABLE.find((r) => `/${r.path}` === path)
|
||||
const Screen = isAuth ? PAGES[path] : SCREENS[def.path]
|
||||
const detail = DETAIL_PAGES.find((d) => path.startsWith(d.prefix))
|
||||
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
|
||||
? h(Route, { path, element: h(Screen) })
|
||||
: h(
|
||||
Route,
|
||||
{ element: h(RequireAuth, null, h(AppLayout)) },
|
||||
h(Route, { path, element: h(Screen) }),
|
||||
h(Route, { path: routePath, element: h(Screen) }),
|
||||
)
|
||||
|
||||
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. */
|
||||
export function listStatusHistory(jobPostId) {
|
||||
return request('/jobs/status-history/fetch', { params: { job_post_id: jobPostId } })
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ export const qk = {
|
|||
list: (p = {}) => ['jobs', 'list', p],
|
||||
requisitionStatuses: () => ['jobs', 'requisition-statuses'],
|
||||
statusHistory: (id) => ['jobs', 'status-history', id],
|
||||
profile: (id) => ['jobs', 'profile', id],
|
||||
stats: (p = {}) => ['jobs', 'stats', p],
|
||||
},
|
||||
talent: {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ const SOURCE_LABEL = { upload: 'Upload', inbox: 'Email' }
|
|||
const PAGE_SIZE_MAX = 100
|
||||
|
||||
/** 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
|
||||
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. */
|
||||
function MiniRing({ score, size = 46 }) {
|
||||
export function MiniRing({ score, size = 46 }) {
|
||||
return (
|
||||
<div
|
||||
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 Modal from '../ui/Modal'
|
||||
import PageHeader from '../ui/PageHeader'
|
||||
import { Tabs } from '../ui/Tabs'
|
||||
import { Badge, EmptyState, FieldError, Icon, SkeletonRows } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
|
|
@ -25,11 +24,9 @@ import { friendlyAuthError } from '../lib/errors'
|
|||
import { platformLabel } from '../lib/platforms'
|
||||
import * as jobsApi from '../api/jobs'
|
||||
import * as jobPostsApi from '../api/jobPosts'
|
||||
import * as assignmentsApi from '../api/assignments'
|
||||
import * as tasksApi from '../api/tasks'
|
||||
import * as usersApi from '../api/users'
|
||||
import * as requisitionsApi from '../api/requisitions'
|
||||
import * as offersApi from '../api/offers'
|
||||
import { fmtDate, fmtDateTime, fmtShort, toDate } from '../lib/format'
|
||||
import { empTypes } from '../data/seed'
|
||||
|
||||
|
|
@ -42,11 +39,11 @@ async function fetchJobs() {
|
|||
return rows.map(jobsApi.toJobView)
|
||||
}
|
||||
|
||||
function deptValue(j) {
|
||||
export function deptValue(j) {
|
||||
return String(j.requisitionDepartment || j.department || '').trim()
|
||||
}
|
||||
|
||||
function deptLabel(j) {
|
||||
export function deptLabel(j) {
|
||||
return deptValue(j) || '—'
|
||||
}
|
||||
|
||||
|
|
@ -107,31 +104,27 @@ export default function Jobs() {
|
|||
const [status, setStatus] = useState('')
|
||||
const [type, setType] = useState('')
|
||||
|
||||
const [viewing, setViewing] = useState(null)
|
||||
const [viewingTab, setViewingTab] = useState('details')
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
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
|
||||
// manager portal. Consume once and replace history: jobs refetch after a
|
||||
// status PATCH used to replay openCreate and pop the create modal over the
|
||||
// detail view. `/jobs?job=` / `?tab=history` is the notification target.
|
||||
// status PATCH used to replay openCreate and pop the create modal. A job
|
||||
// target (`/jobs?job=` / `?tab=history`, or state.openJob) now redirects to
|
||||
// the full job profile page at /job/:jobId.
|
||||
useEffect(() => {
|
||||
const st = location.state
|
||||
const jobId = searchParams.get('job') || st?.openJob
|
||||
const tab = String(searchParams.get('tab') || '').toLowerCase()
|
||||
if (!st?.openCreate && !jobId) return
|
||||
if (st?.openCreate) setCreating(true)
|
||||
if (jobId) {
|
||||
const job = jobs.find((j) => j.id === jobId)
|
||||
if (job) {
|
||||
setViewing(job)
|
||||
setViewingTab(tab === 'history' ? 'history' : 'details')
|
||||
} else if (!jobsQuery.isSuccess) return
|
||||
navigate(`/job/${jobId}${tab === 'history' ? '?tab=history' : ''}`, { replace: true })
|
||||
return
|
||||
}
|
||||
if (st?.openCreate) setCreating(true)
|
||||
const next = new URLSearchParams(searchParams)
|
||||
let queryChanged = false
|
||||
if (next.has('job')) {
|
||||
|
|
@ -143,15 +136,8 @@ export default function Jobs() {
|
|||
queryChanged = true
|
||||
}
|
||||
if (queryChanged) setSearchParams(next, { replace: true })
|
||||
if (st?.openJob || st?.openCreate) navigate('.', { replace: true, state: null })
|
||||
}, [location.state, searchParams, jobs, jobsQuery.isSuccess, 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
|
||||
if (st?.openCreate) navigate('.', { replace: true, state: null })
|
||||
}, [location.state, searchParams, navigate, setSearchParams])
|
||||
|
||||
const createJob = useMutation({
|
||||
mutationFn: async ({ payload, imageFile }) => {
|
||||
|
|
@ -210,17 +196,6 @@ export default function Jobs() {
|
|||
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(
|
||||
() => [...new Set(jobs.map(deptValue).filter(Boolean))].sort(),
|
||||
[jobs],
|
||||
|
|
@ -292,7 +267,7 @@ export default function Jobs() {
|
|||
key: '_a', label: 'Actions', align: 'right',
|
||||
render: (j) => (
|
||||
<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 && (
|
||||
<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}
|
||||
pageSize={50}
|
||||
empty="No requisitions match these filters."
|
||||
onRowClick={(j) => setViewing(j)}
|
||||
onRowClick={(j) => openJob(j.id)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</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 && (
|
||||
<EditJobForm
|
||||
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,
|
||||
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 recruitersQuery = useRecruiterDirectory()
|
||||
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.
|
||||
* 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 qc = useQueryClient()
|
||||
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 statusRows = statusQuery.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
|
||||
object URL, because a bare <img src> cannot carry auth headers. null (404)
|
||||
simply renders nothing. */
|
||||
function JobCover({ jobId }) {
|
||||
export function JobCover({ jobId }) {
|
||||
const [url, setUrl] = useState(null)
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
|
|
@ -1476,136 +1431,3 @@ function JobCover({ jobId }) {
|
|||
if (!url) return null
|
||||
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-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
|
||||
location controls hold a readable fixed width. The widths live here, not
|
||||
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) {
|
||||
.g-kpi-7 { grid-template-columns: 1fr; }
|
||||
.cand-page-actions { width: 100%; }
|
||||
.job-hero-actions { width: 100%; margin-left: 0; }
|
||||
.cand-page-actions .btn { flex: 1 1 auto; justify-content: center; }
|
||||
.hf-sign-grid { grid-template-columns: 1fr; }
|
||||
.hf-summary { grid-template-columns: repeat(2, 1fr); }
|
||||
|
|
|
|||
Loading…
Reference in New Issue