From 91bc1089b20024d0992fc3198180b2787f4657c3 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Mon, 14 Sep 2026 21:02:15 +0500 Subject: [PATCH] candidates are arrigving --- backend/inbox/models.py | 33 ++ backend/job/app.py | 18 + backend/job/candidate/models.py | 22 + backend/job/job_post/plugins.py | 54 +++ backend/job/job_post/serializers.py | 27 ++ backend/job/job_post/views.py | 54 ++- backend/tests/test_job_profile_plugins.py | 75 ++++ frontend/job-profile.test.mjs | 270 ++++++++++++ frontend/package.json | 3 +- frontend/src/App.jsx | 9 + frontend/src/__smoke__/entry.jsx | 14 +- frontend/src/api/jobs.js | 38 ++ frontend/src/lib/queryKeys.js | 1 + frontend/src/screens/JobCandidates.jsx | 4 +- frontend/src/screens/JobProfile.jsx | 485 ++++++++++++++++++++++ frontend/src/screens/Jobs.jsx | 214 +--------- frontend/src/styles/styles.css | 25 ++ 17 files changed, 1142 insertions(+), 204 deletions(-) create mode 100644 backend/tests/test_job_profile_plugins.py create mode 100644 frontend/job-profile.test.mjs create mode 100644 frontend/src/screens/JobProfile.jsx diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 871fd89..2559f2e 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -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.""" diff --git a/backend/job/app.py b/backend/job/app.py index 0f4eec9..d4313ae 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -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), diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index e1d56de..48ad300 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -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).""" diff --git a/backend/job/job_post/plugins.py b/backend/job/job_post/plugins.py index 8604722..e28119e 100644 --- a/backend/job/job_post/plugins.py +++ b/backend/job/job_post/plugins.py @@ -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, + } diff --git a/backend/job/job_post/serializers.py b/backend/job/job_post/serializers.py index 1adb195..3ff97eb 100644 --- a/backend/job/job_post/serializers.py +++ b/backend/job/job_post/serializers.py @@ -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, + } diff --git a/backend/job/job_post/views.py b/backend/job/job_post/views.py index 3f4f408..a0c4208 100644 --- a/backend/job/job_post/views.py +++ b/backend/job/job_post/views.py @@ -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, diff --git a/backend/tests/test_job_profile_plugins.py b/backend/tests/test_job_profile_plugins.py new file mode 100644 index 0000000..4ead684 --- /dev/null +++ b/backend/tests/test_job_profile_plugins.py @@ -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}, + } diff --git a/frontend/job-profile.test.mjs b/frontend/job-profile.test.mjs new file mode 100644 index 0000000..9602c37 --- /dev/null +++ b/frontend/job-profile.test.mjs @@ -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('
', { + 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) diff --git a/frontend/package.json b/frontend/package.json index f06b864..a927402 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 9c52297..cd38957 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() { } /> + + + + } + /> } /> diff --git a/frontend/src/__smoke__/entry.jsx b/frontend/src/__smoke__/entry.jsx index e6287d4..e7e9f2f 100644 --- a/frontend/src/__smoke__/entry.jsx +++ b/frontend/src/__smoke__/entry.jsx @@ -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( diff --git a/frontend/src/api/jobs.js b/frontend/src/api/jobs.js index 1278337..666e4ae 100644 --- a/frontend/src/api/jobs.js +++ b/frontend/src/api/jobs.js @@ -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 } }) diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index 32463ba..9feb4f4 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -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: { diff --git a/frontend/src/screens/JobCandidates.jsx b/frontend/src/screens/JobCandidates.jsx index b3ae014..de6cc95 100644 --- a/frontend/src/screens/JobCandidates.jsx +++ b/frontend/src/screens/JobCandidates.jsx @@ -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 (
{ + 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 ( +
+
+ +
Jobs
+
+
+
+ {profileQuery.isPending ? ( + + ) : ( + + {friendlyAuthError(profileQuery.error, 'The job may have been deleted or is outside your scope.')} + + )} +
+
+
+ ) + } + + const roleLine = [deptValue(job), job.location, job.type].filter(Boolean).join(' · ') + + return ( +
+
+ +
+ Jobs {job.title} +
+
+ +
+
+
+ +
+
{job.title}
+
{roleLine || '—'}
+
+ {job.status} + {job.vacancies ?? 0} {job.vacancies === 1 ? 'Vacancy' : 'Vacancies'} + {job.applicantCount} {job.applicantCount === 1 ? 'Applicant' : 'Applicants'} +
+
+
+
+
{profile.suggested ?? 0}
+
Suggested
+
+
+
{profile.top_match ?? 0}
+
Top Match
+
+ {canEdit ? ( + + ) : null} + {canDelete && ( + + )} + {canEdit && ( + + )} + +
+
+ + + +
+ {tab === 'details' && } + {tab === 'suggested' && } + {tab === 'history' && } +
+
+
+ + {editing && ( + setEditing(false)} + onSubmit={(body) => updateJob.mutate(body)} + /> + )} +
+ ) +} + +function JobDetailsTab({ job: j, canEdit }) { + const created = [j.created ? fmtShort(j.created) : null, j.createdByName ? `by ${j.createdByName}` : null] + .filter(Boolean) + .join(' · ') + return ( + <> + + +
+
Department
{deptLabel(j)}
+
Location
{j.location || '—'}
+
Employment Type
{j.type || '—'}
+
Platform
{platformLabel(j.platform) || '—'}
+
Vacancies
{j.vacancies ?? '—'}
+
Experience
{j.experience || '—'}
+
Created
{created || '—'}
+
Requisition
{j.requisitionLabel || '—'}
+
Hiring Manager
{j.hiringManager || '—'}
+
Assigned Recruiters
{j.recruiter || '—'}
+ {j.closedAt && ( +
Closed at
{fmtShort(j.closedAt)}
+ )} +
+ + + + {j.description && ( + <> +
+
+
Description
+

{j.description}

+
+ + )} + {j.skills.length > 0 && ( +
+
Required Skills
+
{j.skills.map((s) => {s})}
+
+ )} + {j.optionalSkills.length > 0 && ( +
+
Optional Skills
+
+ {j.optionalSkills.map((s) => ( + {s} + ))} +
+
+ )} + + ) +} + +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 ( + <> +
+ {profile.suggested} candidate{profile.suggested === 1 ? '' : 's'} suggested · {scored} scored · vs {job.title} + {sort === 'score' && Sorted: Best → Worst} +
+ +
+
+ + setQ(e.target.value)} placeholder="Search name, skill, company…" /> +
+ +
+
+ + +
+
+ + {list.length === 0 ? ( + candidates.length === 0 ? ( + + Candidates appear here once their CVs are ATS-scored against this job. + + ) : ( + Try a different search or band. + ) + ) : ( +
+ {list.map((c, i) => ( + open(c)} /> + ))} +
+ )} + + {viewing && ( + 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 ( +
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpen() } }} + > +
+ {rank && {rank}} +
+ +
+
{displayName(c.name)}
+
{roleLine || c.email || '—'}
+
+ {c.score != null && } +
+ +
+ {matched.map((s) => {s})} + {missing.map((s) => {s})} + {optional.map((s) => ( + {s} + ))} + {more > 0 && +{more} more} +
+ +

