Merge pull request 'SQS_BROKER' (#86) from SQS_BROKER into main
Deploy to S3 / deploy (push) Successful in 36s Details

Reviewed-on: #86
pull/87/head^2
ahmed.mujtaba 2026-09-09 14:20:57 +00:00
commit 52624d99d8
17 changed files with 1172 additions and 126 deletions

View File

@ -456,6 +456,65 @@ class FormData(SQLModel, table=True):
})
return rows
@classmethod
async def list_for_offer_picker(cls, session: AsyncSession, *, job_post_ids=None, search=None):
"""Unpromoted assigned sheet applicants for the offer dropdown."""
from job.job_post.models import JobPosts
assigned = func.coalesce(cls.assigned_job_post_id, cls.job_post_id)
qry = (
select(cls, JobPosts.title)
.outerjoin(JobPosts, assigned == JobPosts.id)
.where(assigned.is_not(None))
.where(cls.manual_upload_candidate_id.is_(None))
.where(cls.is_duplicate == False) # noqa: E712
.where(cls.processing_state != "rejected")
.order_by(cls.created_at.desc(), cls.id.desc())
)
if job_post_ids is not None:
ids = list(job_post_ids)
if not ids:
return []
qry = qry.where(assigned.in_(ids))
if search:
pattern = f"%{search.strip()}%"
qry = qry.where(or_(cls.name.ilike(pattern), cls.candidate_email.ilike(pattern)))
result = await session.execute(qry)
rows = []
for rec, title in result.all():
job_id = rec.assigned_job_post_id or rec.job_post_id
rows.append({
"form_data_id": str(rec.id),
"user_id": None,
"name": (rec.name or "").strip() or None,
"email": (rec.candidate_email or "").strip().lower() or None,
"job_post_id": str(job_id) if job_id else None,
"job_title": title or rec.position_applied_for or None,
"application_status": rec.processing_state or "PENDING",
})
return rows
@classmethod
async def form_ids_by_manual_ids(cls, session: AsyncSession, manual_ids):
"""form_data.id keyed by the promoted manual_upload_candidate_id."""
uids = []
for raw in manual_ids or []:
try:
uids.append(uuid.UUID(str(raw)))
except (TypeError, ValueError):
continue
if not uids:
return {}
result = await session.execute(
select(cls.manual_upload_candidate_id, cls.id)
.where(cls.manual_upload_candidate_id.in_(uids))
)
out = {}
for manual_id, form_id in result.all():
if manual_id and form_id:
out[str(manual_id)] = str(form_id)
return out
@classmethod
async def job_post_ids_by_emails(cls, session: AsyncSession, emails):
"""(email, job_post_id) pairs from assigned or job_post_id. Unlinked skipped."""

View File

@ -87,6 +87,7 @@ class Inbox(SQLModel, table=True):
Inbox_Messages.candidate_phone_number.label("phone"),
Inbox_Messages.assigned_job_post_id,
Inbox_Messages.application_status,
Inbox_Messages.is_duplicate,
Inbox_Messages.current_employment,
Inbox_Messages.current_title,
Inbox_Messages.experience,
@ -147,6 +148,7 @@ class Inbox(SQLModel, table=True):
"email":row["email"],
"linkedin_url":row["linkedin_url"] or None,
"application_status":status.value if status else None,
"is_duplicate": bool(row["is_duplicate"]),
"phone":row["phone"],
"assigned_job_post_id":str(row["assigned_job_post_id"]) if row["assigned_job_post_id"] else None,
"title":row["title"] or None,

View File

@ -89,6 +89,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
Users.email,
cls.job_post_id,
Users.name,
cls.candidate_name,
cls.candidate_phone,
JobPosts.title,
cls.status,
@ -154,7 +155,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
"user_id":str(row["user_id"]) if row["user_id"] else None,
"email":row["email"],
"job_post_id":str(row["job_post_id"]) if row["job_post_id"] else None,
"name":row["name"],
"name": (row["name"] or "").strip() or (row["candidate_name"] or "").strip() or None,
"candidate_phone":row["candidate_phone"],
"title":row["title"] or None,
"application_status":row["status"] or None,

View File

@ -20,3 +20,4 @@ class HistoryEvent(str, Enum):
ATS_SCORED = "ats.scored"
FORM_CREATED = "form.created"
FORM_UPDATED = "form.updated"
OFFER_SENT = "offer.sent"

View File

@ -0,0 +1,25 @@
-- 034_offers_sent_identity.sql
-- Offers may target inbox, manual-upload, or sheet FormData applications.
-- inbox_id was NOT NULL, which blocked non-email candidates. Applied at
-- startup by alembic_setup.run_manual_sql().
ALTER TABLE app.offers
ALTER COLUMN inbox_id DROP NOT NULL;
ALTER TABLE app.offers
ADD COLUMN IF NOT EXISTS manual_upload_candidate_id UUID REFERENCES app.manual_upload_candidate (id);
ALTER TABLE app.offers
ADD COLUMN IF NOT EXISTS form_data_id UUID REFERENCES app.form_data (id);
ALTER TABLE app.offers
ADD COLUMN IF NOT EXISTS equity_units INTEGER;
ALTER TABLE app.offers
ADD COLUMN IF NOT EXISTS equity_instrument VARCHAR;
CREATE INDEX IF NOT EXISTS ix_offers_manual_upload_candidate_id
ON app.offers (manual_upload_candidate_id);
CREATE INDEX IF NOT EXISTS ix_offers_form_data_id
ON app.offers (form_data_id);

View File

