Find Talent: shortlist sourced profiles for outreach

Manual outreach funnel on talent_profiles (sourced -> shortlisted ->
contacted, one-step undo) tracked per profile with who/when stamps.
New PATCH /talent/profiles/outreach behind talent.edit; Find Talent
screen gains All/Shortlisted/Contacted tabs, a star to shortlist and
a check to record that the recruiter messaged the person on LinkedIn
(the app itself sends nothing). Re-runs never reset outreach state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull/39/head
Talha Ahmed 2026-08-31 14:34:23 +05:00
parent 454af32160
commit 4b93246d43
8 changed files with 393 additions and 28 deletions

View File

@ -0,0 +1,21 @@
-- 017_talent_outreach.sql
-- Manual outreach funnel on talent_profiles: sourced -> shortlisted -> contacted.
-- The app sends no messages; recruiters reach out on LinkedIn themselves and
-- record the result here (who shortlisted / contacted, and when). Applied at
-- startup by alembic_setup.run_manual_sql(). Needed because prod boots with
-- DB_AUTOGENERATE=false.
ALTER TABLE app.talent_profiles
ADD COLUMN IF NOT EXISTS outreach_status TEXT NOT NULL DEFAULT 'sourced';
ALTER TABLE app.talent_profiles
ADD COLUMN IF NOT EXISTS shortlisted_at TIMESTAMPTZ;
ALTER TABLE app.talent_profiles
ADD COLUMN IF NOT EXISTS shortlisted_by UUID REFERENCES app.users(id);
ALTER TABLE app.talent_profiles
ADD COLUMN IF NOT EXISTS contacted_at TIMESTAMPTZ;
ALTER TABLE app.talent_profiles
ADD COLUMN IF NOT EXISTS contacted_by UUID REFERENCES app.users(id);

View File

@ -16,6 +16,10 @@ class TalentRunStart(BaseModel):
keywords: str | None = None
class OutreachStatusUpdate(BaseModel):
outreach_status: str
@router.post("/talent/runs/start")
async def start_talent_run(
payload: TalentRunStart,
@ -102,6 +106,25 @@ async def fetch_talent_profile(
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/talent/profiles/outreach")
async def set_talent_outreach_status(
payload: OutreachStatusUpdate,
profile_id: str = Query(...),
current_user: dict = Depends(require_permission(PermissionTag.TALENT_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service = Talent(session=session)
data = await service.set_outreach_status(
profile_id, payload.model_dump(exclude_unset=True), current_user
)
return JSONResponse(content={"data": data, "status_code": 200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/talent/profiles/delete")
async def delete_talent_profile(
profile_id: str = Query(...),

58
backend/talent/enums.py Normal file
View File

@ -0,0 +1,58 @@
from enum import Enum
class OutreachStatus(str, Enum):
"""Manual outreach funnel on talent_profiles.outreach_status.
The app never sends messages: a recruiter shortlists a sourced profile,
reaches out on LinkedIn themselves, then marks the profile contacted.
Values are the wire form the Find Talent screen PATCHes; labels are what
the tabs render.
"""
SOURCED = "sourced"
SHORTLISTED = "shortlisted"
CONTACTED = "contacted"
@property
def label(self) -> str:
return _LABELS[self]
@classmethod
def parse(cls, value):
"""Accept the stored value or the UI label. None if neither matches."""
raw = (value or "").strip()
if not raw:
return None
lowered = raw.lower()
for member in cls:
if raw == member.value or lowered == member.value or raw == member.label:
return member
return None
@classmethod
def values(cls) -> tuple[str, ...]:
return tuple(m.value for m in cls)
@classmethod
def as_list(cls) -> list[dict]:
return [{"value": m.value, "label": m.label} for m in cls]
_LABELS = {
OutreachStatus.SOURCED: "Sourced",
OutreachStatus.SHORTLISTED: "Shortlisted",
OutreachStatus.CONTACTED: "Contacted",
}
# One-way funnel with single-step undo. sourced -> contacted is disallowed so
# every contacted row carries shortlist stamps, and contacted -> sourced is
# disallowed so un-shortlisting a contacted profile takes two deliberate steps.
ALLOWED_OUTREACH_TRANSITIONS = {
OutreachStatus.SOURCED.value: {OutreachStatus.SHORTLISTED.value},
OutreachStatus.SHORTLISTED.value: {
OutreachStatus.SOURCED.value,
OutreachStatus.CONTACTED.value,
},
OutreachStatus.CONTACTED.value: {OutreachStatus.SHORTLISTED.value},
}

View File

@ -13,6 +13,8 @@ from sqlalchemy import DateTime, JSON, UniqueConstraint, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, SQLModel, select
from talent.enums import ALLOWED_OUTREACH_TRANSITIONS, OutreachStatus
def _now() -> datetime:
return datetime.now(timezone.utc)
@ -24,8 +26,9 @@ TERMINAL_RUN_STATUSES = ("succeeded", "failed", "timed_out", "aborted")
# Profile fields refreshed when a later run re-finds the same person. Kept at
# module level: an underscore-prefixed class attribute on a SQLModel becomes a
# Pydantic ModelPrivateAttr, which is not iterable. `is_deleted` is deliberately
# absent — a dismissed profile stays dismissed.
# Pydantic ModelPrivateAttr, which is not iterable. `is_deleted` and the
# outreach_* columns are deliberately absent — a dismissed profile stays
# dismissed, and a re-run must not reset a recruiter's shortlist/contact state.
MUTABLE_PROFILE_FIELDS = (
"public_id", "full_name", "headline", "location",
"current_title", "current_company", "avatar_url", "summary", "skills",
@ -197,6 +200,14 @@ class TalentProfiles(SQLModel, table=True):
skills: list = Field(default_factory=list, sa_type=JSON)
match_score: int | None = Field(default=None)
raw: dict = Field(default_factory=dict, sa_type=JSON)
# Manual outreach funnel (OutreachStatus): sourced -> shortlisted ->
# contacted. server_default is load-bearing: the column arrives as an ALTER
# on a populated table (017_talent_outreach.sql).
outreach_status: str = Field(default="sourced", sa_column_kwargs={"server_default": "sourced"})
shortlisted_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
shortlisted_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
contacted_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
contacted_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
first_seen_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
last_seen_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
@ -282,6 +293,40 @@ class TalentProfiles(SQLModel, table=True):
statement = select(cls).where(cls.id == uid, cls.is_deleted == False) # noqa: E712
return (await session.execute(statement)).scalars().first()
@classmethod
async def set_outreach_status(cls, session: AsyncSession, record_id, status: str, *, actor=None):
"""Idempotent outreach-funnel writer. Raises ValueError on a disallowed
transition; the stamp pair for a stage is set on entry and cleared on
undo, so shortlist stamps survive contacted and its undo.
"""
row = await cls.get_profile_by_id(session, record_id)
if not row:
return None
previous = row.outreach_status
if previous == status:
return row
if status not in ALLOWED_OUTREACH_TRANSITIONS.get(previous, set()):
raise ValueError(f"cannot move a {previous} profile to {status}")
actor_id = TalentRuns._as_uuid(actor)
if status == OutreachStatus.SHORTLISTED.value:
if previous == OutreachStatus.CONTACTED.value:
row.contacted_at = None
row.contacted_by = None
else:
row.shortlisted_at = _now()
row.shortlisted_by = actor_id
elif status == OutreachStatus.CONTACTED.value:
row.contacted_at = _now()
row.contacted_by = actor_id
else: # back to sourced
row.shortlisted_at = None
row.shortlisted_by = None
row.outreach_status = status
row.updated_at = _now()
session.add(row)
await session.commit()
return row
@classmethod
async def soft_delete_profile(cls, session: AsyncSession, record_id):
row = await cls.get_profile_by_id(session, record_id)

View File

@ -18,9 +18,12 @@ def serialize_talent_run(row) -> dict:
}
def serialize_talent_profile(row) -> dict:
def serialize_talent_profile(row, *, user_names=None) -> dict:
# `raw` stays server-side: it is an actor-shaped blob that can be large and
# is only needed for debugging/re-mapping, not for the profile cards.
# `user_names` maps str(user_id) -> name for the shortlisted_by/contacted_by
# stamps (resolved by the caller in one query, Users.names_by_ids).
names = user_names or {}
return {
"id": str(row.id) if row.id else None,
"job_post_id": str(row.job_post_id) if row.job_post_id else None,
@ -35,16 +38,23 @@ def serialize_talent_profile(row) -> dict:
"summary": row.summary,
"skills": row.skills or [],
"match_score": row.match_score,
"outreach_status": row.outreach_status,
"shortlisted_at": row.shortlisted_at.isoformat() if row.shortlisted_at else None,
"shortlisted_by": str(row.shortlisted_by) if row.shortlisted_by else None,
"shortlisted_by_name": names.get(str(row.shortlisted_by)) if row.shortlisted_by else None,
"contacted_at": row.contacted_at.isoformat() if row.contacted_at else None,
"contacted_by": str(row.contacted_by) if row.contacted_by else None,
"contacted_by_name": names.get(str(row.contacted_by)) if row.contacted_by else None,
"first_seen_at": row.first_seen_at.isoformat() if row.first_seen_at else None,
"last_seen_at": row.last_seen_at.isoformat() if row.last_seen_at else None,
}
def serialize_talent_profile_detail(row) -> dict:
def serialize_talent_profile_detail(row, *, user_names=None) -> dict:
# The card payload plus employment/education history unpacked from the raw
# actor item. Detail is fetched one profile at a time, so the extra weight
# never rides along with the list endpoint.
data = serialize_talent_profile(row)
data = serialize_talent_profile(row, user_names=user_names)
data["experience"] = extract_experience(row.raw or {})
data["education"] = extract_education(row.raw or {})
return data

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.enums import OutreachStatus
from talent.matching import annotate_applications
from talent.models import TalentProfiles, TalentRuns
from talent.serializers import (
@ -11,6 +12,7 @@ from talent.serializers import (
serialize_talent_profile_detail,
serialize_talent_run,
)
from users.models import Users
def _search_basis(actor_input: dict) -> dict:
@ -201,12 +203,19 @@ class Talent:
rows, total = await TalentRuns.fetch_runs(self.session, job_post_id=job_post_id)
return [serialize_talent_run(r) for r in rows], total
async def _outreach_actor_names(self, rows) -> dict:
return await Users.names_by_ids(
self.session,
[r.shortlisted_by for r in rows] + [r.contacted_by for r in rows],
)
async def fetch_profiles(self, job_post_id, search=None, top=None, skip=0):
await self._get_job(job_post_id)
rows, total = await TalentProfiles.fetch_profiles(
self.session, job_post_id=job_post_id, search=search, top=top, skip=skip
)
profiles = [serialize_talent_profile(r) for r in rows]
names = await self._outreach_actor_names(rows)
profiles = [serialize_talent_profile(r, user_names=names) for r in rows]
profiles = await annotate_applications(self.session, profiles)
return profiles, total
@ -214,10 +223,32 @@ class Talent:
row = await TalentProfiles.get_profile_by_id(self.session, profile_id)
if not row:
raise HTTPException(status_code=404, detail="Talent profile not found")
data = serialize_talent_profile_detail(row)
names = await self._outreach_actor_names([row])
data = serialize_talent_profile_detail(row, user_names=names)
await annotate_applications(self.session, [data])
return data
async def set_outreach_status(self, profile_id, payload, current_user):
parsed = OutreachStatus.parse(payload.get("outreach_status") or "")
if parsed is None:
raise HTTPException(
status_code=422,
detail=f"outreach_status must be one of {', '.join(OutreachStatus.values())}",
)
try:
row = await TalentProfiles.set_outreach_status(
self.session,
profile_id,
parsed.value,
actor=(current_user or {}).get("id"),
)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc))
if not row:
raise HTTPException(status_code=404, detail="Talent profile not found")
names = await self._outreach_actor_names([row])
return serialize_talent_profile(row, user_names=names)
async def delete_profile(self, profile_id):
row = await TalentProfiles.soft_delete_profile(self.session, profile_id)
if not row:

View File

@ -47,6 +47,20 @@ export function getProfile(profileId) {
return request('/talent/profiles/fetch_by_id', { params: { profile_id: profileId } })
}
/**
* Move a profile along the manual outreach funnel. Needs talent.edit.
* Allowed: sourced->shortlisted, shortlisted->sourced (un-shortlist),
* shortlisted->contacted, contacted->shortlisted (undo). The app sends no
* messages "contacted" records that the recruiter reached out on LinkedIn.
*/
export function setOutreachStatus(profileId, outreachStatus) {
return request('/talent/profiles/outreach', {
method: 'PATCH',
params: { profile_id: profileId },
body: { outreach_status: outreachStatus },
})
}
/** Dismiss a profile (soft delete; re-runs will not resurrect it). Needs talent.delete. */
export function deleteProfile(profileId) {
return request('/talent/profiles/delete', {
@ -89,6 +103,11 @@ export function toProfileView(row) {
summary: row.summary ?? null,
skills: Array.isArray(row.skills) ? row.skills : [],
matchScore: row.match_score ?? null,
outreachStatus: row.outreach_status ?? 'sourced',
shortlistedAt: row.shortlisted_at ? new Date(row.shortlisted_at) : null,
shortlistedByName: row.shortlisted_by_name ?? null,
contactedAt: row.contacted_at ? new Date(row.contacted_at) : null,
contactedByName: row.contacted_by_name ?? 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 }

View File

@ -14,7 +14,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import PageHeader from '../ui/PageHeader'
import { Badge, EmptyState, Icon } from '../ui/primitives'
import { Tabs } from '../ui/Tabs'
import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
@ -30,6 +32,15 @@ const RUN_BADGE = {
aborted: ['b-amber', 'Aborted'],
}
/* Manual outreach funnel: sourced -> shortlisted -> contacted, one-step undo.
The app sends nothing "contacted" records that the recruiter messaged the
person on LinkedIn themselves. Mirror of backend/talent/enums.py. */
const OUTREACH_TOAST = {
shortlisted: 'Added to shortlist',
contacted: 'Marked as contacted',
sourced: 'Removed from shortlist',
}
async function fetchJobs() {
const res = await candidatesApi.listJobs()
const rows = Array.isArray(res?.data) ? res.data : []
@ -155,7 +166,33 @@ function AppliedBadge({ applied }) {
)
}
function ProfileCard({ p, onView, onDismiss, dismissing }) {
/** Card/modal-shared outreach controls: star = shortlist toggle, check = contacted. */
function outreachProps(p) {
const s = p.outreachStatus
return {
star: {
shown: true,
active: s !== 'sourced',
disabled: s === 'contacted',
next: s === 'sourced' ? 'shortlisted' : 'sourced',
tip:
s === 'sourced' ? 'Shortlist for outreach'
: s === 'shortlisted' ? 'Shortlisted — click to remove'
: 'Undo Contacted first to un-shortlist',
},
check: {
shown: s !== 'sourced',
active: s === 'contacted',
next: s === 'shortlisted' ? 'contacted' : 'shortlisted',
tip:
s === 'shortlisted'
? 'Mark contacted (after messaging on LinkedIn)'
: `Contacted ${p.contactedAt ? fmtDate(p.contactedAt) : ''}${p.contactedByName ? ` by ${p.contactedByName}` : ''} — click to undo`,
},
}
}
function ProfileCard({ p, onView, onDismiss, dismissing, canEdit, onOutreach, outreachBusy, showContacted }) {
const crit = p.summary || p.headline || ''
const shown = p.skills.slice(0, 5)
const more = p.skills.length - shown.length
@ -203,9 +240,38 @@ function ProfileCard({ p, onView, onDismiss, dismissing }) {
>
<Icon name="eye" />
</button>
{canEdit && (() => {
const o = outreachProps(p)
return (
<>
<button
className="act-btn"
data-tip={o.star.tip}
aria-label={o.star.tip}
disabled={outreachBusy || o.star.disabled}
style={o.star.active ? { color: 'var(--warning)' } : undefined}
onClick={(e) => { e.stopPropagation(); onOutreach(p, o.star.next) }}
>
<Icon name="star" />
</button>
{o.check.shown && showContacted && (
<button
className="act-btn"
data-tip={o.check.tip}
aria-label={o.check.tip}
disabled={outreachBusy}
style={o.check.active ? { color: 'var(--success)' } : undefined}
onClick={(e) => { e.stopPropagation(); onOutreach(p, o.check.next) }}
>
<Icon name="check-circle" />
</button>
)}
</>
)
})()}
<button
className="act-btn"
data-tip="Dismiss"
data-tip="Dismiss (removes from all tabs)"
aria-label="Dismiss profile"
disabled={dismissing}
onClick={(e) => { e.stopPropagation(); onDismiss(p) }}
@ -219,12 +285,13 @@ function ProfileCard({ p, onView, onDismiss, dismissing }) {
}
/** Full LinkedIn profile: hero + about + skills + employment/education history. */
function TalentProfileDetail({ profileId, onClose }) {
function TalentProfileDetail({ profileId, onClose, canEdit, onOutreach, outreachBusy }) {
const detailQuery = useQuery({
queryKey: qk.talent.profile(profileId),
queryFn: () => talentApi.getProfile(profileId),
})
const p = detailQuery.data?.data ? talentApi.toProfileDetailView(detailQuery.data.data) : null
const o = p ? outreachProps(p) : null
return (
<Modal
@ -234,6 +301,30 @@ function TalentProfileDetail({ profileId, onClose }) {
onClose={onClose}
footer={
<>
{p && canEdit && (
<>
<button
className="btn"
data-tip={o.star.tip}
disabled={outreachBusy || o.star.disabled}
onClick={() => onOutreach(p, o.star.next)}
>
<Icon name="star" />
{p.outreachStatus === 'sourced' ? 'Shortlist' : 'Un-shortlist'}
</button>
{o.check.shown && (
<button
className="btn"
data-tip={o.check.tip}
disabled={outreachBusy}
onClick={() => onOutreach(p, o.check.next)}
>
<Icon name="check-circle" />
{p.outreachStatus === 'contacted' ? 'Undo contacted' : 'Mark contacted'}
</button>
)}
</>
)}
{p && (
<a className="btn btn-primary" href={p.linkedinUrl} target="_blank" rel="noreferrer">
<Icon name="linkedin" /> Open LinkedIn
@ -262,6 +353,18 @@ function TalentProfileDetail({ profileId, onClose }) {
{p.location && <Badge className="b-plain b-indigo badge-plain">{p.location}</Badge>}
<Badge className="b-gray">LinkedIn</Badge>
<AppliedBadge applied={p.alreadyApplied} />
{p.shortlistedAt && (
<Badge className="b-amber">
<Icon name="star" /> Shortlisted {fmtDate(p.shortlistedAt)}
{p.shortlistedByName ? ` by ${p.shortlistedByName}` : ''}
</Badge>
)}
{p.contactedAt && (
<Badge className="b-green">
<Icon name="check-circle" /> Contacted {fmtDate(p.contactedAt)}
{p.contactedByName ? ` by ${p.contactedByName}` : ''}
</Badge>
)}
{p.lastSeenAt && (
<Badge className="b-plain b-indigo badge-plain">Found {fmtDate(p.lastSeenAt)}</Badge>
)}
@ -331,8 +434,11 @@ function TalentProfileDetail({ profileId, onClose }) {
export default function Talent() {
const { toast } = useToast()
const qc = useQueryClient()
const { can } = useAuth()
const canEdit = can('talent.edit')
const [jobId, setJobId] = useState('')
const [tab, setTab] = useState('all')
const [activeRunId, setActiveRunId] = useState(null)
const [confirmOpen, setConfirmOpen] = useState(false)
const [search, setSearch] = useState('')
@ -399,14 +505,25 @@ export default function Talent() {
: [],
[profilesQuery.data],
)
const counts = useMemo(() => ({
all: profiles.length,
shortlisted: profiles.filter((p) => p.outreachStatus === 'shortlisted').length,
contacted: profiles.filter((p) => p.outreachStatus === 'contacted').length,
}), [profiles])
const tabs = [
{ key: 'all', label: 'All', count: counts.all },
{ key: 'shortlisted', label: 'Shortlisted', count: counts.shortlisted },
{ key: 'contacted', label: 'Contacted', count: counts.contacted },
]
const visible = useMemo(() => {
const scoped = tab === 'all' ? profiles : profiles.filter((p) => p.outreachStatus === tab)
const q = search.trim().toLowerCase()
if (!q) return profiles
return profiles.filter((p) =>
if (!q) return scoped
return scoped.filter((p) =>
[p.name, p.headline, p.currentCompany, p.currentTitle, p.location]
.some((f) => f && f.toLowerCase().includes(q)),
)
}, [profiles, search])
}, [profiles, search, tab])
const effectiveLocation =
locationChoice === CUSTOM_LOCATION ? customLocation.trim() : locationChoice
@ -439,6 +556,17 @@ export default function Talent() {
onError: (err) => toast(friendlyAuthError(err, 'Could not dismiss the profile'), 'error'),
})
const outreach = useMutation({
mutationFn: ({ id, status }) => talentApi.setOutreachStatus(id, status),
onSuccess: (_res, vars) => {
qc.invalidateQueries({ queryKey: qk.talent.profiles({ jobId }) })
qc.invalidateQueries({ queryKey: qk.talent.profile(vars.id) })
toast(OUTREACH_TOAST[vars.status] ?? 'Updated', 'success')
},
onError: (err) => toast(friendlyAuthError(err, 'Could not update outreach status'), 'error'),
})
const handleOutreach = (profile, status) => outreach.mutate({ id: profile.id, status })
const statusRun = runInFlight || !latestRun ? activeRun : latestRun
const [badgeCls, badgeLabel] = statusRun ? (RUN_BADGE[statusRun.status] ?? ['b-gray', statusRun.status]) : []
@ -461,6 +589,7 @@ export default function Talent() {
onChange={(e) => {
const nextId = e.target.value
setJobId(nextId)
setTab('all')
setActiveRunId(null)
setSearch('')
setVisibleCount(10)
@ -542,7 +671,13 @@ export default function Talent() {
)
) : (
<>
<div className="flex items-center gap-8 mb-18">
<Tabs
className="tabs tabs-wrap"
value={tab}
onChange={(t) => { setTab(t); setVisibleCount(10) }}
tabs={tabs}
/>
<div className="flex items-center gap-8 mb-18" style={{ marginTop: 12 }}>
<div className="toolbar-search">
<Icon name="search" />
<input
@ -555,24 +690,41 @@ export default function Talent() {
{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>
{visible.length === 0 && tab !== 'all' && !search.trim() ? (
tab === 'shortlisted' ? (
<EmptyState icon="star" title="No shortlisted profiles yet">
Star a profile in the All tab to build your outreach list.
</EmptyState>
) : (
<EmptyState icon="check-circle" title="No one marked contacted yet">
After messaging a shortlisted person on LinkedIn, mark them contacted
so the team knows they have been reached.
</EmptyState>
)
) : (
<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}
canEdit={canEdit}
onOutreach={handleOutreach}
outreachBusy={outreach.isPending}
showContacted={tab !== 'all'}
/>
))}
</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>
) : (
) : tab === 'all' ? (
<button
className="btn"
disabled={runInFlight || starting.isPending}
@ -581,13 +733,19 @@ export default function Talent() {
<Icon name={runInFlight ? 'clock' : 'search'} />
{runInFlight ? 'Sourcing…' : 'Search LinkedIn for more'}
</button>
)}
) : null}
</div>
</>
)}
{viewProfileId && (
<TalentProfileDetail profileId={viewProfileId} onClose={() => setViewProfileId(null)} />
<TalentProfileDetail
profileId={viewProfileId}
onClose={() => setViewProfileId(null)}
canEdit={canEdit}
onOutreach={handleOutreach}
outreachBusy={outreach.isPending}
/>
)}
{confirmOpen && (