{c.summary || No summary}

+ +
+ {foot || '—'} + {c.sourceLabel} +
+
+
+ ) +} diff --git a/frontend/src/screens/Jobs.jsx b/frontend/src/screens/Jobs.jsx index 03dae78..07b4304 100644 --- a/frontend/src/screens/Jobs.jsx +++ b/frontend/src/screens/Jobs.jsx @@ -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) => (
- + {canEdit && ( )} @@ -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)} /> )}
- {viewing && ( - { 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 && ( 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 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 ( - - {canDelete && ( - - )} - - {canEdit && ( - - )} - - - } - > - - -
- - - -
-
{j.title}
-
{[deptValue(j), j.location].filter(Boolean).join(' · ') || '—'}
-
-
- {canEdit ? ( - - ) : ( - {j.status} - )} -
-
- - - - {tab === 'details' && ( - <> -
-
Department
{deptLabel(j)}
-
Location
{j.location || '—'}
-
Employment Type
{j.type || '—'}
-
Platform
{platformLabel(j.platform) || '—'}
-
Vacancies
{j.vacancies ?? '—'}
-
Experience
{j.experience || '—'}
-
Created
{j.created ? fmtShort(j.created) : '—'}
-
Created by
{j.createdByName || '—'}
-
Requisition
{j.requisitionLabel || '—'}
-
Hiring Manager
{j.hiringManager || '—'}
-
Assigned Recruiters
{j.recruiter || '—'}
-
Closed at
{j.closedAt ? fmtShort(j.closedAt) : '—'}
-
- - - - {j.description && ( - <> -
-
-
Description
-

{j.description}

-
- - )} - {!!(j.skills && j.skills.length) && ( -
-
Required Skills
-
{j.skills.map((s) => {s})}
-
- )} - - )} - - {tab === 'history' && } - - ) -} diff --git a/frontend/src/styles/styles.css b/frontend/src/styles/styles.css index ff48d4a..d510f47 100644 --- a/frontend/src/styles/styles.css +++ b/frontend/src/styles/styles.css @@ -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); }