@ -42,6 +42,7 @@ _HISTORY_KIND = {
"ats.scored": "assessment",
"form.created": "approval",
"form.updated": "approval",
"offer.sent": "application",
"note.created": "message",
"note.updated": "message",
"feedback.created": "message",
@ -66,6 +67,7 @@ _HISTORY_TITLE = {
"ats.scored": "ATS score ready",
"form.created": "Form submitted",
"form.updated": "Form updated",
"offer.sent": "Offer sent",
}
_HISTORY_TAB = {
"stage.changed": "History",
@ -80,6 +82,7 @@ _HISTORY_TAB = {
"feedback.updated": "Activity",
"form.created": "Forms",
"form.updated": "Forms",
"offer.sent": "History",
"ats.scored": "Resume",
"document.uploaded": "History",
"candidate.created": "History",

View File

@ -69,19 +69,39 @@ class OfferIssue(BaseModel):
change_reason: str | None = None
class OfferSent(BaseModel):
offer_id: str | None = None
inbox_id: int | None = None
manual_upload_candidate_id: str | None = None
form_data_id: str | None = None
job_post_id: str
candidate_user_id: str | None = None
base_salary: float
currency: str | None = "USD"
salary_period: str | None = "year"
signing_bonus: float | None = None
annual_bonus_pct: float | None = None
equity_units: int | None = None
equity_instrument: str | None = None
start_date: datetime | None = None
expiry_date: datetime | None = None
change_reason: str | None = None
@router.get("/offers/fetch")
async def fetch_offers(
current_user: dict = Depends(require_permission(PermissionTag.OFFERS_VIEW)),
offer_id: str | None = Query(None),
status: str | None = Query(None),
inbox_id: int | None = Query(None),
job_post_id: str | None = Query(None),
top: int | None = Query(None),
skip: int = Query(0,ge=0),
session: AsyncSession = Depends(get_session),
):
try:
service=Offer(session=session)
data,total=await service.get_offers(offer_id,status,inbox_id,top,skip)
data,total=await service.get_offers(offer_id,status,inbox_id,job_post_id,top,skip)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
raise
@ -137,3 +157,37 @@ async def issue_offer(
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/offers/jobs/candidates/lists")
async def list_offer_candidates(
current_user: dict = Depends(require_permission(PermissionTag.OFFERS_VIEW)),
search: str | None = Query(None),
top: int | None = Query(None),
skip: int = Query(0,ge=0),
session: AsyncSession = Depends(get_session),
):
try:
service=Offer(session=session)
data,total=await service.list_candidates(current_user,search,top,skip)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/offers/jobs/sent")
async def send_job_offer(
payload: OfferSent,
current_user: dict = Depends(require_permission(PermissionTag.OFFERS_CREATE)),
session: AsyncSession = Depends(get_session),
):
try:
service=Offer(session=session)
data=await service.send_offer(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))

View File

@ -14,7 +14,11 @@ class Offers(SQLModel, table=True):
__tablename__ = "offers"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
inbox_id: int = Field(index=True, foreign_key="inbox.id")
inbox_id: int | None = Field(default=None, index=True, foreign_key="inbox.id")
manual_upload_candidate_id: uuid.UUID | None = Field(
default=None, index=True, foreign_key="manual_upload_candidate.id",
)
form_data_id: uuid.UUID | None = Field(default=None, index=True, foreign_key="form_data.id")
job_post_id: uuid.UUID = Field(foreign_key="job_posts.id")
candidate_user_id: uuid.UUID = Field(foreign_key="users.id")
status: str = Field(default="draft")
@ -67,6 +71,7 @@ class Offers(SQLModel, table=True):
*,
status: str | None = None,
inbox_id: int | None = None,
job_post_id=None,
top: int | None = None,
skip: int = 0,
):
@ -75,6 +80,9 @@ class Offers(SQLModel, table=True):
statement = statement.where(cls.status == status)
if inbox_id is not None:
statement = statement.where(cls.inbox_id == int(inbox_id))
jid = cls._as_uuid(job_post_id) if job_post_id is not None else None
if jid is not None:
statement = statement.where(cls.job_post_id == jid)
count_statement = select(func.count()).select_from(statement.subquery())
total = (await session.execute(count_statement)).scalar_one()
statement = statement.order_by(cls.created_at.desc())
@ -147,6 +155,34 @@ class Offers(SQLModel, table=True):
result = await session.execute(statement)
return int(result.scalar_one() or 0)
@classmethod
async def get_open_for_candidate_job(cls, session: AsyncSession, candidate_user_id, job_post_id):
uid = cls._as_uuid(candidate_user_id)
jid = cls._as_uuid(job_post_id)
if uid is None or jid is None:
return None
result = await session.execute(
select(cls)
.where(cls.candidate_user_id == uid, cls.job_post_id == jid)
.where(cls.status.in_(("sent", "negotiating")))
.order_by(cls.created_at.desc())
)
return result.scalars().first()
@classmethod
async def get_failed_for_candidate_job(cls, session: AsyncSession, candidate_user_id, job_post_id):
uid = cls._as_uuid(candidate_user_id)
jid = cls._as_uuid(job_post_id)
if uid is None or jid is None:
return None
result = await session.execute(
select(cls)
.where(cls.candidate_user_id == uid, cls.job_post_id == jid)
.where(cls.status == "failed")
.order_by(cls.created_at.desc())
)
return result.scalars().first()
class OfferStatusHistory(SQLModel, table=True):
__tablename__ = "offer_status_history"
@ -190,3 +226,5 @@ class OfferStatusHistory(SQLModel, table=True):
return row
import users.models as _users_models # noqa: E402, F401
import job.candidate.models as _manual_models # noqa: E402, F401
import g_sheet.models as _form_models # noqa: E402, F401

View File

@ -1,6 +1,145 @@
"""Offer helpers — compensation field list, offer-email template, Teams mail send.
Pure module: no FastAPI imports and no HTTPException.
`send_offer_mail` intentionally duplicates
`notifications.plugins.send_confirmation_mail` rather than importing it: that
helper is domain-named, and each domain owns its own mail copy and env reads.
"""
from __future__ import annotations
import os
from datetime import datetime, timezone
import httpx
from dotenv import load_dotenv
from candidate_forms.plugins import FORM_READY_STATUSES
load_dotenv()
TEAMS_MAIL_API_URL = os.getenv("TEAMS_MAIL_API_URL")
TEAMS_API_TOKEN = os.getenv("TEAMS_API_TOKEN")
MAIL_ACCEPTED_STATUSES = {200, 202}
INTERVIEW_PLUS = FORM_READY_STATUSES
OFFER_SUBJECT = "Offer from UtopiaBrands Recruitement team"
def non_validation_values():
fields=("base_salary","currency","salary_period","signing_bonus","annual_bonus_pct",
"equity_units","equity_instrument","start_date","expiry_date",
"cadre","gross_salary_in_words","subsidized_services","probation_period",
"notice_period","work_location","work_timings")
return fields
def email_key(value) -> str:
return (value or "").strip().lower()
def stage_value(value) -> str:
if value is None:
return ""
return (value.value if hasattr(value,"value") else str(value)).strip().upper()
def is_interview_plus(value) -> bool:
return stage_value(value) in INTERVIEW_PLUS
def parse_offer_datetime(value):
if value in (None,""):
return None
if isinstance(value,datetime):
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value
text=str(value).strip()
if not text:
return None
if text.endswith("Z"):
text=text[:-1]+"+00:00"
try:
parsed=datetime.fromisoformat(text)
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed
def _period_label(period) -> str:
raw=(period or "year").strip().lower()
if raw in ("annual","year"):
return "per year"
if raw=="month":
return "per month"
if raw=="hour":
return "per hour"
return raw
def _money_line(amount, currency) -> str:
cur=(currency or "USD").strip() or "USD"
try:
n=float(amount)
pretty=f"{n:,.0f}" if n==int(n) else f"{n:,.2f}"
except (TypeError,ValueError):
pretty=str(amount)
return f"{cur} {pretty}"
def render_offer_email(
candidate_name,
base_salary,
currency="USD",
salary_period="year",
*,
annual_bonus_pct=None,
signing_bonus=None,
equity_units=None,
equity_instrument=None,
) -> tuple[str, str]:
name=(candidate_name or "").strip() or "Candidate"
salary=_money_line(base_salary,currency)
period=_period_label(salary_period)
html=(
f"<p>Dear {name},</p>"
"<p>We are pleased to extend an offer of employment from UtopiaBrands.</p>"
f"<p>Base salary: <strong>{salary} {period}</strong>.</p>"
)
if annual_bonus_pct not in (None,""):
html+=f"<p>Annual bonus: <strong>{annual_bonus_pct}%</strong>.</p>"
if signing_bonus not in (None,""):
html+=f"<p>Signing bonus: <strong>{_money_line(signing_bonus,currency)}</strong>.</p>"
if equity_units not in (None,"",0):
instrument=(equity_instrument or "RSU").strip() or "RSU"
html+=f"<p>Equity: <strong>{equity_units} {instrument}</strong>.</p>"
html+="<p>Please reply to this email if you have questions about the offer.</p>"
return OFFER_SUBJECT,html
async def send_offer_mail(to_email: str, subject: str, html: str) -> None:
if not TEAMS_MAIL_API_URL or not TEAMS_API_TOKEN:
raise RuntimeError("TEAMS_MAIL_API_URL and TEAMS_API_TOKEN must be set")
fields = [
("subject", (None, subject)),
("body", (None, html)),
("content_type", (None, "html")),
("save_to_sent_items", (None, "false")),
("to", (None, to_email)),
]
async with httpx.AsyncClient(timeout=15.0) as client:
response = await client.post(
TEAMS_MAIL_API_URL,
files=fields,
headers={"Authorization": f"Bearer {TEAMS_API_TOKEN}"},
)
if response.status_code not in MAIL_ACCEPTED_STATUSES:
raise httpx.HTTPStatusError(
response.text,
request=response.request,
response=response,
)

View File

@ -1,9 +1,14 @@
def serialize_offer(row) -> dict:
def serialize_offer(row, candidate_name=None, created_by_name=None) -> dict:
return {
"id": str(row.id) if row.id else None,
"inbox_id": row.inbox_id,
"manual_upload_candidate_id": (
str(row.manual_upload_candidate_id) if row.manual_upload_candidate_id else None
),
"form_data_id": str(row.form_data_id) if row.form_data_id else None,
"job_post_id": str(row.job_post_id) if row.job_post_id else None,
"candidate_user_id": str(row.candidate_user_id) if row.candidate_user_id else None,
"candidate_name": candidate_name,
"status": row.status,
"base_salary": row.base_salary,
"currency": row.currency,
@ -26,6 +31,7 @@ def serialize_offer(row) -> dict:
"closed_at": row.closed_at.isoformat() if row.closed_at else None,
"issued_by": str(row.issued_by) if row.issued_by else None,
"created_by": str(row.created_by) if row.created_by else None,
"created_by_name": created_by_name,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
@ -43,3 +49,23 @@ def serialize_offer_history(row) -> dict:
"actor_kind": row.actor_kind,
"change_reason": row.change_reason,
}
def serialize_offer_candidate(item) -> dict:
manual=item.get("manual_upload_candidate_id")
form_id=item.get("form_data_id")
user_id=item.get("user_id")
job_id=item.get("job_post_id")
return {
"inbox_id": item.get("inbox_id"),
"manual_upload_candidate_id": str(manual) if manual else None,
"form_data_id": str(form_id) if form_id else None,
"source": item.get("source"),
"user_id": str(user_id) if user_id else None,
"name": item.get("name"),
"email": item.get("email"),
"job_post_id": str(job_id) if job_id else None,
"job_title": item.get("job_title"),
"application_status": item.get("application_status"),
"stage": item.get("stage") or item.get("application_status"),
}

View File

@ -1,12 +1,37 @@
import uuid
from datetime import datetime,timezone
import httpx
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
from g_sheet.models import FormData
from inbox.models import Inbox
from job.candidate.models import Manual_UPLOAD_CANDIDATE
from job.candidate.views import owned_job_ids_for_candidate_scope
from job.history.enums import HistoryEvent
from job.history.views import HistoryRecorder
from job.job_post.models import JobPosts
from job.pipeline.views import Pipeline
from notifications.models import Notifications
from offer.models import Offers,OfferStatusHistory
from offer.serializers import serialize_offer
from offer.plugins import non_validation_values
from offer.plugins import (
email_key,
is_interview_plus,
non_validation_values,
parse_offer_datetime,
render_offer_email,
send_offer_mail,
stage_value,
)
from offer.serializers import serialize_offer,serialize_offer_candidate
from users.models import Users
from users.permissions import sees_all_offers
SOURCE_RANK={"inbox":0,"manual":1,"form":2}
MAIL_FAIL_DETAIL="Failed: the offer could not be sent"
def _as_uuid(value):
if value in (None,""):
@ -26,24 +51,172 @@ def _user_id(current_user):
return uid
def _comp_fields(payload):
fields={}
for key in non_validation_values():
if key not in payload:
continue
value=payload[key]
if key in ("start_date","expiry_date"):
value=parse_offer_datetime(value)
fields[key]=value
return fields
class Offer:
def __init__(self,session:AsyncSession):
self.session=session
async def get_offers(self,offer_id=None,status=None,inbox_id=None,top=None,skip=0):
async def _hydrate_offers(self,rows):
ids=[]
for row in rows:
ids.append(row.candidate_user_id)
ids.append(row.created_by)
names=await Users.names_by_ids(self.session,ids)
return [
serialize_offer(
row,
candidate_name=names.get(str(row.candidate_user_id)),
created_by_name=names.get(str(row.created_by)),
)
for row in rows
]
async def get_offers(self,offer_id=None,status=None,inbox_id=None,job_post_id=None,top=None,skip=0):
if offer_id is not None:
row=await Offers.get_offer_by_id(self.session,offer_id)
if not row:
raise HTTPException(status_code=404,detail="Offer not found")
return serialize_offer(row),1
return (await self._hydrate_offers([row]))[0],1
rows,total=await Offers.fetch_offers(
self.session,
status=status,
inbox_id=inbox_id,
job_post_id=job_post_id,
top=top,
skip=skip or 0,
)
return [serialize_offer(r) for r in rows],total
return await self._hydrate_offers(rows),total
async def _offer_job_ids(self,current_user):
if sees_all_offers(current_user):
return None
return await owned_job_ids_for_candidate_scope(self.session,current_user)
async def _assert_offer_job(self,current_user,job_post_id):
if sees_all_offers(current_user):
return
owned=await owned_job_ids_for_candidate_scope(self.session,current_user)
owned=set(owned or [])
jid=_as_uuid(job_post_id)
if jid is None or jid not in owned:
raise HTTPException(status_code=403,detail="This offer is outside your assigned jobs")
async def list_candidates(self,current_user,search=None,top=None,skip=0):
owned=await self._offer_job_ids(current_user)
if owned is not None and not owned:
return [],0
inbox_rows=await Inbox.get_all(self.session,job_post_ids=owned)
manual_rows=await Manual_UPLOAD_CANDIDATE.get_all(self.session,job_post_ids=owned)
form_rows=await FormData.list_for_offer_picker(
self.session,job_post_ids=owned,search=None,
)
form_by_manual=await FormData.form_ids_by_manual_ids(
self.session,[r.get("id") for r in manual_rows],
)
merged={}
for row in inbox_rows:
if row.get("is_duplicate"):
continue
if not is_interview_plus(row.get("application_status")):
continue
item={
"inbox_id":row.get("inbox_id"),
"manual_upload_candidate_id":None,
"form_data_id":None,
"source":"inbox",
"user_id":row.get("user_id"),
"name":row.get("name"),
"email":email_key(row.get("email")),
"job_post_id":row.get("assigned_job_post_id"),
"job_title":row.get("title"),
"application_status":stage_value(row.get("application_status")),
}
self._merge_candidate(merged,item)
for row in manual_rows:
if not is_interview_plus(row.get("application_status")):
continue
mid=row.get("id")
item={
"inbox_id":None,
"manual_upload_candidate_id":mid,
"form_data_id":form_by_manual.get(str(mid)) if mid else None,
"source":"manual",
"user_id":row.get("user_id"),
"name":row.get("name") or None,
"email":email_key(row.get("email") or row.get("candidate_email")),
"job_post_id":row.get("job_post_id"),
"job_title":row.get("title"),
"application_status":stage_value(row.get("application_status")),
}
self._merge_candidate(merged,item)
for row in form_rows:
item={
"inbox_id":None,
"manual_upload_candidate_id":None,
"form_data_id":row.get("form_data_id"),
"source":"form",
"user_id":row.get("user_id"),
"name":row.get("name"),
"email":email_key(row.get("email")),
"job_post_id":row.get("job_post_id"),
"job_title":row.get("job_title"),
"application_status":stage_value(row.get("application_status")) or "PENDING",
}
self._merge_candidate(merged,item)
items=list(merged.values())
needle=(search or "").strip().lower()
if needle:
items=[
r for r in items
if needle in (r.get("name") or "").lower() or needle in (r.get("email") or "")
]
items.sort(key=lambda r:(r.get("name") or r.get("email") or "").lower())
total=len(items)
start=int(skip or 0)
if start:
items=items[start:]
if top is not None:
items=items[:int(top)]
return [serialize_offer_candidate(r) for r in items],total
def _merge_candidate(self,merged,item):
job_id=item.get("job_post_id")
if not job_id:
return
email=item.get("email") or ""
if email:
key=(email,str(job_id))
else:
source=item.get("source") or "row"
raw=item.get("inbox_id") or item.get("manual_upload_candidate_id") or item.get("form_data_id")
key=(f"noid:{source}:{raw}",str(job_id))
existing=merged.get(key)
if existing is None:
merged[key]=item
return
if SOURCE_RANK.get(item.get("source"),9)<SOURCE_RANK.get(existing.get("source"),9):
if not item.get("form_data_id"):
item["form_data_id"]=existing.get("form_data_id")
if not item.get("name"):
item["name"]=existing.get("name")
merged[key]=item
return
if not existing.get("form_data_id"):
existing["form_data_id"]=item.get("form_data_id")
if not existing.get("name"):
existing["name"]=item.get("name")
async def create_offer(self,payload,current_user):
if not payload.get("inbox_id"):
@ -146,3 +319,241 @@ class Offer:
}
await OfferStatusHistory.insert_history(self.session,history_data)
return serialize_offer(updated)
async def send_offer(self,payload,current_user):
created_by=_user_id(current_user)
app=await self._resolve_application(payload)
await self._assert_offer_job(current_user,app["job_post_id"])
candidate_user_id=app["candidate_user_id"]
job_post_id=app["job_post_id"]
if payload.get("base_salary") in (None,""):
raise HTTPException(status_code=422,detail="base_salary is required")
open_row=await Offers.get_open_for_candidate_job(self.session,candidate_user_id,job_post_id)
retry_id=_as_uuid(payload.get("offer_id"))
if open_row and (retry_id is None or open_row.id!=retry_id):
raise HTTPException(status_code=409,detail="An offer is already in progress for this candidate and job")
fields=_comp_fields(payload)
fields.update({
"inbox_id": app.get("inbox_id"),
"manual_upload_candidate_id": app.get("manual_upload_id"),
"form_data_id": app.get("form_data_id"),
"job_post_id": job_post_id,
"candidate_user_id": candidate_user_id,
"status": "failed",
})
if fields.get("equity_units") in (None,0):
fields["equity_instrument"]=None
row=None
if retry_id is not None:
row=await Offers.get_offer_by_id(self.session,retry_id)
if not row:
raise HTTPException(status_code=404,detail="Offer not found")
if row.status in ("sent","negotiating"):
raise HTTPException(status_code=409,detail="This offer has already been sent")
row=await Offers.update_offer(self.session,retry_id,fields)
else:
failed=await Offers.get_failed_for_candidate_job(self.session,candidate_user_id,job_post_id)
if failed:
row=await Offers.update_offer(self.session,failed.id,fields)
else:
fields["created_by"]=created_by
row=await Offers.insert_offer(self.session,fields)
await OfferStatusHistory.insert_history(self.session,{
"offer_id": row.id,
"from_status": None,
"to_status": "failed",
"changed_by": created_by,
"actor_kind": "user",
"change_reason": payload.get("change_reason"),
})
name,email,job_title=await self._offer_mail_context(row,app)
subject,html=render_offer_email(
name,row.base_salary,row.currency,row.salary_period,
annual_bonus_pct=row.annual_bonus_pct,
signing_bonus=row.signing_bonus,
equity_units=row.equity_units,
equity_instrument=row.equity_instrument,
)
try:
if not email:
raise RuntimeError("candidate has no email")
await send_offer_mail(email,subject,html)
except (httpx.HTTPError,RuntimeError) as e:
await self._notify_send_failed(created_by,name,job_title,row)
raise HTTPException(status_code=502,detail=MAIL_FAIL_DETAIL) from e
now=datetime.now(timezone.utc)
from_status=row.status
updated=await Offers.update_offer(self.session,row.id,{
"status": "sent",
"sent_at": now,
"issued_by": created_by,
})
await OfferStatusHistory.insert_history(self.session,{
"offer_id": updated.id,
"from_status": from_status,
"to_status": "sent",
"changed_by": created_by,
"actor_kind": "user",
"change_reason": "sent",
})
await self._advance_pipeline(app,current_user)
await HistoryRecorder(self.session).record(
HistoryEvent.OFFER_SENT.value,
current_user=current_user,
user_id=candidate_user_id,
inbox_id=app.get("inbox_id"),
manual_upload_candidate_id=app.get("manual_upload_id"),
entity_type="offer",
entity_id=updated.id,
to_value="OFFER",
description=f"Offer sent to {name}",
commit=True,
)
return (await self._hydrate_offers([updated]))[0]
async def _resolve_application(self,payload):
inbox_id=payload.get("inbox_id")
manual_id=_as_uuid(payload.get("manual_upload_candidate_id"))
form_id=_as_uuid(payload.get("form_data_id"))
requested_job=_as_uuid(payload.get("job_post_id"))
present=sum(1 for v in (inbox_id not in (None,""), manual_id is not None, form_id is not None) if v)
if present!=1:
raise HTTPException(
status_code=422,
detail="Exactly one of inbox_id, manual_upload_candidate_id or form_data_id is required",
)
if inbox_id not in (None,""):
return await self._resolve_inbox(inbox_id,requested_job)
if form_id is not None:
return await self._resolve_form(form_id,requested_job)
return await self._resolve_manual(manual_id,requested_job)
async def _resolve_inbox(self,inbox_id,requested_job):
inbox=await Inbox.get_inbox_with_message(self.session,inbox_id)
if not inbox or not inbox.messages:
raise HTTPException(status_code=404,detail="Inbox not found")
message=inbox.messages
job_id=message.assigned_job_post_id
if job_id is None:
raise HTTPException(status_code=422,detail="Candidate is not assigned to a job")
if requested_job is not None and job_id!=requested_job:
raise HTTPException(status_code=422,detail="job_post_id does not match the application")
if not is_interview_plus(message.application_status):
raise HTTPException(status_code=422,detail="Candidate is not at interview stage or later")
if not inbox.user_id:
raise HTTPException(status_code=422,detail="candidate_user_id is required")
return {
"inbox_id": inbox.id,
"manual_upload_id": None,
"form_data_id": None,
"job_post_id": job_id,
"candidate_user_id": inbox.user_id,
}
async def _resolve_manual(self,manual_id,requested_job,require_interview=True):
row=await Manual_UPLOAD_CANDIDATE.get_by_id(self.session,manual_id)
if not row:
raise HTTPException(status_code=404,detail="Manual upload candidate not found")
if not row.job_post_id:
raise HTTPException(status_code=422,detail="Candidate is not assigned to a job")
if requested_job is not None and row.job_post_id!=requested_job:
raise HTTPException(status_code=422,detail="job_post_id does not match the application")
if require_interview and not is_interview_plus(row.status):
raise HTTPException(status_code=422,detail="Candidate is not at interview stage or later")
if not row.user_id:
raise HTTPException(status_code=422,detail="candidate_user_id is required")
form_ids=await FormData.form_ids_by_manual_ids(self.session,[row.id])
return {
"inbox_id": None,
"manual_upload_id": row.id,
"form_data_id": _as_uuid(form_ids.get(str(row.id))),
"job_post_id": row.job_post_id,
"candidate_user_id": row.user_id,
}
async def _resolve_form(self,form_id,requested_job):
form_row=await FormData.get_form_data_by_id(self.session,form_id)
if not form_row:
raise HTTPException(status_code=404,detail="Form applicant not found")
job_id=form_row.assigned_job_post_id or form_row.job_post_id
if job_id is None:
raise HTTPException(status_code=422,detail="Candidate is not assigned to a job")
if requested_job is not None and job_id!=requested_job:
raise HTTPException(status_code=422,detail="job_post_id does not match the application")
if form_row.is_duplicate:
raise HTTPException(status_code=422,detail="Duplicate form applicants cannot receive an offer")
if (form_row.processing_state or "").strip().lower()=="rejected":
raise HTTPException(status_code=422,detail="Rejected form applicants cannot receive an offer")
if not form_row.job_post_id and form_row.assigned_job_post_id:
form_row.job_post_id=form_row.assigned_job_post_id
self.session.add(form_row)
await self.session.commit()
if form_row.manual_upload_candidate_id:
return await self._resolve_manual(
form_row.manual_upload_candidate_id,job_id,require_interview=False,
)
from g_sheet.views import SheetFormData
promoted=await SheetFormData(session=self.session)._promote_to_application(form_row)
if not promoted or not promoted.user_id:
raise HTTPException(status_code=422,detail="Could not promote this form applicant")
return {
"inbox_id": None,
"manual_upload_id": promoted.id,
"form_data_id": form_row.id,
"job_post_id": promoted.job_post_id or job_id,
"candidate_user_id": promoted.user_id,
}
async def _offer_mail_context(self,row,app):
names=await Users.names_by_ids(self.session,[row.candidate_user_id])
name=names.get(str(row.candidate_user_id))
email=None
result=await self.session.execute(
select(Users.email,Users.name).where(Users.id==row.candidate_user_id)
)
pair=result.first()
if pair:
email=(pair[0] or "").strip().lower() or None
name=name or pair[1]
job=await JobPosts.get_job_post_by_id(self.session,row.job_post_id)
title=job.title if job else None
return name or "Candidate",email,title
async def _advance_pipeline(self,app,current_user):
pipeline=Pipeline(session=self.session)
try:
if app.get("inbox_id") is not None:
await pipeline.change_stage(
"OFFER",current_user,inbox_id=app["inbox_id"],
change_reason="Offer sent",
)
elif app.get("manual_upload_id") is not None:
await pipeline.change_stage(
"OFFER",current_user,manual_upload_id=app["manual_upload_id"],
change_reason="Offer sent",
)
except HTTPException as exc:
if exc.status_code!=400:
raise
async def _notify_send_failed(self,user_id,candidate_name,job_title,row):
try:
bits=[candidate_name] if candidate_name else []
if job_title:
bits.append(job_title)
body=" · ".join(bits) if bits else MAIL_FAIL_DETAIL
await Notifications.insert_notification(self.session,{
"user_id": user_id,
"kind": "message",
"title": MAIL_FAIL_DETAIL,
"body": body,
"link_path": f"/offers?offer={row.id}",
"job_post_id": row.job_post_id,
})
except Exception:
pass

View File

@ -247,6 +247,18 @@ def sees_all_candidates(current_user: dict | None) -> bool:
return PermissionTag.CANDIDATES_MANAGE.value in granted
def sees_all_offers(current_user: dict | None) -> bool:
"""Unscoped offer candidate picker: admins, or offers.manage.
Recruiters and hiring managers otherwise see only candidates on jobs they
are linked to. Do not key this off role_id.
"""
if is_admin(current_user):
return True
granted = (current_user or {}).get("permissions") or []
return PermissionTag.OFFERS_MANAGE.value in granted
def scopes_to_own_requisitions(current_user: dict | None) -> bool:
"""Jobs and candidates limited to requisitions this user created (or is assigned).

View File

@ -7,19 +7,17 @@ import { toDate } from '../lib/format'
Permissioned OFFERS_VIEW / OFFERS_CREATE / OFFERS_EDIT / OFFERS_APPROVE, so a
recruiter who can read offers still cannot issue one.
serialize_offer returns FOREIGN KEYS ONLY inbox_id, job_post_id,
candidate_user_id. There is no candidate name, job title, department or
recruiter on the wire, so the Offers screen hydrates those from two reads it
already makes (`/pipeline/candidates/fetch` for the person, `/job/fetch?ids=`
for the role) rather than issuing one request per row.
List rows include candidate_name / created_by_name. Job title is still
hydrated from /job/fetch?ids=. Create Offer uses POST /offers/jobs/sent.
============================================================ */
/** `status` is a free-text column defaulting to "draft"; the vocabulary is decided here. */
export const OFFER_STATUSES = ['draft', 'sent', 'negotiating', 'accepted', 'declined', 'expired']
export const OFFER_STATUSES = ['draft', 'sent', 'failed', 'negotiating', 'accepted', 'declined', 'expired']
export const OFFER_STATUS_LABEL = {
draft: 'Draft',
sent: 'Sent',
failed: 'Failed',
negotiating: 'Negotiating',
accepted: 'Accepted',
declined: 'Declined',
@ -38,20 +36,37 @@ const STATUS_CLASS = {
declined: 'b-red',
expired: 'b-gray',
draft: 'b-gray',
failed: 'b-red',
}
export function list({ offerId, status, inboxId, top, skip } = {}) {
export function list({ offerId, status, inboxId, jobPostId, top, skip } = {}) {
return request('/offers/fetch', {
params: {
offer_id: offerId,
status,
inbox_id: inboxId,
job_post_id: jobPostId,
top,
skip,
},
})
}
/** One offer. GET /offers/fetch?offer_id= returns `data` as the row, not a list. */
export function get(offerId) {
return request('/offers/fetch', { params: { offer_id: offerId } })
}
export function listCandidates({ search, top, skip } = {}) {
return request('/offers/jobs/candidates/lists', {
params: { search, top, skip },
})
}
export function send(body) {
return request('/offers/jobs/sent', { method: 'POST', body })
}
/**
* Create a draft. All three links are REQUIRED server-side `inbox_id` (422 if
* blank), `job_post_id` and `candidate_user_id` (422 if not parseable as UUIDs)
@ -108,10 +123,13 @@ export function toOfferView(row, { people, jobTitles } = {}) {
return {
id: row.id,
inboxId: row.inbox_id,
manualUploadId: row.manual_upload_candidate_id ?? null,
formDataId: row.form_data_id ?? null,
jobPostId: row.job_post_id,
candidateUserId: row.candidate_user_id,
candidate: person?.name || 'Unknown candidate',
candidate: person?.name || row.candidate_name || 'Unknown candidate',
email: person?.email ?? null,
createdByName: row.created_by_name || null,
jobTitle: jobTitle || '—',
status,
statusLabel: OFFER_STATUS_LABEL[status] ?? status,

View File

@ -128,7 +128,12 @@ export const qk = {
recruiters: (p = {}) => ['analytics', 'recruiters', p],
jobApps: (p = {}) => ['analytics', 'job-apps', p],
},
offers: { all: () => ['offers'], list: (p = {}) => ['offers', 'list', p] },
offers: {
all: () => ['offers'],
list: (p = {}) => ['offers', 'list', p],
detail: (id) => ['offers', 'detail', id],
candidates: (p = {}) => ['offers', 'candidates', p],
},
forms: {
all: () => ['forms'],
list: (p = {}) => ['forms', 'list', p],

View File

@ -748,6 +748,7 @@ const HISTORY_ICON = {
'candidate.imported': { icon: 'upload', tone: 'i-green' },
'document.uploaded': { icon: 'paperclip', tone: 'i-red' },
'ats.scored': { icon: 'sparkles', tone: 'i-blue' },
'offer.sent': { icon: 'send', tone: 'i-teal' },
}
const HISTORY_TITLE = {
@ -767,6 +768,7 @@ const HISTORY_TITLE = {
'candidate.imported': 'Candidate imported',
'document.uploaded': 'Document uploaded',
'ats.scored': 'ATS scored',
'offer.sent': 'Offer sent',
}
function historyDayLabel(value) {

View File

@ -29,7 +29,8 @@ import * as assignmentsApi from '../api/assignments'
import * as tasksApi from '../api/tasks'
import * as usersApi from '../api/users'
import * as requisitionsApi from '../api/requisitions'
import { fmtDateTime, fmtShort, toDate } from '../lib/format'
import * as offersApi from '../api/offers'
import { fmtDate, fmtDateTime, fmtShort, toDate } from '../lib/format'
import { empTypes } from '../data/seed'
// Backend allows up to 500; stay at 100 so we match Managers.jsx (shared qk.jobs.list).
@ -1230,18 +1231,23 @@ function JobOwnership({ job, canEdit }) {
)
}
function JobHistory({ historyQuery, statusQuery }) {
function JobHistory({ historyQuery, statusQuery, offersQuery }) {
const assignments = historyQuery.data ?? []
const statusRows = statusQuery.data ?? []
const offerRows = offersQuery?.data ?? []
if (historyQuery.isError && statusQuery.isError) {
if (historyQuery.isError && statusQuery.isError && offersQuery?.isError) {
return (
<p className="text-muted text-sm">
{friendlyAuthError(historyQuery.error, 'History did not load.')}
</p>
)
}
if ((historyQuery.isPending && !historyQuery.data) || (statusQuery.isPending && !statusQuery.data)) {
if (
(historyQuery.isPending && !historyQuery.data)
|| (statusQuery.isPending && !statusQuery.data)
|| (offersQuery?.isPending && !offersQuery.data)
) {
return <p className="text-muted text-sm">Loading</p>
}
@ -1258,6 +1264,14 @@ function JobHistory({ historyQuery, statusQuery }) {
at: toDate(row.created_at),
row,
})),
...offerRows
.filter((row) => row.status === 'sent' || row.sent_at)
.map((row) => ({
kind: 'offer',
id: `o-${row.id}`,
at: toDate(row.sent_at || row.created_at),
row,
})),
].sort((a, b) => (b.at?.getTime() || 0) - (a.at?.getTime() || 0))
if (events.length === 0) {
@ -1266,11 +1280,11 @@ function JobHistory({ historyQuery, statusQuery }) {
return (
<div className="list-tight">
{events.map((ev) => (
ev.kind === 'status'
? <StatusHistoryRow key={ev.id} row={ev.row} at={ev.at} />
: <AssignmentHistoryRow key={ev.id} row={ev.row} />
))}
{events.map((ev) => {
if (ev.kind === 'status') return <StatusHistoryRow key={ev.id} row={ev.row} at={ev.at} />
if (ev.kind === 'offer') return <OfferHistoryRow key={ev.id} row={ev.row} at={ev.at} />
return <AssignmentHistoryRow key={ev.id} row={ev.row} />
})}
</div>
)
}
@ -1298,6 +1312,26 @@ function StatusHistoryRow({ row, at }) {
)
}
function OfferHistoryRow({ row, at }) {
const candidate = row.candidate_name || 'a candidate'
const actor = row.created_by_name || 'Someone'
const sent = row.status === 'sent'
return (
<div className="list-row">
<span className="kpi-icn i-teal" style={{ width: 38, height: 38, borderRadius: 10 }}>
<Icon name="send" />
</span>
<div className="lr-main">
<div className="lr-title">{sent ? `Offer sent to ${candidate}` : `Offer for ${candidate}`}</div>
<div className="lr-sub">
{[actor, fmtDate(row.sent_at) || (at ? fmtDate(at) : null)].filter(Boolean).join(' · ')}
</div>
</div>
<Badge className={sent ? 'b-teal' : 'b-red'}>{sent ? 'Offer' : (row.status || 'Offer')}</Badge>
</div>
)
}
function AssignmentHistoryRow({ row }) {
return (
<div className="list-row">
@ -1365,7 +1399,16 @@ function JobDetail({
enabled: Boolean(j.id),
retry: false,
})
const historyCount = (historyQuery.data?.length ?? 0) + (statusQuery.data?.length ?? 0)
const offersQuery = useQuery({
queryKey: qk.offers.list({ jobPostId: j.id, top: 200 }),
queryFn: async () => {
const res = await offersApi.list({ jobPostId: j.id, top: 200 })
return Array.isArray(res?.data) ? res.data : []
},
enabled: Boolean(j.id),
retry: false,
})
const historyCount = (historyQuery.data?.length ?? 0) + (statusQuery.data?.length ?? 0) + (offersQuery.data?.length ?? 0)
return (
<Modal
@ -1460,7 +1503,7 @@ function JobDetail({
</>
)}
{tab === 'history' && <JobHistory historyQuery={historyQuery} statusQuery={statusQuery} />}
{tab === 'history' && <JobHistory historyQuery={historyQuery} statusQuery={statusQuery} offersQuery={offersQuery} />}
</Modal>
)
}

View File

@ -1,26 +1,19 @@
/* ============================================================
Offers live on backend/offer/app.py.
Read is GET /offers/fetch; Create Offer writes POST /offers/create; Send and
Resend write POST /offers/issue (which stamps issued_by + sent_at and moves
draft -> sent, logging the change to offer_status_history); the response
actions write PATCH /offers/update.
Read is GET /offers/fetch. Create Offer writes POST /offers/jobs/sent
(persist, email via Teams, then pipeline OFFER). Failed sends stay listed;
GET /offers/fetch?offer_id= prefills the form for retry. Drafts still use
POST /offers/create (Candidate Forms) and POST /offers/issue.
HYDRATION, NOT N+1. serialize_offer returns foreign keys only no candidate
name, job title, department or recruiter. Two reads the screen needs anyway
fill those in: the pipeline board (one row per application, carrying the
person, their user id and their inbox id together) and /job/fetch?ids= for
the titles. A per-row lookup would be one request per offer.
Two columns from the prototype are gone. `department` is not on a job post at
all in the offers path, and `recruiter` is not on the offer record neither
has a source, so neither is rendered. Equity is `equity_units` +
`equity_instrument` server-side, so the free-text "20k RSU" box became a
number and a picker; nothing else would round-trip.
HYDRATION, NOT N+1. serialize_offer now includes candidate_name when listing;
job titles still come from /job/fetch?ids=. Equity is `equity_units` +
`equity_instrument` server-side.
============================================================ */
import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useMemo, useState, useEffect, useRef } from 'react'
import { useSearchParams } from 'react-router-dom'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import DataTable from '../ui/DataTable'
import Modal from '../ui/Modal'
@ -29,6 +22,7 @@ import { Avatar, Badge, EmptyState, FieldError, Icon, KpiCard, SkeletonRows } fr
import { useToast } from '../ui/Toast'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import { formatRole, toDateInput } from '../lib/format'
import * as offersApi from '../api/offers'
import * as jobPostsApi from '../api/jobPosts'
import { OFFER_STATUS_LABEL, OFFER_STATUS_VALUE } from '../api/offers'
@ -37,16 +31,11 @@ import { avatarColor, fmtDate, fmtShort, initials as initialsOf, money } from '.
const FETCH_TOP = 200
/* Stages a candidate must be at before an offer makes sense. The API does not
enforce this it is a data-entry guard, so the picker does not invite an
offer to someone still in screening. */
const OFFER_READY_STAGES = ['Interview', 'Offer', 'Hired']
/** Statuses reachable from the row menu, keyed by where the offer is now. */
const NEXT_STATUSES = {
sent: ['negotiating', 'accepted', 'declined'],
negotiating: ['accepted', 'declined'],
draft: [],
failed: [],
accepted: [],
declined: [],
expired: [],
@ -65,27 +54,22 @@ function useOffers(status) {
export default function Offers() {
const { toast } = useToast()
const qc = useQueryClient()
const [searchParams, setSearchParams] = useSearchParams()
const [q, setQ] = useState('')
const [statusLabel, setStatusLabel] = useState('')
const [viewing, setViewing] = useState(null)
const [creating, setCreating] = useState(false)
const [creating, setCreating] = useState(null)
const status = statusLabel ? OFFER_STATUS_VALUE[statusLabel] : ''
const offersQuery = useOffers(status)
/* KPIs count the whole table, not the filtered page. With no filter this is
the same query key as above, so React Query serves both from one request. */
const allQuery = useOffers('')
const appsQuery = useApplications()
/* candidate_user_id -> the person. Built from the pipeline board, which is
the only payload carrying user id, name and email on one row. */
const peopleByUserId = useMemo(() => byUserId(appsQuery.data), [appsQuery.data])
/* Titles for every job referenced by an offer, in one call. Offers can point
at a closed requisition, so active_only is false. */
const jobIds = useMemo(() => {
const ids = new Set()
for (const row of allQuery.data ?? []) {
@ -128,7 +112,7 @@ export default function Offers() {
const decided = all.filter((o) => ['accepted', 'declined'].includes(o.status)).length
const accepted = all.filter((o) => o.status === 'accepted').length
return {
sent: all.filter((o) => o.status !== 'draft').length,
sent: all.filter((o) => o.status !== 'draft' && o.status !== 'failed').length,
accepted,
pending: all.filter((o) => ['sent', 'negotiating'].includes(o.status)).length,
rate: Math.round((accepted / (decided || 1)) * 100),
@ -145,7 +129,20 @@ export default function Offers() {
[offers, q],
)
const invalidate = () => qc.invalidateQueries({ queryKey: qk.offers.all() })
const invalidate = () => {
qc.invalidateQueries({ queryKey: qk.offers.all() })
qc.invalidateQueries({ queryKey: qk.notifications.all() })
qc.invalidateQueries({ queryKey: qk.pipeline.all() })
}
useEffect(() => {
const offerId = searchParams.get('offer')
if (offerId) {
setCreating({ offerId })
searchParams.delete('offer')
setSearchParams(searchParams, { replace: true })
}
}, [searchParams, setSearchParams])
const issue = useMutation({
mutationFn: (offerId) => offersApi.issue(offerId),
@ -174,14 +171,17 @@ export default function Offers() {
onError: (err) => toast(friendlyAuthError(err, 'Could not update the offer.'), 'error'),
})
const create = useMutation({
mutationFn: (body) => offersApi.create(body),
const send = useMutation({
mutationFn: (body) => offersApi.send(body),
onSuccess: () => {
invalidate()
setCreating(false)
toast('Offer created as a draft — issue it when ready', 'success')
setCreating(null)
toast('Offer emailed and moved to Offer stage', 'success')
},
onError: (err) => {
invalidate()
toast(friendlyAuthError(err, 'Failed: the offer could not be sent'), 'error')
},
onError: (err) => toast(friendlyAuthError(err, 'Could not create the offer.'), 'error'),
})
const busy = issue.isPending || setStatus.isPending
@ -225,15 +225,17 @@ export default function Offers() {
key: '_a', label: 'Actions', align: 'right',
render: (o) => (
<div className="row-actions">
<button className="act-btn" data-tip="View" aria-label="View offer" onClick={() => setViewing(o)}>
<button className="act-btn" data-tip="View" aria-label="View offer" onClick={() => (
o.status === 'failed' ? setCreating({ offerId: o.id }) : setViewing(o)
)}>
<Icon name="eye" />
</button>
<button
className="act-btn"
data-tip={o.status === 'draft' ? 'Send offer' : 'Resend'}
aria-label={o.status === 'draft' ? 'Send offer' : 'Resend offer'}
data-tip={o.status === 'failed' ? 'Edit and resend' : o.status === 'draft' ? 'Send offer' : 'Resend'}
aria-label={o.status === 'failed' ? 'Edit and resend offer' : o.status === 'draft' ? 'Send offer' : 'Resend offer'}
disabled={busy || ['accepted', 'declined'].includes(o.status)}
onClick={() => issue.mutate(o.id)}
onClick={() => (o.status === 'failed' ? setCreating({ offerId: o.id }) : issue.mutate(o.id))}
>
<Icon name="send" />
</button>
@ -248,7 +250,7 @@ export default function Offers() {
title="Offers"
sub="Track offer letters and acceptance"
actions={
<button className="btn btn-primary" onClick={() => setCreating(true)}>
<button className="btn btn-primary" onClick={() => setCreating({})}>
<Icon name="plus" /> Create Offer
</button>
}
@ -305,23 +307,23 @@ export default function Offers() {
busy={busy}
onClose={() => setViewing(null)}
onIssue={() => issue.mutate(viewing.id)}
onRetry={() => { setViewing(null); setCreating({ offerId: viewing.id }) }}
onStatus={(next) => { setStatus.mutate({ offerId: viewing.id, next }); setViewing(null) }}
/>
)}
{creating && (
<CreateOffer
applications={(appsQuery.data ?? []).filter((a) => OFFER_READY_STAGES.includes(a.stage))}
loading={appsQuery.isPending}
busy={create.isPending}
onClose={() => setCreating(false)}
onSubmit={(body) => create.mutate(body)}
offerId={creating.offerId || null}
busy={send.isPending}
onClose={() => setCreating(null)}
onSubmit={(body) => send.mutate(body)}
/>
)}
</div>
)
}
function OfferDetail({ offer: o, busy, onClose, onIssue, onStatus }) {
function OfferDetail({ offer: o, busy, onClose, onIssue, onRetry, onStatus }) {
/* Est. total cash = base + the bonus percentage applied to it. Signing bonus
is a one-off and is shown separately rather than folded in, because adding
it would overstate year two. */
@ -344,6 +346,11 @@ function OfferDetail({ offer: o, busy, onClose, onIssue, onStatus }) {
Mark {OFFER_STATUS_LABEL[s]}
</button>
))}
{o.status === 'failed' ? (
<button className="btn btn-primary" disabled={busy} onClick={onRetry}>
<Icon name="send" /> Edit and resend
</button>
) : (
<button
className="btn btn-primary"
disabled={busy || ['accepted', 'declined'].includes(o.status)}
@ -351,6 +358,7 @@ function OfferDetail({ offer: o, busy, onClose, onIssue, onStatus }) {
>
<Icon name="send" /> {o.status === 'draft' ? 'Send Offer' : 'Resend Offer'}
</button>
)}
</>
}
>
@ -410,9 +418,40 @@ function OfferDetail({ offer: o, busy, onClose, onIssue, onStatus }) {
)
}
function CreateOffer({ applications, loading, busy, onClose, onSubmit }) {
const [form, setForm] = useState({
inboxId: '',
const SOURCE_LABEL = { inbox: 'Inbox', manual: 'Upload', form: 'Form' }
function candidateKey(c) {
if (c?.inbox_id != null && c.inbox_id !== '') return `inbox:${c.inbox_id}`
if (c?.manual_upload_candidate_id) return `manual:${c.manual_upload_candidate_id}`
if (c?.form_data_id) return `form:${c.form_data_id}`
return ''
}
function applicationIdentity(c) {
if (!c) return {}
if (c.inbox_id != null && c.inbox_id !== '') return { inbox_id: Number(c.inbox_id) }
if (c.manual_upload_candidate_id) return { manual_upload_candidate_id: c.manual_upload_candidate_id }
if (c.form_data_id) return { form_data_id: c.form_data_id }
return {}
}
function localDateIso(date) {
if (!date) return null
const d = new Date(`${date}T00:00`)
return Number.isNaN(d.getTime()) ? null : d.toISOString()
}
function candidateOptionLabel(c) {
const name = c.name || c.email || 'Candidate'
const job = c.job_title ? `${c.job_title}` : ''
const stage = formatRole(c.stage || c.application_status || '')
const source = SOURCE_LABEL[c.source] || c.source || ''
return [name + job, stage, source].filter(Boolean).join(' · ')
}
function blankOfferForm() {
return {
candidateKey: '',
base: '',
currency: 'USD',
salaryPeriod: 'year',
@ -422,21 +461,188 @@ function CreateOffer({ applications, loading, busy, onClose, onSubmit }) {
equityInstrument: 'RSU',
startDate: '',
expiryDate: '',
})
}
}
function formFromOffer(row) {
return {
candidateKey: candidateKey({
inbox_id: row.inbox_id,
manual_upload_candidate_id: row.manual_upload_candidate_id,
form_data_id: row.form_data_id,
}),
base: row.base_salary != null ? String(row.base_salary) : '',
currency: row.currency || 'USD',
salaryPeriod: row.salary_period || 'year',
bonusPct: row.annual_bonus_pct != null ? String(row.annual_bonus_pct) : '',
signingBonus: row.signing_bonus != null ? String(row.signing_bonus) : '',
equityUnits: row.equity_units != null ? String(row.equity_units) : '',
equityInstrument: row.equity_instrument || 'RSU',
startDate: toDateInput(row.start_date),
expiryDate: toDateInput(row.expiry_date),
}
}
function CandidatePicker({
selected,
options,
loading,
disabled,
error,
onPick,
onSearch,
}) {
const [q, setQ] = useState('')
const [open, setOpen] = useState(false)
const root = useRef(null)
useEffect(() => {
function onDoc(e) {
if (root.current && !root.current.contains(e.target)) setOpen(false)
}
document.addEventListener('mousedown', onDoc)
return () => document.removeEventListener('mousedown', onDoc)
}, [])
useEffect(() => {
if (!open || disabled) return
onSearch?.(q)
}, [q, open, disabled, onSearch])
const display = open ? q : (selected ? candidateOptionLabel(selected) : '')
const emptyCopy = q.trim()
? 'No matches'
: 'No candidates at interview stage or later'
return (
<div className={`dropdown${open ? ' open' : ''}`} ref={root} style={{ width: '100%' }}>
<input
className={error ? 'err' : ''}
value={display}
disabled={disabled}
placeholder={loading && !open ? 'Loading candidates…' : 'Search candidate by name or email…'}
autoComplete="off"
onFocus={() => {
if (disabled) return
setOpen(true)
setQ('')
}}
onChange={(e) => {
setQ(e.target.value)
setOpen(true)
}}
/>
{open && !disabled && (
<div
className="dropdown-menu"
style={{ left: 0, right: 'auto', width: '100%', minWidth: 0, maxHeight: 240, overflowY: 'auto' }}
>
{loading && (
<div className="dropdown-link" style={{ color: 'var(--text-3)' }}>Loading</div>
)}
{!loading && options.length === 0 && (
<div className="dropdown-link" style={{ color: 'var(--text-3)' }}>{emptyCopy}</div>
)}
{!loading && options.map((a) => (
<button
type="button"
key={candidateKey(a)}
className="dropdown-link"
onClick={() => {
onPick(a)
setQ('')
setOpen(false)
}}
>
<span>
{a.name || a.email || 'Candidate'}
{a.job_title ? `${a.job_title}` : ''}
<span className="cell-sub" style={{ display: 'block' }}>
{[formatRole(a.stage || a.application_status || ''), SOURCE_LABEL[a.source] || a.source]
.filter(Boolean)
.join(' · ')}
</span>
</span>
</button>
))}
</div>
)}
</div>
)
}
function CreateOffer({ offerId, busy, onClose, onSubmit }) {
const [search, setSearch] = useState('')
const [debounced, setDebounced] = useState('')
const [picked, setPicked] = useState(null)
const [form, setForm] = useState(blankOfferForm)
const [errors, setErrors] = useState({})
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }))
const inboxId = form.inboxId || (applications[0] ? String(applications[0].inboxId) : '')
const selected = applications.find((a) => String(a.inboxId) === String(inboxId)) ?? null
useEffect(() => {
const t = setTimeout(() => setDebounced(search.trim()), 250)
return () => clearTimeout(t)
}, [search])
const candidatesQuery = useQuery({
queryKey: qk.offers.candidates({ search: debounced, top: 200 }),
queryFn: async () => {
const res = await offersApi.listCandidates({ search: debounced || undefined, top: 200 })
return Array.isArray(res?.data) ? res.data : []
},
placeholderData: keepPreviousData,
enabled: !offerId,
})
const offerQuery = useQuery({
queryKey: qk.offers.detail(offerId),
queryFn: async () => {
const res = await offersApi.get(offerId)
return res?.data ?? null
},
enabled: Boolean(offerId),
})
useEffect(() => {
if (!offerQuery.data) return
setForm(formFromOffer(offerQuery.data))
setPicked({
inbox_id: offerQuery.data.inbox_id,
manual_upload_candidate_id: offerQuery.data.manual_upload_candidate_id,
form_data_id: offerQuery.data.form_data_id,
user_id: offerQuery.data.candidate_user_id,
job_post_id: offerQuery.data.job_post_id,
name: offerQuery.data.candidate_name,
job_title: null,
source: offerQuery.data.inbox_id != null ? 'inbox'
: offerQuery.data.manual_upload_candidate_id ? 'manual'
: 'form',
stage: offerQuery.data.status,
})
}, [offerQuery.data])
const applications = candidatesQuery.data ?? []
const selected = picked
|| applications.find((a) => candidateKey(a) === form.candidateKey)
|| null
const loading = offerId ? offerQuery.isPending : candidatesQuery.isPending
function pickCandidate(a) {
setPicked(a)
set('candidateKey', candidateKey(a))
setErrors((e) => ({ ...e, candidate: undefined }))
}
function submit() {
if (busy) return
const next = {}
if (!selected) next.inboxId = 'Pick a candidate'
/* All three links are required server-side (422 otherwise). A pipeline row
always carries them, so a miss here means the picker is stale. */
if (selected && (!selected.userId || !selected.jobPostId)) {
next.inboxId = 'This application has no candidate account or assigned role'
if (!selected) next.candidate = 'Pick a candidate'
if (selected && !selected.job_post_id) {
next.candidate = 'This application has no assigned role'
}
const identity = applicationIdentity(selected)
if (selected && !Object.keys(identity).length) {
next.candidate = 'This application has no inbox, upload, or form id'
}
const base = form.base === '' ? null : Number(form.base)
if (base == null || !Number.isFinite(base) || base <= 0) next.base = 'Enter a base salary'
@ -448,11 +654,10 @@ function CreateOffer({ applications, loading, busy, onClose, onSubmit }) {
if (Object.keys(next).length) return
const signing = form.signingBonus === '' ? null : Number(form.signingBonus)
onSubmit({
inbox_id: Number(selected.inboxId),
job_post_id: selected.jobPostId,
candidate_user_id: selected.userId,
status: 'draft',
const body = {
...identity,
job_post_id: selected.job_post_id,
candidate_user_id: selected.user_id || undefined,
base_salary: base,
currency: form.currency,
salary_period: form.salaryPeriod,
@ -460,21 +665,25 @@ function CreateOffer({ applications, loading, busy, onClose, onSubmit }) {
signing_bonus: Number.isFinite(signing) ? signing : null,
equity_units: units,
equity_instrument: units != null ? form.equityInstrument : null,
start_date: form.startDate ? new Date(form.startDate).toISOString() : null,
expiry_date: form.expiryDate ? new Date(form.expiryDate).toISOString() : null,
})
start_date: localDateIso(form.startDate),
expiry_date: localDateIso(form.expiryDate),
}
if (offerId) body.offer_id = offerId
onSubmit(body)
}
const sendDisabled = busy || loading || !selected
return (
<Modal
title="Create Offer"
subtitle="Saved as a draft — nothing is sent until you issue it"
title={offerId ? 'Resend Offer' : 'Create Offer'}
subtitle={offerId ? 'Edit compensation and send again' : 'Emails the candidate and moves them to Offer'}
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose} disabled={busy}>Cancel</button>
<button className="btn btn-primary" onClick={submit} disabled={busy || !applications.length}>
<Icon name="check" /> {busy ? 'Saving…' : 'Save Draft'}
<button className="btn btn-primary" onClick={submit} disabled={sendDisabled}>
<Icon name="send" /> {busy ? 'Sending…' : 'Send Offer'}
</button>
</>
}
@ -483,23 +692,21 @@ function CreateOffer({ applications, loading, busy, onClose, onSubmit }) {
<div className="form-grid">
<div className="form-field col-span-2">
<label>Candidate <span className="req">*</span></label>
<select
value={inboxId}
className={errors.inboxId ? 'err' : ''}
onChange={(e) => set('inboxId', e.target.value)}
disabled={loading || !applications.length}
>
{loading && <option value="">Loading applications</option>}
{!loading && !applications.length && (
<option value="">No candidates at interview stage or later</option>
<CandidatePicker
selected={selected}
options={applications}
loading={loading}
disabled={Boolean(offerId)}
error={Boolean(errors.candidate)}
onPick={pickCandidate}
onSearch={setSearch}
/>
{offerQuery.isError && (
<p className="text-muted" style={{ marginTop: 8 }}>
{friendlyAuthError(offerQuery.error, 'Could not load this offer.')}
</p>
)}
{applications.map((a) => (
<option key={a.inboxId} value={a.inboxId}>
{a.name}{a.jobTitle ? `${a.jobTitle}` : ''} · {a.stage}
</option>
))}
</select>
<FieldError>{errors.inboxId}</FieldError>
<FieldError>{errors.candidate}</FieldError>
</div>
<div className="form-field">
@ -574,7 +781,7 @@ function CreateOffer({ applications, loading, busy, onClose, onSubmit }) {
<p className="text-muted" style={{ marginTop: 16, fontSize: 13 }}>
Equity is stored as a unit count plus an instrument, so 20k RSU is entered as 20000 and RSU.
Issuing the offer is a separate, permissioned step (<code>offers.approve</code>).
Sending emails the candidate and moves the pipeline to Offer only after the mail succeeds.
</p>
</form>
</Modal>