diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 758d0c5..8fe7f80 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -17,6 +17,7 @@ from sqlalchemy.orm import selectinload from sqlmodel import Field, Relationship, SQLModel, select, true from job.candidate.models import Activity, Feedback, Interviews +from linkedin_utils import primary_slug_from_text from users.models import Users from users.plugins import hash_password @@ -332,6 +333,10 @@ class Inbox_Messages(SQLModel, table=True): file_name: str | None = Field(default=None) file_path: str | None = Field(default=None) resume_text: str | None = Field(default=None) + # Lowercase /in/ extracted from resume_text ("" = scanned, none + # found; NULL = not yet scanned — see linkedin_utils). Lets Find Talent + # flag sourced profiles that already applied. + linkedin_slug: str | None = Field(default=None, index=True) experience: str | None = Field(default=None) suggested_job_post_ids: list[str] | None = Field(default=None, sa_column=Column(JSONB)) assigned_job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id", index=True) @@ -407,6 +412,7 @@ class Inbox_Messages(SQLModel, table=True): return None if resume_text is not None: row.resume_text = resume_text + row.linkedin_slug = primary_slug_from_text(resume_text) if candidate_phone_number is not None: row.candidate_phone_number = candidate_phone_number if candidate_education is not None: diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index 6ec9676..0128e5d 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -9,6 +9,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from sqlmodel import Field, Relationship, SQLModel, select +from linkedin_utils import primary_slug_from_text + if TYPE_CHECKING: from inbox.models import Inbox from users.models import Users @@ -32,6 +34,10 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): candidate_phone: str = Field(default="") job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id") full_text: str = Field(default="") + # Lowercase /in/ from full_text ("" = scanned, none found; NULL = + # not yet scanned — see linkedin_utils). Same contract as + # inbox_messages.linkedin_slug; Find Talent matches on it. + linkedin_slug: str | None = Field(default=None, index=True) current_company: str = Field(default="") # Candidate's role at that company (e.g. "Senior Merchandiser"). Distinct # from job_posts.title — that is the role they applied to, not their own. @@ -195,6 +201,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): candidate_phone=(fields.get("candidate_phone") or "").strip(), job_post_id=cls._as_uuid(fields.get("job_post_id")), full_text=fields.get("full_text") or "", + linkedin_slug=primary_slug_from_text(fields.get("full_text") or ""), current_company=(fields.get("current_company") or "").strip(), current_position=(fields.get("current_position") or "").strip(), apply_via="manual_upload", diff --git a/backend/linkedin_utils.py b/backend/linkedin_utils.py new file mode 100644 index 0000000..1a04465 --- /dev/null +++ b/backend/linkedin_utils.py @@ -0,0 +1,58 @@ +"""LinkedIn profile-link extraction and normalization. + +One shared vocabulary for "the same person" across the two places a LinkedIn +identity appears: sourced talent profiles (a normalized URL from the Apify +actor) and CV text (a link the candidate wrote, often mangled by PDF +extraction). The match key is the lowercase public slug from /in/. + +Top-level module on purpose: talent/, inbox/ and job/ all need it, and any +package-local home would invite an import cycle. +""" + +import re +from urllib.parse import unquote + +# CV text arrives from PDF extraction: URLs may carry percent-escapes, no +# scheme ("linkedin.com/in/jane-doe"), or trailing sentence punctuation glued +# on by layout. /pub/ is the legacy public-profile path some older CVs still +# carry. +_SLUG_RE = re.compile(r"linkedin\.com/(?:in|pub)/([A-Za-z0-9\-_.%]+)", re.IGNORECASE) + +# Sentinel stored on application rows: NULL means "never scanned", the empty +# string means "scanned, no link found". The distinction is what lets the lazy +# backfill converge instead of rescanning every CV on every request. +NO_SLUG = "" + + +def normalize_slug(raw) -> str | None: + """Lowercase, percent-decoded, stripped of trailing sentence punctuation.""" + if not raw: + return None + slug = unquote(str(raw)).strip().lower().rstrip(".") + return slug or None + + +def slug_from_url(url) -> str | None: + """Slug from an already-normalized profile URL (talent_profiles.linkedin_url).""" + if not url: + return None + match = _SLUG_RE.search(str(url)) + return normalize_slug(match.group(1)) if match else None + + +def slugs_from_text(text) -> list[str]: + """Every distinct slug mentioned in a CV, in order of first appearance.""" + if not text: + return [] + found: list[str] = [] + for match in _SLUG_RE.finditer(text): + slug = normalize_slug(match.group(1)) + if slug and slug not in found: + found.append(slug) + return found + + +def primary_slug_from_text(text) -> str: + """The slug to persist on an application row; NO_SLUG when the CV has none.""" + slugs = slugs_from_text(text) + return slugs[0] if slugs else NO_SLUG diff --git a/backend/talent/matching.py b/backend/talent/matching.py new file mode 100644 index 0000000..c58c712 --- /dev/null +++ b/backend/talent/matching.py @@ -0,0 +1,124 @@ +"""Flags sourced LinkedIn profiles that are already applicants in the ATS. + +A sourced profile and a CV describe the same person when they carry the same +/in/. The slug is persisted on application rows as the CV is processed +(inbox_messages.linkedin_slug, manual_upload_candidate.linkedin_slug); rows +that predate those columns are backfilled lazily here in bounded batches, so +the matching converges over normal use without a migration script. + +The annotation rides on the profile list/detail payloads as `already_applied`: + + {"source": "inbox"|"manual", "status", "job_post_id", "candidate", + "applied_at", "same_job": bool, "applications": N} # or null + +When the person applied to several jobs, the application for the profile's own +job wins the summary slot and `same_job` says which case the UI is looking at. +""" + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from inbox.models import Inbox_Messages +from job.candidate.models import Manual_UPLOAD_CANDIDATE +from linkedin_utils import primary_slug_from_text, slug_from_url + +BACKFILL_BATCH = 200 + + +async def _backfill_slugs(session: AsyncSession) -> None: + """Scan a bounded batch of never-scanned CVs (linkedin_slug IS NULL).""" + changed = False + inbox_q = ( + select(Inbox_Messages) + .where( + Inbox_Messages.linkedin_slug.is_(None), + Inbox_Messages.resume_text.is_not(None), + Inbox_Messages.resume_text != "", + ) + .limit(BACKFILL_BATCH) + ) + for row in (await session.execute(inbox_q)).scalars().all(): + row.linkedin_slug = primary_slug_from_text(row.resume_text) + session.add(row) + changed = True + + manual_q = ( + select(Manual_UPLOAD_CANDIDATE) + .where( + Manual_UPLOAD_CANDIDATE.linkedin_slug.is_(None), + Manual_UPLOAD_CANDIDATE.full_text != "", + ) + .limit(BACKFILL_BATCH) + ) + for row in (await session.execute(manual_q)).scalars().all(): + row.linkedin_slug = primary_slug_from_text(row.full_text) + session.add(row) + changed = True + + if changed: + await session.commit() + + +async def annotate_applications(session: AsyncSession, profiles: list[dict]) -> list[dict]: + """Attach `already_applied` to serialized profile dicts, matched by slug.""" + for profile in profiles: + profile["already_applied"] = None + + slug_map: dict[str, list[dict]] = {} + for profile in profiles: + slug = slug_from_url(profile.get("linkedin_url")) + if slug: + slug_map.setdefault(slug, []).append(profile) + if not slug_map: + return profiles + + await _backfill_slugs(session) + + matches: dict[str, list[dict]] = {} + + inbox_q = select( + Inbox_Messages.linkedin_slug, + Inbox_Messages.application_status, + Inbox_Messages.assigned_job_post_id, + Inbox_Messages.message_from, + Inbox_Messages.created_at, + ).where(Inbox_Messages.linkedin_slug.in_(list(slug_map))) + for slug, status, job_id, sender, created in (await session.execute(inbox_q)).all(): + matches.setdefault(slug, []).append({ + "source": "inbox", + "status": (getattr(status, "value", status) or None), + "job_post_id": str(job_id) if job_id else None, + "candidate": sender or None, + "applied_at": created.isoformat() if created else None, + }) + + manual_q = select( + Manual_UPLOAD_CANDIDATE.linkedin_slug, + Manual_UPLOAD_CANDIDATE.status, + Manual_UPLOAD_CANDIDATE.job_post_id, + Manual_UPLOAD_CANDIDATE.candidate_name, + Manual_UPLOAD_CANDIDATE.created_at, + ).where(Manual_UPLOAD_CANDIDATE.linkedin_slug.in_(list(slug_map))) + for slug, status, job_id, name, created in (await session.execute(manual_q)).all(): + matches.setdefault(slug, []).append({ + "source": "manual", + "status": (status or "").strip() or None, + "job_post_id": str(job_id) if job_id else None, + "candidate": (name or "").strip() or None, + "applied_at": created.isoformat() if created else None, + }) + + for slug, slug_profiles in slug_map.items(): + found = matches.get(slug) + if not found: + continue + for profile in slug_profiles: + job_id = profile.get("job_post_id") + same = [m for m in found if m["job_post_id"] and m["job_post_id"] == job_id] + best = same[0] if same else found[0] + profile["already_applied"] = { + **best, + "same_job": bool(same), + "applications": len(found), + } + return profiles diff --git a/backend/talent/views.py b/backend/talent/views.py index cb65845..c17d30b 100644 --- a/backend/talent/views.py +++ b/backend/talent/views.py @@ -4,6 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from job.job_post.models import JobPosts from talent import plugins +from talent.matching import annotate_applications from talent.models import TalentProfiles, TalentRuns from talent.serializers import ( serialize_talent_profile, @@ -205,13 +206,17 @@ class Talent: rows, total = await TalentProfiles.fetch_profiles( self.session, job_post_id=job_post_id, search=search, top=top, skip=skip ) - return [serialize_talent_profile(r) for r in rows], total + profiles = [serialize_talent_profile(r) for r in rows] + profiles = await annotate_applications(self.session, profiles) + return profiles, total async def get_profile(self, profile_id): row = await TalentProfiles.get_profile_by_id(self.session, profile_id) if not row: raise HTTPException(status_code=404, detail="Talent profile not found") - return serialize_talent_profile_detail(row) + data = serialize_talent_profile_detail(row) + await annotate_applications(self.session, [data]) + return data async def delete_profile(self, profile_id): row = await TalentProfiles.soft_delete_profile(self.session, profile_id) diff --git a/backend/tests/test_linkedin_matching.py b/backend/tests/test_linkedin_matching.py new file mode 100644 index 0000000..99fefe7 --- /dev/null +++ b/backend/tests/test_linkedin_matching.py @@ -0,0 +1,62 @@ +"""linkedin_utils: the slug vocabulary Find Talent matches applicants on. + +Pure functions only — the DB annotation path in talent/matching.py reuses +exactly these, so the extraction cases here are the matching cases there. +""" + +from __future__ import annotations + +from linkedin_utils import ( + NO_SLUG, + primary_slug_from_text, + slug_from_url, + slugs_from_text, +) + + +# ---------------------------------------------------------------- from URLs + +def test_slug_from_normalized_profile_url(): + assert slug_from_url("https://www.linkedin.com/in/jane-doe-123") == "jane-doe-123" + assert slug_from_url("https://linkedin.com/in/JaneDoe") == "janedoe" + + +def test_slug_ignores_subpaths_and_non_linkedin(): + assert slug_from_url("https://www.linkedin.com/in/jane-doe/details/experience") == "jane-doe" + assert slug_from_url("https://github.com/in/jane-doe") is None + assert slug_from_url(None) is None + + +# ---------------------------------------------------------------- from CV text + +def test_extracts_bare_and_schemed_links(): + text = "Contact: linkedin.com/in/ali-raza-8a1b2c | ali@example.com" + assert slugs_from_text(text) == ["ali-raza-8a1b2c"] + text2 = "Profile: https://www.linkedin.com/in/Ali-Raza-8A1B2C/" + assert slugs_from_text(text2) == ["ali-raza-8a1b2c"] + + +def test_percent_encoding_and_trailing_punctuation(): + # PDF extraction often percent-encodes hyphens and glues sentence dots on. + assert slugs_from_text("see linkedin.com/in/jane%2Ddoe.") == ["jane-doe"] + + +def test_legacy_pub_path_and_dedup(): + text = "linkedin.com/pub/jane-doe and again https://linkedin.com/in/jane-doe" + assert slugs_from_text(text) == ["jane-doe"] + + +def test_primary_slug_sentinel_contract(): + # "" (scanned, none found) must be distinct from None (never scanned): + # the lazy backfill filters on IS NULL and would otherwise rescan forever. + assert primary_slug_from_text("no links here") == NO_SLUG + assert primary_slug_from_text("") == NO_SLUG + assert primary_slug_from_text("linkedin.com/in/x-y") == "x-y" + + +def test_cv_and_profile_url_agree_on_the_key(): + # The whole feature: a CV mention and the actor's normalized URL must + # produce the same key for the same person. + cv = "Portfolio — www.LinkedIn.com/in/Muhammad%2DTalha%2DAhmed." + profile_url = "https://www.linkedin.com/in/muhammad-talha-ahmed" + assert primary_slug_from_text(cv) == slug_from_url(profile_url) diff --git a/frontend/src/api/talent.js b/frontend/src/api/talent.js index c280ac5..6081d7f 100644 --- a/frontend/src/api/talent.js +++ b/frontend/src/api/talent.js @@ -90,6 +90,9 @@ export function toProfileView(row) { skills: Array.isArray(row.skills) ? row.skills : [], matchScore: row.match_score ?? null, lastSeenAt: row.last_seen_at ? new Date(row.last_seen_at) : null, + // Non-null when a CV in the ATS carries this profile's /in/ link: + // { source, status, job_post_id, candidate, applied_at, same_job, applications } + alreadyApplied: row.already_applied ?? null, } } diff --git a/frontend/src/app/routes.js b/frontend/src/app/routes.js index da47175..e2e81e0 100644 --- a/frontend/src/app/routes.js +++ b/frontend/src/app/routes.js @@ -28,7 +28,7 @@ export const ROUTES = [ { path: 'import', title: 'CV Import', icon: 'upload', group: 'Recruiting', permission: 'candidates.create' }, { path: 'jobboard', title: 'Job Board', icon: 'layers', group: 'Recruiting', permission: 'job_board.view' }, { path: 'recruiterhub', title: 'Recruiter Hub', icon: 'check-circle', group: 'Recruiting', permission: 'analytics.view' }, - { path: 'talent', title: 'Talent', icon: 'user-plus', group: 'Recruiting', permission: 'talent.view' }, + { path: 'talent', title: 'Find Talent', icon: 'user-plus', group: 'Recruiting', permission: 'talent.view' }, { path: 'tasks', title: 'Tasks', icon: 'check-square', group: 'Recruiting', permission: 'tasks.view', badge: 'tasks' }, { path: 'aiassistant', title: 'AI Assistant', icon: 'sparkles', group: 'Recruiting', permission: null, tag: 'AI' }, diff --git a/frontend/src/lib/charts.js b/frontend/src/lib/charts.js index c7550ba..f86617a 100644 --- a/frontend/src/lib/charts.js +++ b/frontend/src/lib/charts.js @@ -68,7 +68,11 @@ function css(name) { return getComputedStyle(document.documentElement).getProper function drawGridY(ctx, w, h, pad, max, tc, fmt) { ctx.font = FONT(11); ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; - const steps = 4; + // A fixed 4 steps over an integer max of 2 puts ticks at 0,0.5,1,1.5,2, + // which Math.round paints as 0,1,1,2,2 — duplicate labels on every small + // count axis. Pick the first step count that divides the nice max evenly + // (niceMax yields 1,2,5,10,20,50…), falling back to 4 for fractional maxes. + const steps = Number.isInteger(max) ? ([4, 5, 2, 1].find((s) => max % s === 0) || 4) : 4; for (let i = 0; i <= steps; i++) { const val = (max / steps) * i; const y = h - pad.b - (val / max) * (h - pad.t - pad.b); @@ -110,9 +114,22 @@ function css(name) { return getComputedStyle(document.documentElement).getProper const stepX = plotW / (labels.length - 1 || 1); points.length = 0; - // x labels - ctx.fillStyle = tc.text; ctx.font = FONT(11); ctx.textAlign = 'center'; ctx.textBaseline = 'top'; - labels.forEach((l, i) => ctx.fillText(l, pad.l + stepX * i, h - pad.b + 8)); + // x labels. Edge labels hug the plot instead of centring on it — a + // centred "Aug 2026" on the last point ran past the canvas and clipped. + // When points are packed (the 12-month view) labels are thinned to the + // ones that fit, always keeping the first and the last. + ctx.fillStyle = tc.text; ctx.font = FONT(11); ctx.textBaseline = 'top'; + // "MMM YYYY" at 11px is ~54px wide; 74 leaves a readable gap between + // neighbours before thinning kicks in. + const labelEvery = Math.max(1, Math.ceil(74 / stepX)); + labels.forEach((l, i) => { + const last = i === labels.length - 1; + if (!last && i % labelEvery !== 0) return; + // drop the runner-up that would collide with the always-drawn last label + if (!last && i + labelEvery > labels.length - 1) return; + ctx.textAlign = last && i > 0 ? 'right' : i === 0 ? 'left' : 'center'; + ctx.fillText(l, pad.l + stepX * i, h - pad.b + 8); + }); datasets.forEach((ds, di) => { const pal = palette(); diff --git a/frontend/src/screens/Dashboard.jsx b/frontend/src/screens/Dashboard.jsx index 226b73f..c2d90d4 100644 --- a/frontend/src/screens/Dashboard.jsx +++ b/frontend/src/screens/Dashboard.jsx @@ -41,6 +41,33 @@ function dayDelta(cur, prior) { return `${d > 0 ? '-' : '+'}${Math.abs(d)} days` } +/** + * Trend chip props for one KPI. Arrow only when a delta is computable — a + * green up-arrow beside "—" reads as an improvement that never happened. For + * lower-is-better metrics (time to hire, cost per hire) the colour tracks + * goodness while the arrow tracks the data direction, so "-3 days" never + * ships with an up arrow. + */ +function trendProps(cur, prior, { lowerIsBetter = false, fmt = pctDelta } = {}) { + const text = fmt(cur, prior) + if (!text) return { trend: '—', dir: 'flat' } + const went = Number(cur) >= Number(prior) ? 'up' : 'down' + const good = lowerIsBetter ? went === 'down' : went === 'up' + return { trend: text, dir: good ? 'up' : 'down', arrow: went } +} + +/* Display order for pipeline stages: progression first, then held/terminal. + The API returns enum order, which interleaves them (PROCESS before PENDING, + CLOSED before SCREENING). */ +const STAGE_ORDER = [ + 'PENDING', 'SCREENING', 'PROCESS', 'ASSESSMENT', 'INTERVIEW', + 'OFFER', 'APPROVED', 'HIRED', 'ONHOLD', 'CLOSED', +] +const stageRank = (s) => { + const i = STAGE_ORDER.indexOf(s) + return i === -1 ? STAGE_ORDER.length : i +} + function greetingFor(now = new Date()) { const h = now.getHours() if (h < 12) return 'Good morning' @@ -250,33 +277,33 @@ export default function Dashboard() { () => asList(trendQuery.data?.applications), [trendQuery.data], ) - const hireSpark = useMemo( - () => asList(trendQuery.data?.hires), - [trendQuery.data], - ) + /* "Active by stage" means exactly that: REJECTED is excluded (matching the + Analytics screen's pipeline card), and each bar is that stage's share of + the ACTIVE total — the old base was the first row's count, which is the + PROCESS stage in enum order, so an empty PROCESS stage zeroed every bar + while the doughnut centre said candidates existed. */ const pipeRows = useMemo(() => { const rows = asList(funnelQuery.data) - const base = rows[0]?.count || 0 + .filter((r) => r.stage !== 'REJECTED') + .sort((a, b) => stageRank(a.stage) - stageRank(b.stage)) + const total = rows.reduce((sum, r) => sum + (r.count || 0), 0) const pal = Charts.PALETTE return rows.map((r, i) => ({ stage: r.stage, count: r.count, - pct: base ? Math.round((r.count / base) * 100) : 0, + pct: total ? Math.round(((r.count || 0) / total) * 100) : 0, color: pal[i % pal.length], })) }, [funnelQuery.data]) - const pipelineDoughnut = useMemo(() => { - const rows = asList(funnelQuery.data) - return { - labels: rows.map((p) => p.stage), - data: rows.map((p) => p.count), - colors: Charts.PALETTE, - centerValue: rows.reduce((sum, s) => sum + (s.count || 0), 0), - centerLabel: 'In pipeline', - } - }, [funnelQuery.data]) + const pipelineDoughnut = useMemo(() => ({ + labels: pipeRows.map((p) => p.stage), + data: pipeRows.map((p) => p.count), + colors: Charts.PALETTE, + centerValue: pipeRows.reduce((sum, s) => sum + (s.count || 0), 0), + centerLabel: 'In pipeline', + }), [pipeRows]) const legend = useMemo( () => [ @@ -293,15 +320,13 @@ export default function Dashboard() { { label: 'Open Jobs', value: dash(k?.open_jobs), - trend: pctDelta(k?.open_jobs, k?.open_jobs_prior) || '—', - dir: Number(k?.open_jobs) >= Number(k?.open_jobs_prior) ? 'up' : 'down', + ...trendProps(k?.open_jobs, k?.open_jobs_prior), spark: null, }, { label: 'Total Candidates', value: dash(k?.total_candidates), - trend: pctDelta(k?.total_candidates, k?.total_candidates_prior) || '—', - dir: Number(k?.total_candidates) >= Number(k?.total_candidates_prior) ? 'up' : 'down', + ...trendProps(k?.total_candidates, k?.total_candidates_prior), spark: candidateSpark, sparkColor: Charts.PALETTE[4], }, @@ -313,37 +338,33 @@ export default function Dashboard() { spark: null, }, { + // No sparkline: the only monthly series in the payload are applications + // and hires, and a hires line under an "Offers Accepted" label plots the + // wrong metric. label: 'Offers Accepted', value: dash(k?.offers_accepted), - trend: pctDelta(k?.offers_accepted, k?.offers_accepted_prior) || '—', - dir: Number(k?.offers_accepted) >= Number(k?.offers_accepted_prior) ? 'up' : 'down', - spark: hireSpark, - sparkColor: Charts.PALETTE[0], + ...trendProps(k?.offers_accepted, k?.offers_accepted_prior), + spark: null, }, { label: 'Time to Hire', value: k?.time_to_hire != null && !pending ? `${Math.round(k.time_to_hire)} days` : '—', - trend: dayDelta(k?.time_to_hire, k?.time_to_hire_prior) || '—', - dir: Number(k?.time_to_hire) <= Number(k?.time_to_hire_prior) ? 'up' : 'down', + ...trendProps(k?.time_to_hire, k?.time_to_hire_prior, { lowerIsBetter: true, fmt: dayDelta }), spark: null, }, { label: 'Cost per Hire', value: k?.cost_per_hire != null && !pending ? money(Math.round(k.cost_per_hire)) : '—', - trend: pctDelta(k?.cost_per_hire, k?.cost_per_hire_prior) || '—', - dir: Number(k?.cost_per_hire) <= Number(k?.cost_per_hire_prior) ? 'up' : 'down', + ...trendProps(k?.cost_per_hire, k?.cost_per_hire_prior, { lowerIsBetter: true }), spark: null, }, { + // Closed jobs means closed requisitions, full stop — the tile used to + // add hires on top, which double-counts a hire on a still-open req and + // mislabels the metric. label: 'Closed Jobs', - value: dash( - k == null ? null : Number(k.closed_jobs || 0) + Number(k.hires || 0), - ), - trend: pctDelta( - Number(k?.closed_jobs || 0) + Number(k?.hires || 0), - Number(k?.closed_jobs_prior || 0) + Number(k?.hires_prior || 0), - ) || '—', - dir: 'up', + value: dash(k?.closed_jobs), + ...trendProps(k?.closed_jobs, k?.closed_jobs_prior), spark: null, }, ] @@ -435,7 +456,9 @@ export default function Dashboard() {

Candidate Pipeline

- {funnelQuery.isPending ? 'Loading…' : 'Active by stage'} + + {funnelQuery.isPending ? 'Loading…' : 'Active by stage, rejections excluded'} +
diff --git a/frontend/src/screens/Talent.jsx b/frontend/src/screens/Talent.jsx index 794ba91..bc27470 100644 --- a/frontend/src/screens/Talent.jsx +++ b/frontend/src/screens/Talent.jsx @@ -133,6 +133,27 @@ function MatchRing({ score, size = 46 }) { ) } +/** + * "Already applied" chip: shown when a CV in the ATS carries this profile's + * /in/ link. Green when they applied to THIS job (sourcing them again + * wastes an InMail); amber when the CV came in against a different job. + */ +function AppliedBadge({ applied }) { + if (!applied) return null + const label = applied.same_job ? 'Already applied' : 'In ATS · other job' + const tip = [ + applied.candidate, + applied.status ? `status ${applied.status}` : null, + applied.applied_at ? `applied ${new Date(applied.applied_at).toLocaleDateString()}` : null, + applied.applications > 1 ? `${applied.applications} applications` : null, + ].filter(Boolean).join(' · ') + return ( + + {label} + + ) +} + function ProfileCard({ p, onView, onDismiss, dismissing }) { const crit = p.summary || p.headline || '' const shown = p.skills.slice(0, 5) @@ -145,6 +166,7 @@ function ProfileCard({ p, onView, onDismiss, dismissing }) {
{p.name ?? 'Unknown'}
{p.currentTitle ?? p.headline ?? '—'}
+
@@ -235,6 +257,7 @@ function TalentProfileDetail({ profileId, onClose }) {
{p.location && {p.location}} LinkedIn + {p.lastSeenAt && ( Found {fmtDate(p.lastSeenAt)} )} @@ -419,7 +442,7 @@ export default function Talent() {
-

Talent

+

Find Talent

Source matching LinkedIn profiles for a job via Apify

diff --git a/frontend/src/ui/primitives.jsx b/frontend/src/ui/primitives.jsx index d45adc1..76c8920 100644 --- a/frontend/src/ui/primitives.jsx +++ b/frontend/src/ui/primitives.jsx @@ -83,11 +83,15 @@ export function EmptyState({ icon = 'search', title = 'No results found', childr } /** Trend chip: `dir` is 'up' | 'down' | 'flat', matching js/dashboard.js:21-26. */ -export function Trend({ dir, children }) { +export function Trend({ dir, arrow, children }) { if (dir === 'flat') return {children} + // `dir` is goodness (colour); `arrow` is the data direction when the two + // differ — a falling time-to-hire is good (green) but the icon must point + // down, or the chip contradicts its own "-3 days" text. + const icon = arrow || dir return ( - + {children} ) @@ -125,12 +129,12 @@ export function KpiCard({ icon, tone = 'i-indigo', label, value, foot, trend, di * `spark` is a number[] and `sparkColor` is a color string. A 1-element array * divides by zero in the engine; the length guard is load-bearing. */ -export function KpiTile({ label, value, trend, dir = 'flat', spark, sparkColor }) { +export function KpiTile({ label, value, trend, dir = 'flat', arrow, spark, sparkColor }) { return (
{label}
{value}
- {trend && {trend}} + {trend && {trend}} {spark?.length > 1 && (