Implement application history retrieval for candidates and enhance reapplication tracking #71
|
|
@ -928,7 +928,7 @@ own keys with `os.getenv` from the same file.
|
|||
| `APIFY_MAX_COST_USD` | `1.0` | Sent as `maxTotalChargeUsd`; Apify's minimum is $0.10 |
|
||||
| `APIFY_TIMEOUT` | `30` | Per-request httpx timeout, seconds |
|
||||
| `APIFY_EXCLUDE_COMPANIES` | `Utopia Brands,Utopia Deals` | Own companies: current employees are filtered out server-side before profiles are stored (case-insensitive substring on current company, headline fallback) |
|
||||
| `APIFY_EXCLUDE_COMPANY_URLS` | the Utopia Deals / Utopia Brands USA / Utopia Brands Pakistan pages | Full LinkedIn company URLs for the actor's `excludeCurrentCompanies` filter — stops those profiles being scraped (and billed) at all |
|
||||
| `APIFY_exclude_company_URLS` | the Utopia Deals / Utopia Brands USA / Utopia Brands Pakistan pages | Full LinkedIn company URLs for the actor's `excludeCurrentCompanies` filter — stops those profiles being scraped (and billed) at all |
|
||||
|
||||
### OpenAI
|
||||
|
||||
|
|
|
|||
|
|
@ -295,6 +295,44 @@ class FormData(SQLModel, table=True):
|
|||
result = await session.execute(statement)
|
||||
return result.scalars().all()
|
||||
|
||||
@classmethod
|
||||
async def list_by_emails(cls, session: AsyncSession, emails):
|
||||
"""Sheet applicants for these addresses. Promoted rows are omitted —
|
||||
those already live on manual_upload_candidate."""
|
||||
from job.job_post.models import JobPosts
|
||||
|
||||
lowers = sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()})
|
||||
if not lowers:
|
||||
return []
|
||||
assigned = func.coalesce(cls.assigned_job_post_id, cls.job_post_id)
|
||||
result = await session.execute(
|
||||
select(cls, JobPosts.title)
|
||||
.outerjoin(JobPosts, assigned == JobPosts.id)
|
||||
.where(func.lower(cls.candidate_email).in_(lowers))
|
||||
.where(cls.manual_upload_candidate_id.is_(None))
|
||||
.order_by(cls.created_at.desc())
|
||||
)
|
||||
rows = []
|
||||
for rec, title in result.all():
|
||||
job_id = rec.assigned_job_post_id or rec.job_post_id
|
||||
rows.append({
|
||||
"source": "form",
|
||||
"email": (rec.candidate_email or "").strip().lower() or None,
|
||||
"inbox_id": None,
|
||||
"message_id": None,
|
||||
"manual_upload_candidate_id": None,
|
||||
"form_data_id": str(rec.id),
|
||||
"candidate_id": None,
|
||||
"job_post_id": str(job_id) if job_id else None,
|
||||
"job_title": title or rec.position_applied_for or None,
|
||||
"status": rec.processing_state or None,
|
||||
"applied_at": (
|
||||
rec.entry_date.isoformat() if rec.entry_date
|
||||
else (rec.created_at.isoformat() if rec.created_at else None)
|
||||
),
|
||||
})
|
||||
return rows
|
||||
|
||||
@classmethod
|
||||
async def count_form_data(
|
||||
cls, session: AsyncSession, *, sheet=None, search=None,
|
||||
|
|
|
|||
|
|
@ -443,6 +443,8 @@ class SheetFormData(Sheet):
|
|||
processing_state=processing_state,is_duplicate=is_duplicate,
|
||||
)
|
||||
items=await self._hydrate_job_posts([serialize_form_data(row) for row in rows])
|
||||
from job.candidate.views import CandidateView
|
||||
items=await CandidateView(session=session).attach_application_history(items)
|
||||
return items,total
|
||||
|
||||
async def get_form_data_by_id(self,record_id):
|
||||
|
|
@ -451,7 +453,8 @@ class SheetFormData(Sheet):
|
|||
if not row:
|
||||
raise HTTPException(status_code=404,detail="Form data not found")
|
||||
items=await self._hydrate_job_posts([serialize_form_data(row)])
|
||||
return items[0]
|
||||
from job.candidate.views import CandidateView
|
||||
return await CandidateView(session=session).attach_application_history(items[0])
|
||||
|
||||
async def assign_job_post(self,record_id,job_post_id):
|
||||
"""Set or clear form_data.assigned_job_post_id (same contract as inbox assign).
|
||||
|
|
|
|||
|
|
@ -160,6 +160,56 @@ class Inbox(SQLModel, table=True):
|
|||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
@classmethod
|
||||
async def list_applications_by_emails(cls, session: AsyncSession, emails):
|
||||
"""Inbox applications whose user or sender address is in `emails`."""
|
||||
from job.job_post.models import JobPosts
|
||||
|
||||
lowers = sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()})
|
||||
if not lowers:
|
||||
return []
|
||||
result = await session.execute(
|
||||
select(
|
||||
cls.id.label("inbox_id"),
|
||||
cls.user_id,
|
||||
cls.message_id,
|
||||
Users.email,
|
||||
Inbox_Messages.message_from,
|
||||
Inbox_Messages.assigned_job_post_id,
|
||||
Inbox_Messages.application_status,
|
||||
Inbox_Messages.message_received_time,
|
||||
cls.created_at,
|
||||
JobPosts.title,
|
||||
)
|
||||
.join(Users, cls.user_id == Users.id)
|
||||
.join(Inbox_Messages, cls.message_id == Inbox_Messages.id)
|
||||
.outerjoin(JobPosts, Inbox_Messages.assigned_job_post_id == JobPosts.id)
|
||||
.where(or_(
|
||||
func.lower(Users.email).in_(lowers),
|
||||
func.lower(Inbox_Messages.message_from).in_(lowers),
|
||||
))
|
||||
.order_by(cls.created_at.desc())
|
||||
)
|
||||
rows = []
|
||||
for row in result.mappings().all():
|
||||
email = (row["email"] or row["message_from"] or "").strip().lower() or None
|
||||
status = row["application_status"]
|
||||
applied = row["message_received_time"] or row["created_at"]
|
||||
rows.append({
|
||||
"source": "inbox",
|
||||
"email": email,
|
||||
"inbox_id": row["inbox_id"],
|
||||
"message_id": str(row["message_id"]) if row["message_id"] else None,
|
||||
"manual_upload_candidate_id": None,
|
||||
"form_data_id": None,
|
||||
"candidate_id": None,
|
||||
"job_post_id": str(row["assigned_job_post_id"]) if row["assigned_job_post_id"] else None,
|
||||
"job_title": row["title"] or None,
|
||||
"status": status.value if status else None,
|
||||
"applied_at": applied.isoformat() if hasattr(applied, "isoformat") else (applied or None),
|
||||
})
|
||||
return rows
|
||||
|
||||
@classmethod
|
||||
async def linkedin_urls_by_message_ids(cls, session: AsyncSession, message_ids) -> dict:
|
||||
"""users.linkedin_url keyed by inbox_messages.id for one list page."""
|
||||
|
|
|
|||
|
|
@ -271,7 +271,7 @@ class Email:
|
|||
item["assigned_job_post"]=await cv.get_job_post_by_id(record_id=assigned_id)
|
||||
else:
|
||||
item["assigned_job_post"]=None
|
||||
return item
|
||||
return await cv.attach_application_history(item)
|
||||
|
||||
async def get_all_applications(self,top,skip,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None):
|
||||
if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED) or processing_state:
|
||||
|
|
@ -281,14 +281,18 @@ class Email:
|
|||
else:
|
||||
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,light=True)
|
||||
urls=await Inbox.linkedin_urls_by_message_ids(self.session,[m.id for m in messages])
|
||||
return [serialize_application(m,linkedin_url=urls.get(m.id),light=True) for m in messages]
|
||||
items=[serialize_application(m,linkedin_url=urls.get(m.id),light=True) for m in messages]
|
||||
from job.candidate.views import CandidateView
|
||||
return await CandidateView(session=self.session).attach_application_history(items)
|
||||
|
||||
async def get_application_by_id(self,record_id):
|
||||
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
|
||||
if not message:
|
||||
raise HTTPException(status_code=404,detail="Application not found")
|
||||
urls=await Inbox.linkedin_urls_by_message_ids(self.session,[message.id])
|
||||
return serialize_application(message,linkedin_url=urls.get(message.id))
|
||||
item=serialize_application(message,linkedin_url=urls.get(message.id))
|
||||
from job.candidate.views import CandidateView
|
||||
return await CandidateView(session=self.session).attach_application_history(item)
|
||||
|
||||
async def queue_rematch(self,record_id):
|
||||
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
|
||||
|
|
|
|||
|
|
@ -969,6 +969,22 @@ async def fetch_candidate(
|
|||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.get("/candidate/applications/fetch")
|
||||
async def fetch_candidate_applications(
|
||||
email:str=Query(...),
|
||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=CandidateView(session=session)
|
||||
data=await service.get_application_history(email)
|
||||
return JSONResponse(content={"data":data,"total":len(data.get("applications") or []),"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/candidate/update")
|
||||
async def update_candidate(
|
||||
user_id:str=Query(...),
|
||||
|
|
|
|||
|
|
@ -389,6 +389,38 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
)
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
async def list_by_emails(cls, session: AsyncSession, emails):
|
||||
"""Every manual/form/cv-bank row for these addresses, newest first."""
|
||||
from job.job_post.models import JobPosts
|
||||
|
||||
lowers = sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()})
|
||||
if not lowers:
|
||||
return []
|
||||
result = await session.execute(
|
||||
select(cls, JobPosts.title)
|
||||
.outerjoin(JobPosts, cls.job_post_id == JobPosts.id)
|
||||
.where(func.lower(cls.candidate_email).in_(lowers))
|
||||
.order_by(cls.created_at.desc())
|
||||
)
|
||||
rows = []
|
||||
for rec, title in result.all():
|
||||
status = (rec.status or "").strip() or None
|
||||
rows.append({
|
||||
"source": "manual",
|
||||
"email": (rec.candidate_email or "").strip().lower() or None,
|
||||
"inbox_id": None,
|
||||
"message_id": None,
|
||||
"manual_upload_candidate_id": str(rec.id),
|
||||
"form_data_id": None,
|
||||
"candidate_id": None,
|
||||
"job_post_id": str(rec.job_post_id) if rec.job_post_id else None,
|
||||
"job_title": title or None,
|
||||
"status": status or "PENDING",
|
||||
"applied_at": rec.created_at.isoformat() if rec.created_at else None,
|
||||
})
|
||||
return rows
|
||||
|
||||
@classmethod
|
||||
async def list_for_talent_pool(cls, session: AsyncSession, *, limit=100, offset=0, search=None, job_post_ids=None):
|
||||
"""Newest applications with a user + job for Talent Pool (manual / form)."""
|
||||
|
|
@ -807,6 +839,38 @@ class Candidates(SQLModel, table=True):
|
|||
)
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
async def list_by_emails(cls, session: AsyncSession, emails):
|
||||
"""Scored `candidates` rows for these addresses (ATS, not pipeline stage)."""
|
||||
from job.job_post.models import JobPosts
|
||||
|
||||
lowers = sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()})
|
||||
if not lowers:
|
||||
return []
|
||||
result = await session.execute(
|
||||
select(cls, JobPosts.title)
|
||||
.outerjoin(JobPosts, cls.job_id == JobPosts.id)
|
||||
.where(func.lower(cls.candidate_email).in_(lowers))
|
||||
.order_by(cls.created_at.desc())
|
||||
)
|
||||
rows = []
|
||||
for rec, title in result.all():
|
||||
email = (rec.candidate_email or "").strip().lower() or None
|
||||
rows.append({
|
||||
"source": "ats",
|
||||
"email": email,
|
||||
"inbox_id": None,
|
||||
"message_id": None,
|
||||
"manual_upload_candidate_id": None,
|
||||
"form_data_id": None,
|
||||
"candidate_id": str(rec.id),
|
||||
"job_post_id": str(rec.job_id) if rec.job_id else None,
|
||||
"job_title": title or rec.job_title or None,
|
||||
"status": rec.status or None,
|
||||
"applied_at": rec.created_at.isoformat() if rec.created_at else None,
|
||||
})
|
||||
return rows
|
||||
|
||||
@classmethod
|
||||
async def upsert_candidate(cls, session: AsyncSession, fields: dict):
|
||||
existing = None
|
||||
|
|
|
|||
|
|
@ -248,3 +248,35 @@ def serialize_manager_candidate(row, *, source) -> dict:
|
|||
"ai_score": score,
|
||||
"recommendation": band,
|
||||
}
|
||||
|
||||
|
||||
def serialize_application_history_item(row) -> dict:
|
||||
"""One prior application / score / sheet row for a reapplicant lookup."""
|
||||
return {
|
||||
"source": row.get("source"),
|
||||
"inbox_id": row.get("inbox_id"),
|
||||
"message_id": row.get("message_id"),
|
||||
"manual_upload_candidate_id": row.get("manual_upload_candidate_id"),
|
||||
"form_data_id": row.get("form_data_id"),
|
||||
"candidate_id": row.get("candidate_id"),
|
||||
"job_post_id": row.get("job_post_id"),
|
||||
"job_title": row.get("job_title"),
|
||||
"status": row.get("status"),
|
||||
"applied_at": row.get("applied_at"),
|
||||
}
|
||||
|
||||
|
||||
def serialize_application_history(email, *, user=None, present_in=None, applications=None) -> dict:
|
||||
items = [serialize_application_history_item(row) for row in (applications or [])]
|
||||
found = bool(user or present_in or items)
|
||||
return {
|
||||
"email": email,
|
||||
"found": found,
|
||||
"present_in": list(present_in or []),
|
||||
"user": (
|
||||
{"id": str(user.id), "name": user.name, "email": user.email}
|
||||
if user is not None else None
|
||||
),
|
||||
"is_reapplicant": len(items) > 0,
|
||||
"applications": items,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ from job.candidate.plugins import (
|
|||
get_scoring_settings,
|
||||
normalize_spaced_text,
|
||||
)
|
||||
from job.candidate.serializers import serialize_candidate,serialize_candidate_profile,serialize_manual_candidate_profile,serialize_manual_upload_candidate,serialize_matching_candidate,serialize_manager_candidate
|
||||
from g_sheet.models import FormData
|
||||
from job.candidate.serializers import serialize_application_history,serialize_candidate,serialize_candidate_profile,serialize_manual_candidate_profile,serialize_manual_upload_candidate,serialize_matching_candidate,serialize_manager_candidate
|
||||
from job.job_post.models import JobPosts
|
||||
from job.job_post.serializers import serialize_job_post
|
||||
from job.candidate.models import Notes,Manual_UPLOAD_CANDIDATE
|
||||
|
|
@ -43,6 +44,62 @@ MANUAL_UPLOAD_TO_ADDRESS=os.getenv(
|
|||
)
|
||||
MANAGER_SCOPE_DETAIL="You can only access candidates allocated to jobs opened from your requisitions"
|
||||
CREATOR_SCOPE_DETAIL="You can only access candidates allocated to jobs you created"
|
||||
_HISTORY_TABLES=("users","candidates","manual_upload_candidate","form_data")
|
||||
|
||||
|
||||
def _norm_email(value):
|
||||
raw=(value or "").strip().lower()
|
||||
if not raw:
|
||||
return ""
|
||||
if "<" in raw and ">" in raw:
|
||||
inner=raw.rsplit("<",1)[-1]
|
||||
raw=inner.split(">",1)[0].strip()
|
||||
return raw
|
||||
|
||||
|
||||
def _payload_email(payload):
|
||||
if not isinstance(payload,dict):
|
||||
return ""
|
||||
return _norm_email(
|
||||
payload.get("email")
|
||||
or payload.get("candidate_email")
|
||||
or payload.get("fromEmail")
|
||||
)
|
||||
|
||||
|
||||
def _is_current_application(item,payload):
|
||||
"""True when `item` is the same row the list/detail payload is showing."""
|
||||
if not isinstance(item,dict) or not isinstance(payload,dict):
|
||||
return False
|
||||
source=item.get("source")
|
||||
if source=="inbox":
|
||||
if payload.get("inbox_id") is not None and item.get("inbox_id") is not None:
|
||||
try:
|
||||
if int(payload["inbox_id"])==int(item["inbox_id"]):
|
||||
return True
|
||||
except (TypeError,ValueError):
|
||||
pass
|
||||
pid=payload.get("message_id")
|
||||
if not pid and payload.get("inbox_id") is None and payload.get("sheet") is None:
|
||||
pid=payload.get("id")
|
||||
return bool(pid and item.get("message_id") and str(pid)==str(item["message_id"]))
|
||||
if source=="manual":
|
||||
pid=payload.get("manual_upload_candidate_id")
|
||||
if not pid and payload.get("inbox_id") is None and payload.get("sheet") is None:
|
||||
pid=payload.get("id")
|
||||
return bool(pid and item.get("manual_upload_candidate_id") and str(pid)==str(item["manual_upload_candidate_id"]))
|
||||
if source=="form":
|
||||
if payload.get("sheet") is None:
|
||||
return False
|
||||
pid=payload.get("id")
|
||||
return bool(pid and item.get("form_data_id") and str(pid)==str(item["form_data_id"]))
|
||||
if source=="ats":
|
||||
pid=payload.get("candidate_id") or payload.get("id")
|
||||
return bool(
|
||||
pid and item.get("candidate_id") and str(pid)==str(item["candidate_id"])
|
||||
and (payload.get("match_score") is not None or payload.get("filename"))
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def assigned_job_ids_for_user(session,user_id):
|
||||
|
|
@ -473,13 +530,16 @@ class CandidateScoring:
|
|||
rows,total=await Candidates.get_candidates_by_job(
|
||||
self.session,job_id,limit=limit,offset=offset,
|
||||
)
|
||||
return [serialize_candidate(row) for row in rows],total
|
||||
data=[serialize_candidate(row) for row in rows]
|
||||
data=await CandidateView(session=self.session).attach_application_history(data)
|
||||
return data,total
|
||||
|
||||
async def fetch_candidate_by_id(self,candidate_id):
|
||||
row=await Candidates.get_candidate_by_id(self.session,candidate_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404,detail="Candidate not found")
|
||||
return serialize_candidate(row)
|
||||
data=serialize_candidate(row)
|
||||
return await CandidateView(session=self.session).attach_application_history(data)
|
||||
|
||||
async def _score_and_persist(self,job_id,sources,source_kind,current_user):
|
||||
job=await JobPosts.get_job_post_by_id(self.session,job_id)
|
||||
|
|
@ -820,7 +880,8 @@ class CandidateView:
|
|||
total=len(merged)
|
||||
start=max(0,int(offset or 0))
|
||||
cap=max(1,int(limit or 50))
|
||||
return merged[start:start+cap],total
|
||||
page=merged[start:start+cap]
|
||||
return await self.attach_application_history(page),total
|
||||
|
||||
async def get_candidate(self,user_id=None,limit=10,offset=0,search=None,current_user=None,assigned_job_post_id=None,created_by=False):
|
||||
try:
|
||||
|
|
@ -854,10 +915,10 @@ class CandidateView:
|
|||
if jid and jid in owned_set:
|
||||
kept.append(rec)
|
||||
if kept:
|
||||
return await self.attach_profile_detail(kept)
|
||||
return await self.attach_application_history(await self.attach_profile_detail(kept))
|
||||
records=[]
|
||||
if records:
|
||||
return await self.attach_profile_detail(rows)
|
||||
return await self.attach_application_history(await self.attach_profile_detail(rows))
|
||||
# Manual uploads create users + manual_upload_candidate but no inbox
|
||||
# row — resolve the profile from that table instead of returning [].
|
||||
manual=await Manual_UPLOAD_CANDIDATE.get_by_user_id(self.session,user_id)
|
||||
|
|
@ -881,9 +942,9 @@ class CandidateView:
|
|||
payload["candidate_id"]=score.get("candidate_id")
|
||||
if score.get("user_id") and not payload.get("user_id"):
|
||||
payload["user_id"]=score["user_id"]
|
||||
if score.get("job_post_id"):
|
||||
payload["scored_job_post_id"]=score["job_post_id"]
|
||||
return payload
|
||||
if score.get("job_post_id"):
|
||||
payload["scored_job_post_id"]=score["job_post_id"]
|
||||
return await self.attach_application_history(payload)
|
||||
# List mode: inbox applications + manual/form applications (dedupe by user).
|
||||
inbox_payloads=await self.attach_job_posts(rows)
|
||||
if not isinstance(inbox_payloads,list):
|
||||
|
|
@ -944,7 +1005,7 @@ class CandidateView:
|
|||
if assigned_job_post_id:
|
||||
job_id=str(assigned_job_post_id)
|
||||
data=[p for p in data if str(p.get("assigned_job_post_id") or "")==job_id]
|
||||
return data
|
||||
return await self.attach_application_history(data)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
|
@ -997,7 +1058,7 @@ class CandidateView:
|
|||
from_value=old_rating,to_value=fields["rating"],commit=True,
|
||||
)
|
||||
refreshed=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=100,offset=0)
|
||||
return await self.attach_profile_detail(refreshed)
|
||||
return await self.attach_application_history(await self.attach_profile_detail(refreshed))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
|
@ -1303,3 +1364,117 @@ class CandidateView:
|
|||
description="cv_bank",commit=True,
|
||||
)
|
||||
return serialize_matching_candidate(row,job_post)
|
||||
|
||||
async def application_history_by_emails(self,emails):
|
||||
"""Prior applications keyed by lowercase email across users / inbox /
|
||||
manual_upload_candidate / form_data / candidates."""
|
||||
lowers=[]
|
||||
seen=set()
|
||||
for raw in emails or []:
|
||||
email=_norm_email(raw)
|
||||
if email and email not in seen:
|
||||
seen.add(email)
|
||||
lowers.append(email)
|
||||
if not lowers:
|
||||
return {}
|
||||
users=await Users.get_users_by_emails(self.session,lowers)
|
||||
user_by_email={_norm_email(u.email):u for u in users}
|
||||
inbox_rows=await Inbox.list_applications_by_emails(self.session,lowers)
|
||||
manual_rows=await Manual_UPLOAD_CANDIDATE.list_by_emails(self.session,lowers)
|
||||
form_rows=await FormData.list_by_emails(self.session,lowers)
|
||||
ats_rows=await Candidates.list_by_emails(self.session,lowers)
|
||||
packed={email:{"present_in":[],"user":user_by_email.get(email),"applications":[]} for email in lowers}
|
||||
for email,user in user_by_email.items():
|
||||
if email in packed:
|
||||
packed[email]["present_in"].append("users")
|
||||
packed[email]["user"]=user
|
||||
for row in inbox_rows:
|
||||
email=_norm_email(row.get("email"))
|
||||
if email in packed:
|
||||
packed[email]["applications"].append(row)
|
||||
for row in manual_rows:
|
||||
email=_norm_email(row.get("email"))
|
||||
if email not in packed:
|
||||
continue
|
||||
packed[email]["applications"].append(row)
|
||||
if "manual_upload_candidate" not in packed[email]["present_in"]:
|
||||
packed[email]["present_in"].append("manual_upload_candidate")
|
||||
for row in form_rows:
|
||||
email=_norm_email(row.get("email"))
|
||||
if email not in packed:
|
||||
continue
|
||||
packed[email]["applications"].append(row)
|
||||
if "form_data" not in packed[email]["present_in"]:
|
||||
packed[email]["present_in"].append("form_data")
|
||||
for row in ats_rows:
|
||||
email=_norm_email(row.get("email"))
|
||||
if email not in packed:
|
||||
continue
|
||||
packed[email]["applications"].append(row)
|
||||
if "candidates" not in packed[email]["present_in"]:
|
||||
packed[email]["present_in"].append("candidates")
|
||||
pipeline_jobs={}
|
||||
for email,pack in packed.items():
|
||||
jobs=set()
|
||||
for row in pack["applications"]:
|
||||
if row.get("source") in ("inbox","manual") and row.get("job_post_id"):
|
||||
jobs.add(row["job_post_id"])
|
||||
pipeline_jobs[email]=jobs
|
||||
for email,pack in packed.items():
|
||||
jobs=pipeline_jobs.get(email) or set()
|
||||
if not jobs:
|
||||
continue
|
||||
# ATS scores for a job the person already applied to are not a
|
||||
# separate application — they duplicate the pipeline row.
|
||||
pack["applications"]=[
|
||||
row for row in pack["applications"]
|
||||
if not (row.get("source")=="ats" and row.get("job_post_id") in jobs)
|
||||
]
|
||||
for pack in packed.values():
|
||||
pack["applications"].sort(key=lambda r: r.get("applied_at") or "",reverse=True)
|
||||
present=pack["present_in"]
|
||||
pack["present_in"]=[name for name in _HISTORY_TABLES if name in present]
|
||||
return packed
|
||||
|
||||
async def get_application_history(self,email):
|
||||
cleaned=_norm_email(email)
|
||||
if not cleaned:
|
||||
raise HTTPException(status_code=422,detail="email is required")
|
||||
packed=await self.application_history_by_emails([cleaned])
|
||||
pack=packed.get(cleaned) or {"present_in":[],"user":None,"applications":[]}
|
||||
return serialize_application_history(
|
||||
cleaned,user=pack.get("user"),present_in=pack.get("present_in"),
|
||||
applications=pack.get("applications"),
|
||||
)
|
||||
|
||||
async def attach_application_history(self,payloads):
|
||||
"""Stamp is_reapplicant + previous_applications onto list/detail dicts."""
|
||||
single=not isinstance(payloads,list)
|
||||
records=[payloads] if single else list(payloads or [])
|
||||
emails=[_payload_email(p) for p in records]
|
||||
history=await self.application_history_by_emails(emails)
|
||||
for payload in records:
|
||||
if not isinstance(payload,dict):
|
||||
continue
|
||||
email=_payload_email(payload)
|
||||
pack=history.get(email) or {"present_in":[],"user":None,"applications":[]}
|
||||
previous=[]
|
||||
for row in pack.get("applications") or []:
|
||||
if _is_current_application(row,payload):
|
||||
continue
|
||||
previous.append({
|
||||
"source":row.get("source"),
|
||||
"inbox_id":row.get("inbox_id"),
|
||||
"message_id":row.get("message_id"),
|
||||
"manual_upload_candidate_id":row.get("manual_upload_candidate_id"),
|
||||
"form_data_id":row.get("form_data_id"),
|
||||
"candidate_id":row.get("candidate_id"),
|
||||
"job_post_id":row.get("job_post_id"),
|
||||
"job_title":row.get("job_title"),
|
||||
"status":row.get("status"),
|
||||
"applied_at":row.get("applied_at"),
|
||||
})
|
||||
payload["present_in"]=list(pack.get("present_in") or [])
|
||||
payload["is_reapplicant"]=bool(previous)
|
||||
payload["previous_applications"]=previous
|
||||
return records[0] if single else records
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ class Pipeline:
|
|||
try:
|
||||
inbox_data=await Inbox.get_all(self.session,job_post_id=job_post_id,limit=limit,offset=offset)
|
||||
manual_upload_data=await Manual_UPLOAD_CANDIDATE.get_all(self.session,job_post_id=job_post_id,limit=limit,offset=offset)
|
||||
from job.candidate.views import CandidateView
|
||||
history=CandidateView(session=self.session)
|
||||
inbox_data=await history.attach_application_history(inbox_data)
|
||||
manual_upload_data=await history.attach_application_history(manual_upload_data)
|
||||
counts=serialize_pipeline_counts(
|
||||
await Inbox.count_by_status(self.session,job_post_id=job_post_id),
|
||||
await Manual_UPLOAD_CANDIDATE.count_by_status(self.session,job_post_id=job_post_id),
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ class OrgSettings(SQLModel, table=True):
|
|||
|
||||
|
||||
class ExcludeUniversity(SQLModel, table=True):
|
||||
__tablename__ = "Exclude_University"
|
||||
__tablename__ = "exclude_university"
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
name: str = Field(index=True)
|
||||
|
|
@ -166,7 +166,7 @@ class ExcludeUniversity(SQLModel, table=True):
|
|||
|
||||
|
||||
class ExcludeCompany(SQLModel, table=True):
|
||||
__tablename__ = "Exclude_Company"
|
||||
__tablename__ = "exclude_company"
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
name: str = Field(index=True)
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ def is_excluded_profile(profile: dict, *, companies=None, universities=None) ->
|
|||
Company: current employer, or headline only when no company was extracted so
|
||||
an "ex-…" headline on someone now elsewhere does not exclude them.
|
||||
University: any education school name on the sourced profile.
|
||||
Lists come from Exclude_Company / Exclude_University — never hardcoded.
|
||||
Lists come from exclude_company / exclude_university — never hardcoded.
|
||||
"""
|
||||
company = (profile or {}).get("current_company")
|
||||
if _matches_excluded(company, companies):
|
||||
|
|
|
|||
|
|
@ -181,6 +181,19 @@ class Users(SQLModel, table=True):
|
|||
result = await session.execute(statement)
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
async def get_users_by_emails(cls, session: AsyncSession, emails):
|
||||
"""Any account matching these addresses, including candidate role_id=8."""
|
||||
lowers = sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()})
|
||||
if not lowers:
|
||||
return []
|
||||
statement = (
|
||||
select(cls)
|
||||
.where(func.lower(cls.email).in_(lowers), cls.is_deleted == False) # noqa: E712
|
||||
)
|
||||
result = await session.execute(statement)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@classmethod
|
||||
async def count_users(cls, session: AsyncSession, search: str | None = None, role_id: Optional[int] = None):
|
||||
statement = (
|
||||
|
|
|
|||
|
|
@ -148,6 +148,8 @@ export function toCandidateView(row) {
|
|||
errorCode: row.error_code ?? null,
|
||||
errorMessage: row.error_message ?? null,
|
||||
applied: toDate(row.created_at),
|
||||
isReapplicant: Boolean(row.is_reapplicant),
|
||||
previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -239,6 +241,8 @@ export function toApplicationListView(row) {
|
|||
aiScore: score,
|
||||
recommendation: bandOf(score, row.recommendation || null),
|
||||
applied: row.created_at ? new Date(row.created_at) : (row.applied ? new Date(row.applied) : null),
|
||||
isReapplicant: Boolean(row.is_reapplicant),
|
||||
previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -425,6 +429,14 @@ export function listHistory(userId, { limit = 10, offset = 0 } = {}) {
|
|||
return request('/candidate/history/fetch', { params: { user_id: userId, limit, offset } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Prior applications for one email across users, candidates, manual upload,
|
||||
* form_data (and inbox). Used by Add Candidate to warn on reapply.
|
||||
*/
|
||||
export function fetchApplicationHistory(email) {
|
||||
return request('/candidate/applications/fetch', { params: { email } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated attachment download. Never send a filesystem path — the server
|
||||
* resolves by owning record + index. `inboxId` is the `inbox` table PK (int),
|
||||
|
|
|
|||
|
|
@ -216,6 +216,8 @@ export function toBoardCard(row, kind = 'inbox') {
|
|||
aiScore: asScore(row.ats_result?.overall_score),
|
||||
recommendation: asText(row.ats_result?.band),
|
||||
applied: toDate(row.created_at),
|
||||
isReapplicant: Boolean(row.is_reapplicant),
|
||||
previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
import { Badge } from '../ui/primitives'
|
||||
import { STAGE_FROM_STATUS } from '../api/pipeline'
|
||||
import { fmtDate } from '../lib/format'
|
||||
|
||||
const SOURCE_LABEL = {
|
||||
inbox: 'Email',
|
||||
manual: 'Manual',
|
||||
form: 'Form',
|
||||
ats: 'ATS',
|
||||
}
|
||||
|
||||
const STAGE_BADGE = {
|
||||
Shortlist: 'b-indigo',
|
||||
Screening: 'b-teal',
|
||||
Assessment: 'b-purple',
|
||||
Interview: 'b-amber',
|
||||
Offer: 'b-green',
|
||||
Approved: 'b-green',
|
||||
Hired: 'b-green',
|
||||
'On Hold': 'b-amber',
|
||||
Rejected: 'b-gray',
|
||||
}
|
||||
|
||||
export function applicationStatusLabel(status) {
|
||||
if (status == null || status === '') return 'Shortlist'
|
||||
const key = String(status).toUpperCase()
|
||||
if (STAGE_FROM_STATUS[key]) return STAGE_FROM_STATUS[key]
|
||||
const extras = {
|
||||
UNREAD: 'Unread',
|
||||
IMPORTED: 'Imported',
|
||||
PROCESSED: 'Processed',
|
||||
COMPLETED: 'ATS scored',
|
||||
FAILED: 'ATS failed',
|
||||
BANKED: 'CV bank',
|
||||
}
|
||||
if (extras[key]) return extras[key]
|
||||
return key.charAt(0) + key.slice(1).toLowerCase()
|
||||
}
|
||||
|
||||
export function previousApplicationsOf(row) {
|
||||
if (!row) return []
|
||||
if (Array.isArray(row.previousApplications)) return row.previousApplications
|
||||
if (Array.isArray(row.previous_applications)) return row.previous_applications
|
||||
return []
|
||||
}
|
||||
|
||||
export function isReapplicant(row) {
|
||||
if (!row) return false
|
||||
if (row.isReapplicant === true || row.is_reapplicant === true) return true
|
||||
return previousApplicationsOf(row).length > 0
|
||||
}
|
||||
|
||||
export function previousApplicationsTip(row) {
|
||||
const items = previousApplicationsOf(row)
|
||||
if (!items.length) return 'Applied before'
|
||||
return items.map((item) => {
|
||||
const job = item.job_title || item.jobTitle || 'Unassigned job'
|
||||
return `${job} — ${applicationStatusLabel(item.status)}`
|
||||
}).join('\n')
|
||||
}
|
||||
|
||||
/** Compact chip for tables, kanban cards, and inbox rows. */
|
||||
export function ReappliedBadge({ row, className = '' }) {
|
||||
if (!isReapplicant(row)) return null
|
||||
const count = previousApplicationsOf(row).length
|
||||
return (
|
||||
<span
|
||||
className={`badge b-amber badge-plain ${className}`.trim()}
|
||||
style={{ marginLeft: 6, fontSize: 10, padding: '1px 6px' }}
|
||||
title={previousApplicationsTip(row)}
|
||||
>
|
||||
Reapplied{count > 1 ? ` · ${count}` : ''}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Full prior-job list for profile / inbox / add-candidate. */
|
||||
export function PreviousApplications({ row, title = 'Previous applications' }) {
|
||||
const items = previousApplicationsOf(row)
|
||||
if (!items.length) return null
|
||||
return (
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
boxShadow: 'none',
|
||||
background: 'var(--warning-soft)',
|
||||
border: '1px solid var(--warning)',
|
||||
marginBottom: 18,
|
||||
}}
|
||||
>
|
||||
<div className="card-body">
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: 'var(--warning)',
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{items.map((item, idx) => {
|
||||
const stage = applicationStatusLabel(item.status)
|
||||
const key = [
|
||||
item.source,
|
||||
item.inbox_id,
|
||||
item.manual_upload_candidate_id,
|
||||
item.form_data_id,
|
||||
item.candidate_id,
|
||||
item.job_post_id,
|
||||
idx,
|
||||
].filter(Boolean).join(':')
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center gap-8"
|
||||
style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="fw-600" style={{ fontSize: 13 }}>
|
||||
{item.job_title || item.jobTitle || 'No job assigned'}
|
||||
</div>
|
||||
<div className="cell-sub">
|
||||
{SOURCE_LABEL[item.source] || item.source || 'Application'}
|
||||
{item.applied_at ? ` · ${fmtDate(item.applied_at)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<Badge className={STAGE_BADGE[stage] || 'b-gray'}>{stage}</Badge>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -100,6 +100,7 @@ export const qk = {
|
|||
count: (p = {}) => ['candidates', 'count', p],
|
||||
detail: (id) => ['candidates', 'detail', id],
|
||||
history: (id, p = {}) => ['candidates', 'history', id, p],
|
||||
applications: (email) => ['candidates', 'applications', email],
|
||||
matching: (p = {}) => ['candidates', 'matching', p],
|
||||
matchingDetail: (id) => ['candidates', 'matching', 'detail', id],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import * as formsApi from '../api/forms'
|
|||
import * as pipelineApi from '../api/pipeline'
|
||||
import * as s3Api from '../api/s3'
|
||||
import CandidateFormsTab from './CandidateForms'
|
||||
import { PreviousApplications, ReappliedBadge } from '../components/ReapplicantHistory'
|
||||
import { fmtDate, fmtTime, toDate } from '../lib/format'
|
||||
import { companies, moneyK, pick } from '../data/seed'
|
||||
|
||||
|
|
@ -274,7 +275,10 @@ export default function CandidateProfile({
|
|||
<div className="profile-hero">
|
||||
<Avatar name={live?.name || c.name} initials={c.initials} color={c.color} className="avatar-lg" />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="ph-name">{live?.name || c.name}</div>
|
||||
<div className="ph-name">
|
||||
{live?.name || c.name}
|
||||
<ReappliedBadge row={live || c} />
|
||||
</div>
|
||||
<div className="ph-role">{company ? `${title} at ${company}` : title}</div>
|
||||
<div className="ph-tags">
|
||||
{stageLabel && <Badge>{stageLabel}</Badge>}{' '}
|
||||
|
|
@ -322,6 +326,7 @@ export default function CandidateProfile({
|
|||
<div className="tab-pane active">
|
||||
{tab === 'Overview' && (guard || (live ? (
|
||||
<>
|
||||
<PreviousApplications row={live} />
|
||||
<div className="info-grid" style={{ marginBottom: 20 }}>
|
||||
<Info label="Email" val={live.email} />
|
||||
<Info label="Phone" val={live.phone} />
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import { friendlyAuthError } from '../lib/errors'
|
|||
import * as candidatesApi from '../api/candidates'
|
||||
import * as jobPostsApi from '../api/jobPosts'
|
||||
import * as pipelineApi from '../api/pipeline'
|
||||
import { PreviousApplications, ReappliedBadge } from '../components/ReapplicantHistory'
|
||||
import { useFormState } from '../components/AuthLayout'
|
||||
import { persist, useSeedMutation } from '../data/seedQueries'
|
||||
import { avatarColor, fmtDate, initials as initialsOf, sources, stages } from '../data/seed'
|
||||
|
|
@ -201,7 +202,10 @@ function HiringManagerCandidates() {
|
|||
sortValue: (r) => r.name || '',
|
||||
render: (r) => (
|
||||
<>
|
||||
<div className="cell-primary">{r.name || '—'}</div>
|
||||
<div className="cell-primary">
|
||||
{r.name || '—'}
|
||||
<ReappliedBadge row={r} />
|
||||
</div>
|
||||
<div className="cell-sub">{r.email || '—'}</div>
|
||||
</>
|
||||
),
|
||||
|
|
@ -658,6 +662,7 @@ function RecruiterCandidates() {
|
|||
{c.source === 'Form' && (
|
||||
<Badge className="b-gray" style={{ marginLeft: 6, fontSize: 10 }}>Form</Badge>
|
||||
)}
|
||||
<ReappliedBadge row={c} />
|
||||
</div>
|
||||
<div className="cell-sub">{c.email || '—'}</div>
|
||||
</div>
|
||||
|
|
@ -1015,6 +1020,30 @@ function AddCandidate({ onClose, onSave, onInvalid }) {
|
|||
referral: '',
|
||||
})
|
||||
|
||||
const lookupEmail = (form.values.email || '').trim().toLowerCase()
|
||||
const [debouncedEmail, setDebouncedEmail] = useState('')
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedEmail(lookupEmail), 400)
|
||||
return () => clearTimeout(timer)
|
||||
}, [lookupEmail])
|
||||
const emailLooksValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(debouncedEmail)
|
||||
const priorQuery = useQuery({
|
||||
queryKey: qk.candidates.applications(debouncedEmail),
|
||||
queryFn: async () => {
|
||||
const res = await candidatesApi.fetchApplicationHistory(debouncedEmail)
|
||||
return res?.data ?? null
|
||||
},
|
||||
enabled: emailLooksValid,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
const priorHistory = priorQuery.data
|
||||
const priorRow = priorHistory?.found
|
||||
? {
|
||||
is_reapplicant: Boolean(priorHistory.is_reapplicant),
|
||||
previous_applications: Array.isArray(priorHistory.applications) ? priorHistory.applications : [],
|
||||
}
|
||||
: null
|
||||
|
||||
// Defaulting by derivation rather than in an effect: the picker resolves after
|
||||
// first paint, and useFormState's setters are new every render, so seeding the
|
||||
// field from an effect would either loop or need a ref to guard it.
|
||||
|
|
@ -1127,6 +1156,25 @@ function AddCandidate({ onClose, onSave, onInvalid }) {
|
|||
}
|
||||
>
|
||||
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
|
||||
{priorHistory?.found && priorRow?.previous_applications.length > 0 && (
|
||||
<PreviousApplications row={priorRow} title="This email already applied" />
|
||||
)}
|
||||
{priorHistory?.found && !(priorRow?.previous_applications.length) && (
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
boxShadow: 'none',
|
||||
background: 'var(--warning-soft)',
|
||||
border: '1px solid var(--warning)',
|
||||
marginBottom: 18,
|
||||
}}
|
||||
>
|
||||
<div className="card-body text-sm">
|
||||
This email already has a candidate account
|
||||
{priorHistory.user?.name ? ` (${priorHistory.user.name})` : ''}.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-grid">
|
||||
<div className="form-field">
|
||||
<label>Full Name <span className="req">*</span></label>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { seedQuery, useSeedMutation } from '../data/seedQueries'
|
|||
import { qk } from '../lib/queryKeys'
|
||||
import { exportStyledXlsx } from '../lib/exportXlsx'
|
||||
import { formatRole, fmtDate, fmtDateTime, fmtTime, toDate } from '../lib/format'
|
||||
import { PreviousApplications, ReappliedBadge } from '../components/ReapplicantHistory'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as inboxApi from '../api/inbox'
|
||||
import * as sheetApi from '../api/sheet'
|
||||
|
|
@ -260,6 +261,8 @@ function mapFormRow(row) {
|
|||
atsScore,
|
||||
assignedId,
|
||||
assignedPost: row.assigned_job_post || null,
|
||||
isReapplicant: Boolean(row.is_reapplicant),
|
||||
previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -374,6 +377,8 @@ async function fetchMessageDetail(recordId) {
|
|||
suggestedPosts: Array.isArray(row.suggested_job_posts) ? row.suggested_job_posts : [],
|
||||
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
|
||||
assignedPost: row.assigned_job_post || null,
|
||||
isReapplicant: Boolean(row.is_reapplicant),
|
||||
previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -422,6 +427,8 @@ async function fetchApplications(params) {
|
|||
? row.suggested_job_post_ids.map(String)
|
||||
: [],
|
||||
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
|
||||
isReapplicant: Boolean(row.is_reapplicant),
|
||||
previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [],
|
||||
}
|
||||
}),
|
||||
total: Number(res?.total ?? rows.length) || 0,
|
||||
|
|
@ -1363,6 +1370,7 @@ export default function Inbox() {
|
|||
{i.duplicate && (
|
||||
<span className="badge b-red badge-plain" style={{ padding: '1px 6px', fontSize: 10 }}>DUP</span>
|
||||
)}
|
||||
<ReappliedBadge row={i} />
|
||||
</div>
|
||||
<div className="ii-pos">{i.position}</div>
|
||||
<div className="ii-aside">
|
||||
|
|
@ -1633,7 +1641,10 @@ function FormApplicantDetail({
|
|||
<div className="flex items-center gap-16" style={{ marginBottom: 20 }}>
|
||||
<Avatar name={i.name} initials={i.initials} color={i.color} className="avatar-lg" />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="ph-name" style={{ fontSize: 19 }}>{i.name}</div>
|
||||
<div className="ph-name" style={{ fontSize: 19 }}>
|
||||
{i.name}
|
||||
<ReappliedBadge row={i} />
|
||||
</div>
|
||||
<div className="ph-role">{i.position}</div>
|
||||
<div className="ph-tags" style={{ marginTop: 8 }}>
|
||||
<SourceChip item={i} /> <Badge>{i.processing}</Badge>{' '}
|
||||
|
|
@ -1662,6 +1673,8 @@ function FormApplicantDetail({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<PreviousApplications row={i} />
|
||||
|
||||
{(resumeHref || profileHref) && (
|
||||
<div className="flex gap-8" style={{ flexWrap: 'wrap', marginBottom: 18 }}>
|
||||
{resumeHref && (
|
||||
|
|
@ -2003,7 +2016,10 @@ function ApplicationDetail({
|
|||
<div className="flex items-center gap-16" style={{ marginBottom: 20 }}>
|
||||
<Avatar name={i.name} initials={i.initials} color={i.color} className="avatar-lg" />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="ph-name" style={{ fontSize: 19 }}>{i.name}</div>
|
||||
<div className="ph-name" style={{ fontSize: 19 }}>
|
||||
{i.name}
|
||||
<ReappliedBadge row={i} />
|
||||
</div>
|
||||
<div className="ph-role">{i.position}</div>
|
||||
<div className="ph-tags" style={{ marginTop: 8 }}>
|
||||
<SourceChip item={i} /> <Badge>{i.processing}</Badge>{' '}
|
||||
|
|
@ -2027,6 +2043,8 @@ function ApplicationDetail({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<PreviousApplications row={i} />
|
||||
|
||||
{(resumeKey || i.hasAttachment || profileHref) && (
|
||||
<div className="flex gap-8" style={{ flexWrap: 'wrap', marginBottom: 18 }}>
|
||||
{(resumeKey || i.hasAttachment) && (
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { Avatar, Badge, EmptyState, Icon, ProgressBar } from '../ui/primitives'
|
|||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as candidatesApi from '../api/candidates'
|
||||
import { PreviousApplications, ReappliedBadge } from '../components/ReapplicantHistory'
|
||||
import { avatarColor, fmtDate, initials as initialsOf } from '../data/seed'
|
||||
|
||||
const SOURCE_LABEL = { upload: 'Upload', inbox: 'Email' }
|
||||
|
|
@ -75,7 +76,10 @@ function CandidateCard({ c, onView }) {
|
|||
<div className="cand-head">
|
||||
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} className="avatar-lg" />
|
||||
<div className="cand-id">
|
||||
<div className="cand-name">{displayName(c.name)}</div>
|
||||
<div className="cand-name">
|
||||
{displayName(c.name)}
|
||||
<ReappliedBadge row={c} />
|
||||
</div>
|
||||
<div className="cand-role">{c.currentTitle ?? '—'}</div>
|
||||
</div>
|
||||
<MiniRing score={c.aiScore} />
|
||||
|
|
@ -147,7 +151,10 @@ export function ScoredCandidateDetail({ candidate: c, jobTitle, onClose }) {
|
|||
<div className="profile-hero">
|
||||
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} className="avatar-lg" />
|
||||
<div className="flex-1">
|
||||
<div className="ph-name">{displayName(c.name)}</div>
|
||||
<div className="ph-name">
|
||||
{displayName(c.name)}
|
||||
<ReappliedBadge row={c} />
|
||||
</div>
|
||||
<div className="ph-role">{roleLine}</div>
|
||||
<div className="ph-tags">
|
||||
<Badge className="b-gray">{SOURCE_LABEL[c.source] ?? c.source ?? '—'}</Badge>
|
||||
|
|
@ -172,6 +179,7 @@ export function ScoredCandidateDetail({ candidate: c, jobTitle, onClose }) {
|
|||
<div className="tab-pane active">
|
||||
{tab === 'Overview' && (
|
||||
<>
|
||||
<PreviousApplications row={c} />
|
||||
<div className="info-grid" style={{ marginBottom: 20 }}>
|
||||
<div className="info-item"><div className="il">Candidate</div><div className="iv">{displayName(c.name)}</div></div>
|
||||
<div className="info-item"><div className="il">Current Title</div><div className="iv">{c.currentTitle ?? '—'}</div></div>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { qk } from '../lib/queryKeys'
|
|||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as jobPostsApi from '../api/jobPosts'
|
||||
import * as pipelineApi from '../api/pipeline'
|
||||
import { ReappliedBadge } from '../components/ReapplicantHistory'
|
||||
|
||||
/* Stage colours reference CSS tokens so the board re-tints with the theme. */
|
||||
export const KANBAN_STAGES = [
|
||||
|
|
@ -286,6 +287,7 @@ export default function Pipeline() {
|
|||
{c.source === 'Form' && (
|
||||
<Badge className="b-gray" style={{ fontSize: 10 }}>Form</Badge>
|
||||
)}
|
||||
<ReappliedBadge row={c} />
|
||||
</div>
|
||||
<div className="kc-role">{c.currentTitle}</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/* ============================================================
|
||||
Settings — org settings tabs persist via GET/PUT /org-settings/*.
|
||||
Excluding Universities / Companies are CRUD lists on Exclude_University
|
||||
and Exclude_Company (not org_settings key/value). Users + Appearance stay
|
||||
Excluding Universities / Companies are CRUD lists on exclude_university
|
||||
and exclude_company (not org_settings key/value). Users + Appearance stay
|
||||
as before. Email Templates stay decorative (explicitly out of Section C
|
||||
wiring scope). The Permissions tab remains chrome; Access Control is the
|
||||
authoritative RBAC surface.
|
||||
|
|
@ -37,7 +37,11 @@ function humaniseSlug(slug) {
|
|||
const TABS = [
|
||||
'General', 'Users', 'Approvals', 'Permissions', 'Notifications',
|
||||
'Email Templates', 'Career Portal', 'Branding', 'Security', 'Appearance',
|
||||
'Excluding Universities', 'Excluding Companies',
|
||||
]
|
||||
|
||||
const EXCLUSION_TABS = [
|
||||
'Excluding Universities',
|
||||
'Excluding Companies',
|
||||
]
|
||||
|
||||
const ORG_TABS = new Set(['General', 'Notifications', 'Career Portal', 'Branding', 'Security'])
|
||||
|
|
@ -140,16 +144,24 @@ export default function Settings() {
|
|||
)}
|
||||
/>
|
||||
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
className="tabs tabs-wrap"
|
||||
tabs={TABS.map((t) => ({
|
||||
key: t,
|
||||
label: t,
|
||||
count: t === 'Approvals' && pendingCount ? pendingCount : undefined,
|
||||
}))}
|
||||
/>
|
||||
<div className="settings-tabs">
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
className="tabs settings-tabs-primary"
|
||||
tabs={TABS.map((t) => ({
|
||||
key: t,
|
||||
label: t,
|
||||
count: t === 'Approvals' && pendingCount ? pendingCount : undefined,
|
||||
}))}
|
||||
/>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
className="tabs settings-tabs-exclude"
|
||||
tabs={EXCLUSION_TABS.map((t) => ({ key: t, label: t }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="tab-pane active">
|
||||
{tab === 'General' && <General registerSave={(fn) => { saveRef.current = fn }} />}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import { friendlyAuthError } from '../lib/errors'
|
|||
import * as candidatesApi from '../api/candidates'
|
||||
import * as jobPostsApi from '../api/jobPosts'
|
||||
import * as pipelineApi from '../api/pipeline'
|
||||
import { ReappliedBadge } from '../components/ReapplicantHistory'
|
||||
import { fmtDate } from '../lib/format'
|
||||
import { avatarColor, initials as initialsOf } from '../data/seed'
|
||||
|
||||
|
|
@ -121,6 +122,8 @@ function merge(row, template) {
|
|||
// a recruiter would read as a real match.
|
||||
aiScore: row.ai_score ?? null,
|
||||
recommendation: row.recommendation ?? null,
|
||||
isReapplicant: Boolean(row.is_reapplicant),
|
||||
previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -353,7 +356,10 @@ export default function TalentPool() {
|
|||
<div className="flex items-center gap-12" style={{ marginBottom: 12 }}>
|
||||
<Avatar name={c.name} initials={c.initials} color={c.color} className="avatar-lg" />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="lr-title">{c.name}</div>
|
||||
<div className="lr-title">
|
||||
{c.name}
|
||||
<ReappliedBadge row={c} />
|
||||
</div>
|
||||
<div className="lr-sub">{c.currentTitle}</div>
|
||||
</div>
|
||||
{c.aiScore != null && <ScoreChip score={c.aiScore} />}
|
||||
|
|
|
|||
|
|
@ -1798,6 +1798,23 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
|||
ten tabs do not fit the 860px profile modal. */
|
||||
.tabs-wrap { flex-wrap: wrap; overflow-x: visible; }
|
||||
|
||||
/* Settings: keep exclusion tabs on their own full-width bar, grouped left
|
||||
with a wider hit area so they no longer wrap as a leftover second row. */
|
||||
.settings-tabs { margin-bottom: var(--space-5); }
|
||||
.settings-tabs-primary { margin-bottom: 0; }
|
||||
.settings-tabs-exclude {
|
||||
margin-bottom: 0;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.settings-tabs-exclude .tab {
|
||||
min-width: 240px;
|
||||
justify-content: center;
|
||||
padding-left: 28px;
|
||||
padding-right: 28px;
|
||||
}
|
||||
|
||||
/* Full-page candidate profile (/candidate/:userId).
|
||||
The page centers itself with a generous cap so ultrawide monitors don't get
|
||||
a mile-wide form, and everything below the cap is fluid — no fixed widths. */
|
||||
|
|
|
|||
Loading…
Reference in New Issue