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

pull/24/head
talha.ahmed 2026-08-21 15:12:26 +00:00
commit 6f54020f5c
12 changed files with 381 additions and 49 deletions

View File

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

View File

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

58
backend/linkedin_utils.py Normal file
View File

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

124
backend/talent/matching.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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