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 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/<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)
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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/<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="")
|
||||
# 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",
|
||||
|
|
|
|||
|
|
@ -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 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)
|
||||
|
|
|
|||
|
|
@ -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 : [],
|
||||
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/<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: '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' },
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
const pipelineDoughnut = useMemo(() => ({
|
||||
labels: pipeRows.map((p) => p.stage),
|
||||
data: pipeRows.map((p) => p.count),
|
||||
colors: Charts.PALETTE,
|
||||
centerValue: rows.reduce((sum, s) => sum + (s.count || 0), 0),
|
||||
centerValue: pipeRows.reduce((sum, s) => sum + (s.count || 0), 0),
|
||||
centerLabel: 'In pipeline',
|
||||
}
|
||||
}, [funnelQuery.data])
|
||||
}), [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() {
|
|||
<div className="card-head">
|
||||
<div>
|
||||
<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 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 }) {
|
||||
const crit = p.summary || p.headline || ''
|
||||
const shown = p.skills.slice(0, 5)
|
||||
|
|
@ -145,6 +166,7 @@ function ProfileCard({ p, onView, onDismiss, dismissing }) {
|
|||
<div className="cand-id">
|
||||
<div className="cand-name">{p.name ?? 'Unknown'}</div>
|
||||
<div className="cand-role">{p.currentTitle ?? p.headline ?? '—'}</div>
|
||||
<AppliedBadge applied={p.alreadyApplied} />
|
||||
</div>
|
||||
<MatchRing score={p.matchScore} />
|
||||
</div>
|
||||
|
|
@ -235,6 +257,7 @@ function TalentProfileDetail({ profileId, onClose }) {
|
|||
<div className="ph-tags">
|
||||
{p.location && <Badge className="b-plain b-indigo badge-plain">{p.location}</Badge>}
|
||||
<Badge className="b-gray">LinkedIn</Badge>
|
||||
<AppliedBadge applied={p.alreadyApplied} />
|
||||
{p.lastSeenAt && (
|
||||
<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-head">
|
||||
<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>
|
||||
</div>
|
||||
<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. */
|
||||
export function Trend({ dir, children }) {
|
||||
export function Trend({ dir, arrow, children }) {
|
||||
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 (
|
||||
<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}
|
||||
</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
|
||||
* 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 (
|
||||
<div className="kpi kpi-tile">
|
||||
<span className="kpi-label">{label}</span>
|
||||
<div className="kpi-value">{value}</div>
|
||||
{trend && <Trend dir={dir}>{trend}</Trend>}
|
||||
{trend && <Trend dir={dir} arrow={arrow}>{trend}</Trend>}
|
||||
{spark?.length > 1 && (
|
||||
<div className="kpi-spark">
|
||||
<Chart type="sparkline" data={spark} options={sparkColor} height={36} />
|
||||
|
|
|
|||
Loading…
Reference in New Issue