Merge pull request 'Dashboard audit fixes, Find Talent rename, already-applied matching' (#23) from Talha into main
Deploy to S3 / deploy (push) Successful in 31s
Details
Deploy to S3 / deploy (push) Successful in 31s
Details
commit
6f54020f5c
|
|
@ -17,6 +17,7 @@ from sqlalchemy.orm import selectinload
|
||||||
from sqlmodel import Field, Relationship, SQLModel, select, true
|
from sqlmodel import Field, Relationship, SQLModel, select, true
|
||||||
|
|
||||||
from job.candidate.models import Activity, Feedback, Interviews
|
from job.candidate.models import Activity, Feedback, Interviews
|
||||||
|
from linkedin_utils import primary_slug_from_text
|
||||||
from users.models import Users
|
from users.models import Users
|
||||||
from users.plugins import hash_password
|
from users.plugins import hash_password
|
||||||
|
|
||||||
|
|
@ -332,6 +333,10 @@ class Inbox_Messages(SQLModel, table=True):
|
||||||
file_name: str | None = Field(default=None)
|
file_name: str | None = Field(default=None)
|
||||||
file_path: str | None = Field(default=None)
|
file_path: str | None = Field(default=None)
|
||||||
resume_text: str | None = Field(default=None)
|
resume_text: str | None = Field(default=None)
|
||||||
|
# Lowercase /in/<slug> 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)
|
experience: str | None = Field(default=None)
|
||||||
suggested_job_post_ids: list[str] | None = Field(default=None, sa_column=Column(JSONB))
|
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)
|
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
|
return None
|
||||||
if resume_text is not None:
|
if resume_text is not None:
|
||||||
row.resume_text = resume_text
|
row.resume_text = resume_text
|
||||||
|
row.linkedin_slug = primary_slug_from_text(resume_text)
|
||||||
if candidate_phone_number is not None:
|
if candidate_phone_number is not None:
|
||||||
row.candidate_phone_number = candidate_phone_number
|
row.candidate_phone_number = candidate_phone_number
|
||||||
if candidate_education is not None:
|
if candidate_education is not None:
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from sqlmodel import Field, Relationship, SQLModel, select
|
from sqlmodel import Field, Relationship, SQLModel, select
|
||||||
|
|
||||||
|
from linkedin_utils import primary_slug_from_text
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from inbox.models import Inbox
|
from inbox.models import Inbox
|
||||||
from users.models import Users
|
from users.models import Users
|
||||||
|
|
@ -32,6 +34,10 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
candidate_phone: str = Field(default="")
|
candidate_phone: str = Field(default="")
|
||||||
job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id")
|
job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id")
|
||||||
full_text: str = Field(default="")
|
full_text: str = Field(default="")
|
||||||
|
# Lowercase /in/<slug> 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="")
|
current_company: str = Field(default="")
|
||||||
# Candidate's role at that company (e.g. "Senior Merchandiser"). Distinct
|
# 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.
|
# 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(),
|
candidate_phone=(fields.get("candidate_phone") or "").strip(),
|
||||||
job_post_id=cls._as_uuid(fields.get("job_post_id")),
|
job_post_id=cls._as_uuid(fields.get("job_post_id")),
|
||||||
full_text=fields.get("full_text") or "",
|
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_company=(fields.get("current_company") or "").strip(),
|
||||||
current_position=(fields.get("current_position") or "").strip(),
|
current_position=(fields.get("current_position") or "").strip(),
|
||||||
apply_via="manual_upload",
|
apply_via="manual_upload",
|
||||||
|
|
|
||||||
|
|
@ -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/<slug>.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
@ -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/<slug>. 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
|
||||||
|
|
@ -4,6 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from job.job_post.models import JobPosts
|
from job.job_post.models import JobPosts
|
||||||
from talent import plugins
|
from talent import plugins
|
||||||
|
from talent.matching import annotate_applications
|
||||||
from talent.models import TalentProfiles, TalentRuns
|
from talent.models import TalentProfiles, TalentRuns
|
||||||
from talent.serializers import (
|
from talent.serializers import (
|
||||||
serialize_talent_profile,
|
serialize_talent_profile,
|
||||||
|
|
@ -205,13 +206,17 @@ class Talent:
|
||||||
rows, total = await TalentProfiles.fetch_profiles(
|
rows, total = await TalentProfiles.fetch_profiles(
|
||||||
self.session, job_post_id=job_post_id, search=search, top=top, skip=skip
|
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):
|
async def get_profile(self, profile_id):
|
||||||
row = await TalentProfiles.get_profile_by_id(self.session, profile_id)
|
row = await TalentProfiles.get_profile_by_id(self.session, profile_id)
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404, detail="Talent profile not found")
|
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):
|
async def delete_profile(self, profile_id):
|
||||||
row = await TalentProfiles.soft_delete_profile(self.session, profile_id)
|
row = await TalentProfiles.soft_delete_profile(self.session, profile_id)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -90,6 +90,9 @@ export function toProfileView(row) {
|
||||||
skills: Array.isArray(row.skills) ? row.skills : [],
|
skills: Array.isArray(row.skills) ? row.skills : [],
|
||||||
matchScore: row.match_score ?? null,
|
matchScore: row.match_score ?? null,
|
||||||
lastSeenAt: row.last_seen_at ? new Date(row.last_seen_at) : 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/<slug> link:
|
||||||
|
// { source, status, job_post_id, candidate, applied_at, same_job, applications }
|
||||||
|
alreadyApplied: row.already_applied ?? null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ export const ROUTES = [
|
||||||
{ path: 'import', title: 'CV Import', icon: 'upload', group: 'Recruiting', permission: 'candidates.create' },
|
{ 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: '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: '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: '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' },
|
{ path: 'aiassistant', title: 'AI Assistant', icon: 'sparkles', group: 'Recruiting', permission: null, tag: 'AI' },
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,11 @@ function css(name) { return getComputedStyle(document.documentElement).getProper
|
||||||
function drawGridY(ctx, w, h, pad, max, tc, fmt) {
|
function drawGridY(ctx, w, h, pad, max, tc, fmt) {
|
||||||
ctx.font = FONT(11);
|
ctx.font = FONT(11);
|
||||||
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
|
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++) {
|
for (let i = 0; i <= steps; i++) {
|
||||||
const val = (max / steps) * i;
|
const val = (max / steps) * i;
|
||||||
const y = h - pad.b - (val / max) * (h - pad.t - pad.b);
|
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);
|
const stepX = plotW / (labels.length - 1 || 1);
|
||||||
points.length = 0;
|
points.length = 0;
|
||||||
|
|
||||||
// x labels
|
// x labels. Edge labels hug the plot instead of centring on it — a
|
||||||
ctx.fillStyle = tc.text; ctx.font = FONT(11); ctx.textAlign = 'center'; ctx.textBaseline = 'top';
|
// centred "Aug 2026" on the last point ran past the canvas and clipped.
|
||||||
labels.forEach((l, i) => ctx.fillText(l, pad.l + stepX * i, h - pad.b + 8));
|
// 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) => {
|
datasets.forEach((ds, di) => {
|
||||||
const pal = palette();
|
const pal = palette();
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,33 @@ function dayDelta(cur, prior) {
|
||||||
return `${d > 0 ? '-' : '+'}${Math.abs(d)} days`
|
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()) {
|
function greetingFor(now = new Date()) {
|
||||||
const h = now.getHours()
|
const h = now.getHours()
|
||||||
if (h < 12) return 'Good morning'
|
if (h < 12) return 'Good morning'
|
||||||
|
|
@ -250,33 +277,33 @@ export default function Dashboard() {
|
||||||
() => asList(trendQuery.data?.applications),
|
() => asList(trendQuery.data?.applications),
|
||||||
[trendQuery.data],
|
[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 pipeRows = useMemo(() => {
|
||||||
const rows = asList(funnelQuery.data)
|
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
|
const pal = Charts.PALETTE
|
||||||
return rows.map((r, i) => ({
|
return rows.map((r, i) => ({
|
||||||
stage: r.stage,
|
stage: r.stage,
|
||||||
count: r.count,
|
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],
|
color: pal[i % pal.length],
|
||||||
}))
|
}))
|
||||||
}, [funnelQuery.data])
|
}, [funnelQuery.data])
|
||||||
|
|
||||||
const pipelineDoughnut = useMemo(() => {
|
const pipelineDoughnut = useMemo(() => ({
|
||||||
const rows = asList(funnelQuery.data)
|
labels: pipeRows.map((p) => p.stage),
|
||||||
return {
|
data: pipeRows.map((p) => p.count),
|
||||||
labels: rows.map((p) => p.stage),
|
colors: Charts.PALETTE,
|
||||||
data: rows.map((p) => p.count),
|
centerValue: pipeRows.reduce((sum, s) => sum + (s.count || 0), 0),
|
||||||
colors: Charts.PALETTE,
|
centerLabel: 'In pipeline',
|
||||||
centerValue: rows.reduce((sum, s) => sum + (s.count || 0), 0),
|
}), [pipeRows])
|
||||||
centerLabel: 'In pipeline',
|
|
||||||
}
|
|
||||||
}, [funnelQuery.data])
|
|
||||||
|
|
||||||
const legend = useMemo(
|
const legend = useMemo(
|
||||||
() => [
|
() => [
|
||||||
|
|
@ -293,15 +320,13 @@ export default function Dashboard() {
|
||||||
{
|
{
|
||||||
label: 'Open Jobs',
|
label: 'Open Jobs',
|
||||||
value: dash(k?.open_jobs),
|
value: dash(k?.open_jobs),
|
||||||
trend: pctDelta(k?.open_jobs, k?.open_jobs_prior) || '—',
|
...trendProps(k?.open_jobs, k?.open_jobs_prior),
|
||||||
dir: Number(k?.open_jobs) >= Number(k?.open_jobs_prior) ? 'up' : 'down',
|
|
||||||
spark: null,
|
spark: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Total Candidates',
|
label: 'Total Candidates',
|
||||||
value: dash(k?.total_candidates),
|
value: dash(k?.total_candidates),
|
||||||
trend: pctDelta(k?.total_candidates, k?.total_candidates_prior) || '—',
|
...trendProps(k?.total_candidates, k?.total_candidates_prior),
|
||||||
dir: Number(k?.total_candidates) >= Number(k?.total_candidates_prior) ? 'up' : 'down',
|
|
||||||
spark: candidateSpark,
|
spark: candidateSpark,
|
||||||
sparkColor: Charts.PALETTE[4],
|
sparkColor: Charts.PALETTE[4],
|
||||||
},
|
},
|
||||||
|
|
@ -313,37 +338,33 @@ export default function Dashboard() {
|
||||||
spark: null,
|
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',
|
label: 'Offers Accepted',
|
||||||
value: dash(k?.offers_accepted),
|
value: dash(k?.offers_accepted),
|
||||||
trend: pctDelta(k?.offers_accepted, k?.offers_accepted_prior) || '—',
|
...trendProps(k?.offers_accepted, k?.offers_accepted_prior),
|
||||||
dir: Number(k?.offers_accepted) >= Number(k?.offers_accepted_prior) ? 'up' : 'down',
|
spark: null,
|
||||||
spark: hireSpark,
|
|
||||||
sparkColor: Charts.PALETTE[0],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Time to Hire',
|
label: 'Time to Hire',
|
||||||
value: k?.time_to_hire != null && !pending ? `${Math.round(k.time_to_hire)} days` : '—',
|
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) || '—',
|
...trendProps(k?.time_to_hire, k?.time_to_hire_prior, { lowerIsBetter: true, fmt: dayDelta }),
|
||||||
dir: Number(k?.time_to_hire) <= Number(k?.time_to_hire_prior) ? 'up' : 'down',
|
|
||||||
spark: null,
|
spark: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Cost per Hire',
|
label: 'Cost per Hire',
|
||||||
value: k?.cost_per_hire != null && !pending ? money(Math.round(k.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) || '—',
|
...trendProps(k?.cost_per_hire, k?.cost_per_hire_prior, { lowerIsBetter: true }),
|
||||||
dir: Number(k?.cost_per_hire) <= Number(k?.cost_per_hire_prior) ? 'up' : 'down',
|
|
||||||
spark: null,
|
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',
|
label: 'Closed Jobs',
|
||||||
value: dash(
|
value: dash(k?.closed_jobs),
|
||||||
k == null ? null : Number(k.closed_jobs || 0) + Number(k.hires || 0),
|
...trendProps(k?.closed_jobs, k?.closed_jobs_prior),
|
||||||
),
|
|
||||||
trend: pctDelta(
|
|
||||||
Number(k?.closed_jobs || 0) + Number(k?.hires || 0),
|
|
||||||
Number(k?.closed_jobs_prior || 0) + Number(k?.hires_prior || 0),
|
|
||||||
) || '—',
|
|
||||||
dir: 'up',
|
|
||||||
spark: null,
|
spark: null,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
@ -435,7 +456,9 @@ export default function Dashboard() {
|
||||||
<div className="card-head">
|
<div className="card-head">
|
||||||
<div>
|
<div>
|
||||||
<h3>Candidate Pipeline</h3>
|
<h3>Candidate Pipeline</h3>
|
||||||
<span className="ch-sub">{funnelQuery.isPending ? 'Loading…' : 'Active by stage'}</span>
|
<span className="ch-sub">
|
||||||
|
{funnelQuery.isPending ? 'Loading…' : 'Active by stage, rejections excluded'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,27 @@ function MatchRing({ score, size = 46 }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Already applied" chip: shown when a CV in the ATS carries this profile's
|
||||||
|
* /in/<slug> 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 (
|
||||||
|
<Badge className={applied.same_job ? 'b-green' : 'b-amber'} data-tip={tip || undefined}>
|
||||||
|
<Icon name="check-circle" /> {label}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ProfileCard({ p, onView, onDismiss, dismissing }) {
|
function ProfileCard({ p, onView, onDismiss, dismissing }) {
|
||||||
const crit = p.summary || p.headline || ''
|
const crit = p.summary || p.headline || ''
|
||||||
const shown = p.skills.slice(0, 5)
|
const shown = p.skills.slice(0, 5)
|
||||||
|
|
@ -145,6 +166,7 @@ function ProfileCard({ p, onView, onDismiss, dismissing }) {
|
||||||
<div className="cand-id">
|
<div className="cand-id">
|
||||||
<div className="cand-name">{p.name ?? 'Unknown'}</div>
|
<div className="cand-name">{p.name ?? 'Unknown'}</div>
|
||||||
<div className="cand-role">{p.currentTitle ?? p.headline ?? '—'}</div>
|
<div className="cand-role">{p.currentTitle ?? p.headline ?? '—'}</div>
|
||||||
|
<AppliedBadge applied={p.alreadyApplied} />
|
||||||
</div>
|
</div>
|
||||||
<MatchRing score={p.matchScore} />
|
<MatchRing score={p.matchScore} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -235,6 +257,7 @@ function TalentProfileDetail({ profileId, onClose }) {
|
||||||
<div className="ph-tags">
|
<div className="ph-tags">
|
||||||
{p.location && <Badge className="b-plain b-indigo badge-plain">{p.location}</Badge>}
|
{p.location && <Badge className="b-plain b-indigo badge-plain">{p.location}</Badge>}
|
||||||
<Badge className="b-gray">LinkedIn</Badge>
|
<Badge className="b-gray">LinkedIn</Badge>
|
||||||
|
<AppliedBadge applied={p.alreadyApplied} />
|
||||||
{p.lastSeenAt && (
|
{p.lastSeenAt && (
|
||||||
<Badge className="b-plain b-indigo badge-plain">Found {fmtDate(p.lastSeenAt)}</Badge>
|
<Badge className="b-plain b-indigo badge-plain">Found {fmtDate(p.lastSeenAt)}</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
@ -419,7 +442,7 @@ export default function Talent() {
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<div className="page-head">
|
<div className="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="page-title">Talent</h1>
|
<h1 className="page-title">Find Talent</h1>
|
||||||
<p className="page-sub">Source matching LinkedIn profiles for a job via Apify</p>
|
<p className="page-sub">Source matching LinkedIn profiles for a job via Apify</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="page-head-actions">
|
<div className="page-head-actions">
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
/** 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 <span className="trend trend-flat">{children}</span>
|
if (dir === 'flat') return <span className="trend trend-flat">{children}</span>
|
||||||
|
// `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 (
|
return (
|
||||||
<span className={`trend ${dir === 'up' ? 'trend-up' : 'trend-down'}`}>
|
<span className={`trend ${dir === 'up' ? 'trend-up' : 'trend-down'}`}>
|
||||||
<Icon name={dir === 'up' ? 'trending-up' : 'trending-down'} />
|
<Icon name={icon === 'up' ? 'trending-up' : 'trending-down'} />
|
||||||
{children}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|
@ -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
|
* `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.
|
* 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 (
|
return (
|
||||||
<div className="kpi kpi-tile">
|
<div className="kpi kpi-tile">
|
||||||
<span className="kpi-label">{label}</span>
|
<span className="kpi-label">{label}</span>
|
||||||
<div className="kpi-value">{value}</div>
|
<div className="kpi-value">{value}</div>
|
||||||
{trend && <Trend dir={dir}>{trend}</Trend>}
|
{trend && <Trend dir={dir} arrow={arrow}>{trend}</Trend>}
|
||||||
{spark?.length > 1 && (
|
{spark?.length > 1 && (
|
||||||
<div className="kpi-spark">
|
<div className="kpi-spark">
|
||||||
<Chart type="sparkline" data={spark} options={sparkColor} height={36} />
|
<Chart type="sparkline" data={spark} options={sparkColor} height={36} />
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue