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
parent
454af32160
commit
4b93246d43
|
|
@ -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);
|
||||
|
|
@ -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(...),
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
Loading…
Reference in New Issue