HR-ATS-Portal/frontend/src/screens/Talent.jsx

622 lines
23 KiB
JavaScript

/* ============================================================
Talent — LinkedIn talent sourcing per job (backend/talent/, Apify).
Pick a job, start a paid actor search, watch the run, browse the profiles.
The status poll is what persists results server-side: the backend fetches
the Apify dataset the first time it sees the run SUCCEEDED, so reloading
mid-run loses nothing — the screen re-adopts the newest unfinished run and
keeps polling. Profiles are deduped per job across re-runs by LinkedIn URL.
============================================================ */
import { useEffect, useMemo, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import { Badge, EmptyState, Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
import * as talentApi from '../api/talent'
import { avatarColor, fmtDate, initials as initialsOf } from '../data/seed'
const RUN_BADGE = {
pending: ['b-blue', 'Starting…'],
running: ['b-blue', 'Sourcing…'],
succeeded: ['b-green', 'Completed'],
failed: ['b-red', 'Failed'],
timed_out: ['b-red', 'Timed out'],
aborted: ['b-amber', 'Aborted'],
}
async function fetchJobs() {
const res = await candidatesApi.listJobs()
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map((row) => ({ id: row.id, title: row.title, location: row.location }))
}
/* Where to source from. Pakistan is the company's hub (Karachi and Lahore
offices today), so those lead the list; "Anywhere" clears the geography
filter server-side (useful for remote roles); CUSTOM reveals a free-text
input for anything else. */
const CUSTOM_LOCATION = '__custom__'
const LOCATION_OPTIONS = [
{ value: 'Karachi, Pakistan', label: 'Karachi' },
{ value: 'Lahore, Pakistan', label: 'Lahore' },
{ value: 'Pakistan', label: 'Pakistan — country-wide' },
{ value: 'Anywhere', label: 'Anywhere (no location filter)' },
{ value: CUSTOM_LOCATION, label: 'Custom location…' },
]
/* Work arrangements are not geographies — mirror of the backend list. */
const NON_GEOGRAPHIC = new Set([
'remote', 'hybrid', 'onsite', 'on-site', 'on site',
'anywhere', 'flexible', 'wfh', 'work from home',
])
/** Dropdown default for a job: its own city when it has one, else the hub. */
function defaultLocationFor(job) {
const loc = (job?.location || '').trim()
if (!loc || NON_GEOGRAPHIC.has(loc.toLowerCase())) {
// Remote/unspecified posts still source from the hub by default; the
// recruiter can widen to country-wide or Anywhere from the dropdown.
return { choice: 'Pakistan', custom: '' }
}
const match = LOCATION_OPTIONS.find(
(o) => o.value !== CUSTOM_LOCATION && o.value.toLowerCase().startsWith(loc.toLowerCase()),
)
if (match) return { choice: match.value, custom: '' }
return { choice: CUSTOM_LOCATION, custom: loc }
}
function ProfileAvatar({ name, url }) {
const [broken, setBroken] = useState(false)
if (url && !broken) {
return (
<img
className="avatar avatar-lg"
src={url}
alt={name || 'Profile photo'}
referrerPolicy="no-referrer"
style={{ objectFit: 'cover', padding: 0 }}
onError={() => setBroken(true)}
/>
)
}
return (
<span className="avatar avatar-lg" style={{ background: avatarColor(name || '?') }}>
{initialsOf(name || '?')}
</span>
)
}
/** Big centered loader: the ai-assist ring scaled up inline (CSS is frozen). */
function BigLoader({ title, children }) {
return (
<div style={{ display: 'grid', placeItems: 'center', padding: '64px 20px', textAlign: 'center' }}>
<span
className="ai-assist-spinner"
role="status"
aria-label={title}
style={{ width: 72, height: 72, borderWidth: 5 }}
/>
<div className="fw-600" style={{ marginTop: 20, fontSize: 17 }}>{title}</div>
{children && <p className="text-muted" style={{ marginTop: 6, maxWidth: 420 }}>{children}</p>}
</div>
)
}
/** The JobCandidates MiniRing verbatim, fed by the deterministic job-match score. */
function MatchRing({ score, size = 46 }) {
if (score == null) return null
const color = score >= 70 ? 'var(--success)' : score >= 40 ? 'var(--warning)' : 'var(--danger)'
return (
<div
data-tip="Job match"
style={{
width: size, height: size, borderRadius: '50%', flexShrink: 0,
display: 'grid', placeItems: 'center',
background: `conic-gradient(${color} ${score}%, var(--bg-sunken) 0)`,
}}
>
<div
style={{
width: size - 8, height: size - 8, borderRadius: '50%',
background: 'var(--bg-elev)', display: 'grid', placeItems: 'center',
fontWeight: 800, fontSize: 13.5, letterSpacing: '-.3px',
color,
}}
>
{score}
</div>
</div>
)
}
/**
* "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)
const more = p.skills.length - shown.length
return (
<div className="card cand-card" onClick={() => onView(p)}>
<div className="card-body">
<div className="cand-head">
<ProfileAvatar name={p.name} url={p.avatarUrl} />
<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>
{shown.length > 0 && (
<div className="cand-skills">
{shown.map((s) => <span className="cand-chip" key={s}>{s}</span>)}
{more > 0 && <span className="cand-chip more">+{more} more</span>}
</div>
)}
<p className="cand-crit">{crit}</p>
<div className="cand-foot">
<span className="cand-meta"><Icon name="map" /> {p.location ?? '—'}</span>
<span className="cand-company">{p.currentCompany ?? ''}</span>
<a
className="act-btn"
data-tip="Open LinkedIn profile"
href={p.linkedinUrl}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
>
<Icon name="linkedin" />
</a>
<button
className="act-btn"
data-tip="View profile"
onClick={(e) => { e.stopPropagation(); onView(p) }}
>
<Icon name="eye" />
</button>
<button
className="act-btn"
data-tip="Dismiss"
disabled={dismissing}
onClick={(e) => { e.stopPropagation(); onDismiss(p) }}
>
<Icon name="trash" />
</button>
</div>
</div>
</div>
)
}
/** Full LinkedIn profile: hero + about + skills + employment/education history. */
function TalentProfileDetail({ profileId, onClose }) {
const detailQuery = useQuery({
queryKey: qk.talent.profile(profileId),
queryFn: () => talentApi.getProfile(profileId),
})
const p = detailQuery.data?.data ? talentApi.toProfileDetailView(detailQuery.data.data) : null
return (
<Modal
title="Talent Profile"
subtitle={p?.headline ?? undefined}
size="modal-lg"
onClose={onClose}
footer={
<>
{p && (
<a className="btn btn-primary" href={p.linkedinUrl} target="_blank" rel="noreferrer">
<Icon name="linkedin" /> Open LinkedIn
</a>
)}
<button className="btn" onClick={onClose}>Close</button>
</>
}
>
{detailQuery.isError ? (
<EmptyState icon="alert" title="Could not load this profile">
{friendlyAuthError(detailQuery.error, 'Please try again.')}
</EmptyState>
) : detailQuery.isPending ? (
<BigLoader title="Loading profile…" />
) : (
<>
<div className="profile-hero">
<ProfileAvatar name={p.name} url={p.avatarUrl} />
<div style={{ flex: 1 }}>
<div className="ph-name">{p.name ?? 'Unknown'}</div>
<div className="ph-role">
{[p.currentTitle, p.currentCompany].filter(Boolean).join(' at ') || p.headline || '—'}
</div>
<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>
)}
</div>
</div>
</div>
{p.summary && (
<>
<div className="form-section-title">About</div>
<p className="text-muted" style={{ whiteSpace: 'pre-line' }}>{p.summary}</p>
</>
)}
{p.skills.length > 0 && (
<>
<div className="form-section-title">Skills ({p.skills.length})</div>
<div className="k-tags" style={{ marginBottom: 16 }}>
{p.skills.map((s) => <span className="tag" key={s}>{s}</span>)}
</div>
</>
)}
{p.experience.length > 0 && (
<>
<div className="form-section-title">Experience ({p.experience.length})</div>
{p.experience.map((e, i) => (
<div key={i} style={{ marginBottom: 14 }}>
<div className="fw-600">
{[e.title, e.company].filter(Boolean).join(' — ') || '—'}
</div>
<div className="text-muted text-sm">
{[e.period, e.duration, e.employmentType, e.location].filter(Boolean).join(' · ')}
</div>
{e.description && (
<p className="text-muted text-sm" style={{ marginTop: 4 }}>{e.description}</p>
)}
{e.skills.length > 0 && (
<div className="cand-skills" style={{ marginTop: 6 }}>
{e.skills.map((s) => <span className="cand-chip" key={s}>{s}</span>)}
</div>
)}
</div>
))}
</>
)}
{p.education.length > 0 && (
<>
<div className="form-section-title">Education ({p.education.length})</div>
{p.education.map((e, i) => (
<div key={i} style={{ marginBottom: 12 }}>
<div className="fw-600">{e.school ?? '—'}</div>
<div className="text-muted text-sm">
{[[e.degree, e.field].filter(Boolean).join(', '), e.period].filter(Boolean).join(' · ')}
</div>
</div>
))}
</>
)}
</>
)}
</Modal>
)
}
export default function Talent() {
const { toast } = useToast()
const qc = useQueryClient()
const [jobId, setJobId] = useState('')
const [activeRunId, setActiveRunId] = useState(null)
const [confirmOpen, setConfirmOpen] = useState(false)
const [search, setSearch] = useState('')
const [locationChoice, setLocationChoice] = useState('Pakistan')
const [customLocation, setCustomLocation] = useState('')
const [visibleCount, setVisibleCount] = useState(10)
const [viewProfileId, setViewProfileId] = useState(null)
const jobsQuery = useQuery({ queryKey: qk.jobPosts.list(), queryFn: fetchJobs })
const jobs = jobsQuery.data ?? []
const selectedJob = jobs.find((j) => j.id === jobId)
const runsQuery = useQuery({
queryKey: qk.talent.runs(jobId),
queryFn: () => talentApi.listRuns(jobId),
enabled: !!jobId,
})
const runs = useMemo(
() => (Array.isArray(runsQuery.data?.data) ? runsQuery.data.data.map(talentApi.toRunView) : []),
[runsQuery.data],
)
const latestRun = runs[0] ?? null
// Resume-after-reload: adopt the newest unfinished run as the poll target.
useEffect(() => {
if (!activeRunId && latestRun && !latestRun.isTerminal) setActiveRunId(latestRun.id)
}, [activeRunId, latestRun])
const statusQuery = useQuery({
queryKey: qk.talent.run(activeRunId),
queryFn: () => talentApi.getRunStatus(activeRunId),
enabled: !!activeRunId,
refetchInterval: (query) => {
const status = query.state.data?.data?.status
return status && talentApi.isTerminalRun(status) ? false : 4000
},
})
const activeRun = statusQuery.data?.data ? talentApi.toRunView(statusQuery.data.data) : null
const runInFlight = !!activeRun && !activeRun.isTerminal
// Toast + refresh exactly once per run settling.
const settledRef = useRef(null)
useEffect(() => {
if (!activeRun || !activeRun.isTerminal || settledRef.current === activeRun.id) return
settledRef.current = activeRun.id
qc.invalidateQueries({ queryKey: qk.talent.all() })
setVisibleCount(10)
if (activeRun.status === 'succeeded') {
toast(`${activeRun.profilesFound} profile${activeRun.profilesFound === 1 ? '' : 's'} found on LinkedIn`, 'success')
} else {
toast(activeRun.error || `Talent search ${activeRun.status.replace('_', ' ')}`, 'error')
}
}, [activeRun, qc, toast])
const profilesQuery = useQuery({
queryKey: qk.talent.profiles({ jobId }),
queryFn: () => talentApi.listProfiles({ jobId }),
enabled: !!jobId,
})
const profiles = useMemo(
() =>
Array.isArray(profilesQuery.data?.data)
? profilesQuery.data.data.map(talentApi.toProfileView)
: [],
[profilesQuery.data],
)
const visible = useMemo(() => {
const q = search.trim().toLowerCase()
if (!q) return profiles
return profiles.filter((p) =>
[p.name, p.headline, p.currentCompany, p.currentTitle, p.location]
.some((f) => f && f.toLowerCase().includes(q)),
)
}, [profiles, search])
const effectiveLocation =
locationChoice === CUSTOM_LOCATION ? customLocation.trim() : locationChoice
const locationLabel =
locationChoice === 'Anywhere'
? 'anywhere (no location filter)'
: `in ${effectiveLocation}`
const starting = useMutation({
mutationFn: () => talentApi.startRun(jobId, { location: effectiveLocation }),
onSuccess: (res) => {
setConfirmOpen(false)
const run = res?.data
if (run?.id) {
qc.setQueryData(qk.talent.run(run.id), res)
setActiveRunId(run.id)
}
qc.invalidateQueries({ queryKey: qk.talent.runs(jobId) })
toast('Talent search started', 'success')
},
onError: (err) => {
setConfirmOpen(false)
toast(friendlyAuthError(err, 'Could not start the talent search'), 'error')
},
})
const dismissing = useMutation({
mutationFn: (profile) => talentApi.deleteProfile(profile.id),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.talent.profiles({ jobId }) }),
onError: (err) => toast(friendlyAuthError(err, 'Could not dismiss the profile'), 'error'),
})
const statusRun = runInFlight || !latestRun ? activeRun : latestRun
const [badgeCls, badgeLabel] = statusRun ? (RUN_BADGE[statusRun.status] ?? ['b-gray', statusRun.status]) : []
return (
<div className="page">
<div className="page-head">
<div>
<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">
<span className="integration-status pending"><span className="pulse" />LinkedIn Sourcing · Live</span>
</div>
</div>
<div className="card mb-18">
<div className="card-body">
<div className="flex items-center gap-8" style={{ flexWrap: 'wrap' }}>
<select
className="select"
style={{ flex: 1, minWidth: 180 }}
aria-label="Source for job"
value={jobId}
onChange={(e) => {
const nextId = e.target.value
setJobId(nextId)
setActiveRunId(null)
setSearch('')
setVisibleCount(10)
const preset = defaultLocationFor(jobs.find((j) => j.id === nextId))
setLocationChoice(preset.choice)
setCustomLocation(preset.custom)
}}
>
<option value="">Select a job post</option>
{jobs.map((j) => <option key={j.id} value={j.id}>{j.title}</option>)}
</select>
<select
className="select"
style={{ width: 220 }}
value={locationChoice}
onChange={(e) => setLocationChoice(e.target.value)}
aria-label="Location"
>
{LOCATION_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
{locationChoice === CUSTOM_LOCATION && (
<input
className="input"
style={{ width: 200 }}
placeholder="City or country…"
value={customLocation}
onChange={(e) => setCustomLocation(e.target.value)}
/>
)}
<button
className="btn btn-primary"
disabled={!jobId || runInFlight || starting.isPending || !effectiveLocation}
onClick={() => setConfirmOpen(true)}
>
<Icon name={runInFlight ? 'clock' : 'search'} />
{runInFlight ? 'Sourcing…' : 'Find Talent'}
</button>
</div>
{jobsQuery.isError && (
<p className="text-muted text-sm" style={{ marginTop: 12 }}>
{friendlyAuthError(jobsQuery.error, 'Could not load job posts')}
</p>
)}
{jobId && statusRun && (
<p className="text-muted text-sm flex items-center gap-8" style={{ marginTop: 12 }}>
<Badge className={badgeCls}>{badgeLabel}</Badge>
{statusRun.status === 'succeeded' && (
<span>{statusRun.profilesFound} profile{statusRun.profilesFound === 1 ? '' : 's'} in the last run</span>
)}
{statusRun.error && <span>{statusRun.error}</span>}
{statusRun.createdAt && <span>· {fmtDate(statusRun.createdAt)}</span>}
</p>
)}
</div>
</div>
{!jobId ? (
<EmptyState icon="user-plus" title="Pick a job to source for">
Sourced LinkedIn profiles are saved per job and kept across searches.
</EmptyState>
) : profilesQuery.isError ? (
<EmptyState icon="alert" title="Could not load sourced profiles">
{friendlyAuthError(profilesQuery.error, 'Please try again.')}
</EmptyState>
) : profilesQuery.isPending ? (
<BigLoader title="Loading sourced profiles…" />
) : profiles.length === 0 ? (
runInFlight ? (
<BigLoader title="Searching LinkedIn…">
Scanning profiles matching this job&apos;s title, skills and experience.
This usually takes a minute or two results appear here automatically.
</BigLoader>
) : (
<EmptyState icon="search" title="No profiles sourced yet">
Run Find Talent to search LinkedIn for people matching this job.
</EmptyState>
)
) : (
<>
<div className="flex items-center gap-8 mb-18">
<input
className="input"
style={{ maxWidth: 320 }}
placeholder="Filter by name, headline, company…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<span className="text-muted text-sm">
{visible.length} of {profiles.length} profile{profiles.length === 1 ? '' : 's'}
</span>
</div>
<div className="grid g-3">
{visible.slice(0, visibleCount).map((p) => (
<ProfileCard
key={p.id}
p={p}
onView={(profile) => setViewProfileId(profile.id)}
onDismiss={(profile) => dismissing.mutate(profile)}
dismissing={dismissing.isPending}
/>
))}
</div>
<div className="flex items-center gap-8" style={{ justifyContent: 'center', marginTop: 18 }}>
{visible.length > visibleCount ? (
<button className="btn" onClick={() => setVisibleCount((n) => n + 10)}>
<Icon name="chevron-down" />
Show more ({visible.length - visibleCount} remaining)
</button>
) : (
<button
className="btn"
disabled={runInFlight || starting.isPending}
onClick={() => setConfirmOpen(true)}
>
<Icon name={runInFlight ? 'clock' : 'search'} />
{runInFlight ? 'Sourcing…' : 'Search LinkedIn for more'}
</button>
)}
</div>
</>
)}
{viewProfileId && (
<TalentProfileDetail profileId={viewProfileId} onClose={() => setViewProfileId(null)} />
)}
{confirmOpen && (
<Modal
title="Start LinkedIn talent search"
subtitle={selectedJob?.title}
onClose={() => setConfirmOpen(false)}
footer={
<>
<button className="btn" onClick={() => setConfirmOpen(false)}>Cancel</button>
<button
className="btn btn-primary"
disabled={starting.isPending}
onClick={() => starting.mutate()}
>
{starting.isPending ? 'Starting…' : 'Start search'}
</button>
</>
}
>
<p>
This starts a <strong>paid</strong> Apify search of LinkedIn for people matching
this job&apos;s title, technical requirements and experience level, {locationLabel}
up to 25 profiles per run (roughly $0.20). Repeating the same search continues
deeper into the results, so each run surfaces new people; anyone already found
is refreshed, not duplicated.
</p>
</Modal>
)}
</div>
)
}