candidates are arrigving

Edit_Job_Profile
ahmed.mujtaba 2026-09-14 21:02:15 +05:00
parent cf605ee3d6
commit 91bc1089b2
17 changed files with 1142 additions and 204 deletions

View File

@ -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."""

View File

@ -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),

View File

@ -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)."""

View File

@ -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,
}

View File

@ -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,
}

View File

@ -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,

View File

@ -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},
}

View File

@ -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)

View File

@ -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",

View File

@ -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 />} />

View File

@ -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(

View File

@ -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 } })

View File

@ -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: {

View File

@ -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={{

View File

@ -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="Couldnt 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>
)
}

View File

@ -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>
)
}

View File

@ -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); }