Merge pull request 'Correct_Recieved_time' (#39) from Correct_Recieved_time into main
Deploy to S3 / deploy (push) Successful in 38s
Details
Deploy to S3 / deploy (push) Successful in 38s
Details
Reviewed-on: #39pull/40/head^2
commit
615d127775
|
|
@ -13,6 +13,7 @@
|
|||
**/.env
|
||||
**/.env.*
|
||||
!**/.env.example
|
||||
backend/credentials/*.json
|
||||
|
||||
**/__pycache__/
|
||||
**/*.py[cod]
|
||||
|
|
|
|||
|
|
@ -60,6 +60,9 @@ frontend/dist/
|
|||
# Uploaded content — user data, never in git
|
||||
backend/uploads/
|
||||
|
||||
# Google OAuth ADC / Desktop client secrets — never commit
|
||||
backend/credentials/*.json
|
||||
|
||||
**.pdf
|
||||
# Per-machine alembic autogen revisions only — the old bare `**_**_**.py`
|
||||
# also swallowed any module with two underscores (e.g. test_talent_plugins.py).
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
{
|
||||
"account": "",
|
||||
"type": "authorized_user",
|
||||
"client_id": "679334897177-ufal3rogbg8cgm3qcren6pv20phqd7nl.apps.googleusercontent.com",
|
||||
"client_secret": "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",
|
||||
"refresh_token": "1//03C8LMkk-9mSyCgYIARAAGAMSNwF-L9IrAcPhShsp3cDeprSlqI-P6lArpmbyzu-PcKiLfQ5gC3H_MEk930IaKfewy3cxP3T0Oo8",
|
||||
"type": "authorized_user",
|
||||
"universe_domain": "googleapis.com"
|
||||
"universe_domain": "googleapis.com",
|
||||
"account": "ahmed.mujtaba@utopiabrands.com",
|
||||
"token": "ya29.a0AdMD6Eg_6meQs84gTmiyhzZp7C-JlZeJU6-ECm6twwAcMqvfvRvyvs5LQGbAhHarHzZF-jiU-sicebJmxXIN4l6hNDXoaHcrojuhq--hj2oSBWojiEKaGIgLKPM8frdspz_wVANrwkFwpIKhN3RpWID9mJCt7N6IFaNrZtgStakdF0sVCKKVttE7qWK0vIvJT3HZHpVbaCgYKAX8SARASFQHGX2MiQvJqWStYQDgYFK4E6GJ9Zw0207",
|
||||
"expiry": "2026-08-31T09:52:09Z",
|
||||
"quota_project_id": "hrms-ats-portal"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,13 @@ family and lets g_sheet/views.py translate that into HTTPException.
|
|||
|
||||
Auth reuses the credentials already on disk (authorized_user ADC + a valid refresh
|
||||
token). Nothing here launches a browser, runs InstalledAppFlow, or reads stdin.
|
||||
After a successful refresh, store_authorized_session writes the ADC JSON back so
|
||||
the session can be copied to Linux prod. Re-auth lives in g_sheet/store_session.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
|
|
@ -31,12 +34,11 @@ from g_sheet.enums import (
|
|||
MonthNormalisation,
|
||||
)
|
||||
|
||||
load_dotenv()
|
||||
|
||||
logger=logging.getLogger("g_sheet.plugins")
|
||||
|
||||
# backend/ — GOOGLE_APPLICATION_CREDENTIALS is stored relative to it ("credentials/...").
|
||||
ROOT=Path(__file__).resolve().parent.parent
|
||||
load_dotenv(ROOT/".env")
|
||||
|
||||
SCOPES=[
|
||||
"https://www.googleapis.com/auth/spreadsheets",
|
||||
|
|
@ -47,6 +49,9 @@ SPREADSHEET_ID=os.getenv("SPREADSHEET_ID")
|
|||
SPREADSHEET_NAME=os.getenv("SPREADSHEET_NAME")
|
||||
SPREADSHEET_URL=os.getenv("SPREADSHEET_URL")
|
||||
GOOGLE_APPLICATION_CREDENTIALS=os.getenv("GOOGLE_APPLICATION_CREDENTIALS")
|
||||
GOOGLE_OAUTH_CLIENT_ID_FILE=os.getenv("GOOGLE_OAUTH_CLIENT_ID_FILE")
|
||||
GOOGLE_CLOUD_PROJECT=os.getenv("GOOGLE_CLOUD_PROJECT")
|
||||
GOOGLE_ACCOUNT=os.getenv("GOOGLE_ACCOUNT")
|
||||
|
||||
# 429 and 5xx are transient; every other 4xx is a bad request that a retry repeats.
|
||||
RETRY_ATTEMPTS=3
|
||||
|
|
@ -94,6 +99,73 @@ def resolve_credentials_path(credentials_path=None):
|
|||
return path
|
||||
|
||||
|
||||
def resolve_client_secret_path(client_secret_path=None):
|
||||
"""Absolute path to the Desktop OAuth client json (credentials/client_secret.json)."""
|
||||
raw=client_secret_path or GOOGLE_OAUTH_CLIENT_ID_FILE
|
||||
if not raw:
|
||||
return None
|
||||
path=Path(raw)
|
||||
if not path.is_absolute():
|
||||
path=ROOT/path
|
||||
return path
|
||||
|
||||
|
||||
def _expiry_iso(expiry):
|
||||
if expiry is None:
|
||||
return None
|
||||
if expiry.tzinfo is None:
|
||||
return expiry.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
return expiry.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _authorized_user_adc(credentials):
|
||||
"""gcloud-compatible authorized_user payload. google.auth.default() requires type."""
|
||||
payload={
|
||||
"type":"authorized_user",
|
||||
"client_id":credentials.client_id,
|
||||
"client_secret":credentials.client_secret,
|
||||
"refresh_token":credentials.refresh_token,
|
||||
"universe_domain":getattr(credentials,"universe_domain",None) or "googleapis.com",
|
||||
"account":getattr(credentials,"account",None) or GOOGLE_ACCOUNT or "",
|
||||
}
|
||||
token=getattr(credentials,"token",None)
|
||||
if token:
|
||||
payload["token"]=token
|
||||
expiry=_expiry_iso(getattr(credentials,"expiry",None))
|
||||
if expiry:
|
||||
payload["expiry"]=expiry
|
||||
if GOOGLE_CLOUD_PROJECT:
|
||||
payload["quota_project_id"]=GOOGLE_CLOUD_PROJECT
|
||||
return payload
|
||||
|
||||
|
||||
def store_authorized_session(credentials,credentials_path=None):
|
||||
"""Persist an authorized_user session to GOOGLE_APPLICATION_CREDENTIALS.
|
||||
|
||||
Service-account key files are left untouched (no refresh_token to rotate).
|
||||
A persist failure is logged, never raised — the in-memory token still works.
|
||||
"""
|
||||
path=resolve_credentials_path(credentials_path)
|
||||
if path is None:
|
||||
logger.warning("GOOGLE_APPLICATION_CREDENTIALS is not configured; session not stored")
|
||||
return None
|
||||
if not getattr(credentials,"refresh_token",None) or not getattr(credentials,"client_id",None):
|
||||
return None
|
||||
try:
|
||||
path.parent.mkdir(parents=True,exist_ok=True)
|
||||
tmp=path.with_name(path.name+".tmp")
|
||||
tmp.write_text(json.dumps(_authorized_user_adc(credentials),indent=2)+"\n",encoding="utf-8")
|
||||
tmp.replace(path)
|
||||
try:
|
||||
os.chmod(path,0o600)
|
||||
except OSError:
|
||||
pass
|
||||
except OSError as e:
|
||||
logger.warning("could not persist Google authorized session: %s",e)
|
||||
return None
|
||||
return path
|
||||
|
||||
|
||||
def load_credentials(credentials_path=None,scopes=None):
|
||||
"""Build scoped ADC credentials and refresh them once. Never prompts."""
|
||||
path=resolve_credentials_path(credentials_path)
|
||||
|
|
@ -108,10 +180,11 @@ def load_credentials(credentials_path=None,scopes=None):
|
|||
raise
|
||||
except Exception as e:
|
||||
raise SheetsAuthError(f"Google credential refresh failed: {e}")
|
||||
store_authorized_session(credentials,credentials_path)
|
||||
return credentials
|
||||
|
||||
|
||||
def ensure_fresh(credentials):
|
||||
def ensure_fresh(credentials,credentials_path=None):
|
||||
"""Refresh only when the token has actually gone stale — not on every call."""
|
||||
if credentials is None:
|
||||
raise SheetsAuthError("Google credentials are not initialised")
|
||||
|
|
@ -121,6 +194,7 @@ def ensure_fresh(credentials):
|
|||
credentials.refresh(Request())
|
||||
except Exception as e:
|
||||
raise SheetsAuthError(f"Google credential refresh failed: {e}")
|
||||
store_authorized_session(credentials,credentials_path)
|
||||
return credentials
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
"""Capture a Google authorized_user session into credentials/.
|
||||
|
||||
Run on a machine with a browser (Windows/macOS). Copy the resulting JSON to
|
||||
Linux prod — the API never opens a browser.
|
||||
|
||||
cd backend
|
||||
python g_sheet/store_session.py
|
||||
python g_sheet/store_session.py --force # re-consent, mint a new refresh token
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# `python g_sheet/store_session.py` puts this file's dir on sys.path, not backend/.
|
||||
_BACKEND=Path(__file__).resolve().parent.parent
|
||||
if str(_BACKEND) not in sys.path:
|
||||
sys.path.insert(0,str(_BACKEND))
|
||||
|
||||
from g_sheet.plugins import (
|
||||
SCOPES,
|
||||
SheetsAuthError,
|
||||
load_credentials,
|
||||
resolve_client_secret_path,
|
||||
resolve_credentials_path,
|
||||
store_authorized_session,
|
||||
)
|
||||
|
||||
|
||||
def _authorize_browser(client_secret_path):
|
||||
try:
|
||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||
except ImportError as e:
|
||||
raise SystemExit(
|
||||
"google-auth-oauthlib is required for browser login. "
|
||||
"pip install google-auth-oauthlib==1.4.0"
|
||||
) from e
|
||||
if client_secret_path is None or not client_secret_path.exists():
|
||||
raise SystemExit(
|
||||
"OAuth client file not found. Set GOOGLE_OAUTH_CLIENT_ID_FILE "
|
||||
"(credentials/client_secret.json)."
|
||||
)
|
||||
flow=InstalledAppFlow.from_client_secrets_file(str(client_secret_path),SCOPES)
|
||||
return flow.run_local_server(port=0,prompt="consent",access_type="offline")
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser=argparse.ArgumentParser(description="Store a Google authorized_user session on disk.")
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Ignore the existing ADC file and open a browser consent screen.",
|
||||
)
|
||||
args=parser.parse_args(argv)
|
||||
path=resolve_credentials_path()
|
||||
if path is None:
|
||||
raise SystemExit("GOOGLE_APPLICATION_CREDENTIALS is not set.")
|
||||
credentials=None
|
||||
if not args.force:
|
||||
try:
|
||||
credentials=load_credentials()
|
||||
except SheetsAuthError as e:
|
||||
print(f"existing session unusable ({e}); opening browser…",file=sys.stderr)
|
||||
if credentials is None:
|
||||
credentials=_authorize_browser(resolve_client_secret_path())
|
||||
stored=store_authorized_session(credentials)
|
||||
else:
|
||||
stored=path
|
||||
if stored is None:
|
||||
raise SystemExit("failed to write the authorized session file")
|
||||
print(f"stored authorized session: {stored}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__=="__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -85,13 +85,13 @@ class SheetClient(Sheet):
|
|||
their own client.
|
||||
"""
|
||||
if self.client is not None:
|
||||
return ensure_fresh(self.credentials) and self.client
|
||||
return ensure_fresh(self.credentials,self.credentials_path) and self.client
|
||||
with self._lock:
|
||||
if self.client is None:
|
||||
self.credentials=load_credentials(self.credentials_path,self.scopes)
|
||||
self.client=build_sheets_client(self.credentials)
|
||||
else:
|
||||
ensure_fresh(self.credentials)
|
||||
ensure_fresh(self.credentials,self.credentials_path)
|
||||
return self.client
|
||||
|
||||
async def _values(self):
|
||||
|
|
|
|||
|
|
@ -820,7 +820,7 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
):
|
||||
|
||||
statement = cls._apply_filters(
|
||||
select(cls).order_by(cls.created_at.desc(),cls.id.desc()),
|
||||
select(cls).order_by(cls.message_received_time.desc(),cls.id.desc()),
|
||||
search, isread, application_status, assigned, is_duplicate,
|
||||
no_suggestions, processing_state,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -864,7 +864,10 @@ class Interviews(SQLModel, table=True):
|
|||
def _with_inbox_message(cls):
|
||||
from inbox.models import Inbox
|
||||
|
||||
return selectinload(cls.inbox).selectinload(Inbox.messages)
|
||||
return selectinload(cls.inbox).options(
|
||||
selectinload(Inbox.messages),
|
||||
selectinload(Inbox.user),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def get_interview_by_id(cls, session: AsyncSession, record_id):
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
def serialize_interview(row, *, job_title=None) -> dict:
|
||||
inbox=getattr(row,"inbox",None)
|
||||
user=getattr(inbox,"user",None) if inbox else None
|
||||
uid=getattr(user,"id",None) or getattr(row,"user_id",None)
|
||||
return {
|
||||
"id": str(row.id),
|
||||
"inbox_id": row.inbox_id,
|
||||
|
|
@ -9,6 +10,7 @@ def serialize_interview(row, *, job_title=None) -> dict:
|
|||
"interview_type": row.interview_type,
|
||||
"interview_status": row.interview_status,
|
||||
"candidate_name": user.name if user else None,
|
||||
"user_id": str(uid) if uid else None,
|
||||
"job_title": job_title or None,
|
||||
"graph_event_id": row.graph_event_id or None,
|
||||
"web_link": row.web_link or None,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -50,6 +50,7 @@ openpyxl==3.1.5
|
|||
google-api-python-client==2.198.0 # Sheets v4 client in g_sheet/plugins.py
|
||||
google-auth==2.56.3 # ADC + refresh in g_sheet/plugins.py
|
||||
google-auth-httplib2==0.4.1 # transport used by googleapiclient
|
||||
google-auth-oauthlib==1.4.0 # InstalledAppFlow in g_sheet/store_session.py only
|
||||
|
||||
# --- AWS S3 (s3/) ----------------------------------------------------------
|
||||
boto3==1.40.49 # S3 PutObject / DeleteObject in s3/plugins.py
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -51,13 +51,18 @@ x-backend-env: &backend-env
|
|||
|
||||
# Shared CV storage. Named volume so API + workers see the same files.
|
||||
# Optional docker-compose.dev.yml remounts ./backend/inbox/decoded_attachments.
|
||||
# Google authorized_user ADC is bind-mounted so a refresh-token rotation on
|
||||
# disk survives image rebuilds. Copy the JSON onto the host; do not bake it.
|
||||
x-attachments: &attachments
|
||||
- attachments-data:/app/inbox/decoded_attachments
|
||||
- ./backend/credentials:/app/credentials
|
||||
|
||||
x-backend-service: &backend-service
|
||||
build: *backend-build
|
||||
image: hrms-backend:local
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- ./backend/credentials:/app/credentials
|
||||
env_file:
|
||||
# backend/.env is the source of truth (plain DB_* + PROD_ENV).
|
||||
- ./backend/.env
|
||||
|
|
|
|||
|
|
@ -95,10 +95,12 @@ export function update(interviewId, { instant, type, status } = {}) {
|
|||
* render.
|
||||
*
|
||||
* serialize_interview returns the interview columns plus optional calendar sync
|
||||
* fields (`graph_event_id`, `web_link`) and `job_title`. Meeting mode, duration,
|
||||
* interviewer list and feedback verdict still have no source — they stay absent
|
||||
* rather than defaulted. Screens that already hydrate `jobTitle` from the
|
||||
* application row keep doing so as a fallback.
|
||||
* fields (`graph_event_id`, `web_link`), `job_title`, and `user_id` (inbox.user
|
||||
* id, falling back to the denorm column). Meeting mode, duration, interviewer
|
||||
* list and feedback verdict still have no source — they stay absent rather than
|
||||
* defaulted. Screens that already hydrate `jobTitle` from the application row
|
||||
* keep doing so as a fallback; `userId` prefers the interview row so unassigned
|
||||
* applications still open a profile.
|
||||
*/
|
||||
export function toInterviewView(row) {
|
||||
const whenRaw = row.interview_date || row.interview_time
|
||||
|
|
@ -111,6 +113,7 @@ export function toInterviewView(row) {
|
|||
status: row.interview_status || 'Scheduled',
|
||||
when: when && !Number.isNaN(when.getTime()) ? when : null,
|
||||
jobTitle: row.job_title || null,
|
||||
userId: row.user_id ?? null,
|
||||
graphEventId: row.graph_event_id || null,
|
||||
webLink: row.web_link || null,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -71,8 +71,9 @@ export default function Calendar() {
|
|||
},
|
||||
})
|
||||
|
||||
/* Job title and the candidate's user id are not on the interview row; the
|
||||
application supplies both. One extra request for the whole screen. */
|
||||
/* Job title is hydrated from the pipeline board; user id prefers the
|
||||
interview row so unassigned applications (absent from the board) still
|
||||
open a profile. One extra request for the whole screen. */
|
||||
const appsQuery = useApplications()
|
||||
const appByInbox = useMemo(() => byInboxId(appsQuery.data), [appsQuery.data])
|
||||
|
||||
|
|
@ -84,7 +85,7 @@ export default function Calendar() {
|
|||
return {
|
||||
...iv,
|
||||
jobTitle: iv.jobTitle || app?.jobTitle || null,
|
||||
userId: app?.userId ?? null,
|
||||
userId: iv.userId ?? app?.userId ?? null,
|
||||
}
|
||||
}),
|
||||
[monthQuery.data, appByInbox],
|
||||
|
|
|
|||
|
|
@ -225,10 +225,15 @@ async function fetchFormDetail(recordId) {
|
|||
function SourceChip({ item }) {
|
||||
// The dot carries the partner's brand colour; the label uses theme text —
|
||||
// 11px labels in the partner colour failed AA in both themes.
|
||||
// When no board matched, `source` is the raw To-address; keep it out of the
|
||||
// chip — show "Email", and leave the address to the tooltip and the opened
|
||||
// message.
|
||||
const source = String(item.source ?? '')
|
||||
const label = source.includes('@') ? 'Email' : source
|
||||
return (
|
||||
<span className="source-chip" style={{ '--chip': item.sourceMeta?.color }} title={item.source}>
|
||||
<span className="source-chip" style={{ '--chip': item.sourceMeta?.color }} title={source}>
|
||||
<span className="source-dot" />
|
||||
<span className="source-chip-label">{item.source}</span>
|
||||
<span className="source-chip-label">{label}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -993,6 +998,7 @@ export default function Inbox() {
|
|||
<div className="card">
|
||||
<div style={{ margin: '0 16px', paddingTop: 8 }}>
|
||||
<Tabs
|
||||
className="tabs tabs-wrap"
|
||||
value={tab}
|
||||
onChange={(t) => {
|
||||
setTab(t)
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ export default function Interviews() {
|
|||
return {
|
||||
...iv,
|
||||
jobTitle: iv.jobTitle || app?.jobTitle || null,
|
||||
userId: app?.userId ?? null,
|
||||
userId: iv.userId ?? app?.userId ?? null,
|
||||
}
|
||||
},
|
||||
[appByInbox],
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -979,7 +979,15 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
|||
/* Split inbox layout */
|
||||
.split { display: grid; grid-template-columns: 380px 1fr; gap: 0; min-height: 560px; }
|
||||
.split-list { border-right: 1px solid var(--border); overflow-y: auto; overscroll-behavior: contain; max-height: calc(100vh - 260px); max-height: calc(100dvh - 260px); }
|
||||
.split-detail { overflow-y: auto; overscroll-behavior: contain; max-height: calc(100vh - 260px); max-height: calc(100dvh - 260px); }
|
||||
.split-detail { overflow-y: auto; overscroll-behavior: contain; max-height: calc(100vh - 260px); max-height: calc(100dvh - 260px); container-type: inline-size; }
|
||||
/* The detail pane can be narrow while the viewport is wide (split layout),
|
||||
so viewport media queries cannot see it: the pane is a size container and
|
||||
its two-column field grid collapses on the pane's own width. */
|
||||
@container (max-width: 560px) {
|
||||
.split-detail .info-grid { grid-template-columns: 1fr; }
|
||||
.split-detail .profile-hero { flex-wrap: wrap; }
|
||||
}
|
||||
.split-detail .ph-name { overflow-wrap: anywhere; }
|
||||
.inbox-item { display: flex; gap: 12px; padding: 14px 18px; border-bottom: 1px solid var(--border); cursor: pointer; transition: .12s; position: relative; }
|
||||
.inbox-item:hover { background: var(--bg-sunken); }
|
||||
.inbox-item.active { background: var(--primary-soft); }
|
||||
|
|
@ -994,8 +1002,11 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
|||
.ii-meta { display: flex; align-items: center; gap: 8px; margin-top: 5px; }
|
||||
.ii-time { font-size: 11px; color: var(--text-3); white-space: nowrap; }
|
||||
/* Inbox sidebar only: fit the list instead of scrolling sideways.
|
||||
Username (.ii-name) and subject (.ii-pos) are left alone. */
|
||||
.inbox-split { grid-template-columns: minmax(0, 420px) 1fr; }
|
||||
Username (.ii-name) and subject (.ii-pos) are left alone.
|
||||
The list column yields (34%, floor 280px) instead of holding a hard 420px,
|
||||
so the detail pane keeps a readable width in the 980-1200px band where the
|
||||
split has not collapsed to one column yet. */
|
||||
.inbox-split { grid-template-columns: minmax(280px, 34%) minmax(0, 1fr); }
|
||||
.inbox-queue { overflow-x: hidden; min-width: 0; }
|
||||
.inbox-queue .inbox-item { min-width: 0; }
|
||||
/* Name + time on row 1, subject on row 2, chips span the full width under
|
||||
|
|
|
|||
Loading…
Reference in New Issue