offerws page

pull/86/head
ahmed.mujtaba 2026-09-09 19:19:05 +05:00
parent 3969d62a51
commit 0e4902d486
17 changed files with 1172 additions and 126 deletions

View File

@ -456,6 +456,65 @@ class FormData(SQLModel, table=True):
}) })
return rows 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 @classmethod
async def job_post_ids_by_emails(cls, session: AsyncSession, emails): async def job_post_ids_by_emails(cls, session: AsyncSession, emails):
"""(email, job_post_id) pairs from assigned or job_post_id. Unlinked skipped.""" """(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.candidate_phone_number.label("phone"),
Inbox_Messages.assigned_job_post_id, Inbox_Messages.assigned_job_post_id,
Inbox_Messages.application_status, Inbox_Messages.application_status,
Inbox_Messages.is_duplicate,
Inbox_Messages.current_employment, Inbox_Messages.current_employment,
Inbox_Messages.current_title, Inbox_Messages.current_title,
Inbox_Messages.experience, Inbox_Messages.experience,
@ -147,6 +148,7 @@ class Inbox(SQLModel, table=True):
"email":row["email"], "email":row["email"],
"linkedin_url":row["linkedin_url"] or None, "linkedin_url":row["linkedin_url"] or None,
"application_status":status.value if status else None, "application_status":status.value if status else None,
"is_duplicate": bool(row["is_duplicate"]),
"phone":row["phone"], "phone":row["phone"],
"assigned_job_post_id":str(row["assigned_job_post_id"]) if row["assigned_job_post_id"] else None, "assigned_job_post_id":str(row["assigned_job_post_id"]) if row["assigned_job_post_id"] else None,
"title":row["title"] or None, "title":row["title"] or None,

View File

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

View File

@ -20,3 +20,4 @@ class HistoryEvent(str, Enum):
ATS_SCORED = "ats.scored" ATS_SCORED = "ats.scored"
FORM_CREATED = "form.created" FORM_CREATED = "form.created"
FORM_UPDATED = "form.updated" 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", "ats.scored": "assessment",
"form.created": "approval", "form.created": "approval",
"form.updated": "approval", "form.updated": "approval",
"offer.sent": "application",
"note.created": "message", "note.created": "message",
"note.updated": "message", "note.updated": "message",
"feedback.created": "message", "feedback.created": "message",
@ -66,6 +67,7 @@ _HISTORY_TITLE = {
"ats.scored": "ATS score ready", "ats.scored": "ATS score ready",
"form.created": "Form submitted", "form.created": "Form submitted",
"form.updated": "Form updated", "form.updated": "Form updated",
"offer.sent": "Offer sent",
} }
_HISTORY_TAB = { _HISTORY_TAB = {
"stage.changed": "History", "stage.changed": "History",
@ -80,6 +82,7 @@ _HISTORY_TAB = {
"feedback.updated": "Activity", "feedback.updated": "Activity",
"form.created": "Forms", "form.created": "Forms",
"form.updated": "Forms", "form.updated": "Forms",
"offer.sent": "History",
"ats.scored": "Resume", "ats.scored": "Resume",
"document.uploaded": "History", "document.uploaded": "History",
"candidate.created": "History", "candidate.created": "History",

View File

@ -69,19 +69,39 @@ class OfferIssue(BaseModel):
change_reason: str | None = None 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") @router.get("/offers/fetch")
async def fetch_offers( async def fetch_offers(
current_user: dict = Depends(require_permission(PermissionTag.OFFERS_VIEW)), current_user: dict = Depends(require_permission(PermissionTag.OFFERS_VIEW)),
offer_id: str | None = Query(None), offer_id: str | None = Query(None),
status: str | None = Query(None), status: str | None = Query(None),
inbox_id: int | None = Query(None), inbox_id: int | None = Query(None),
job_post_id: str | None = Query(None),
top: int | None = Query(None), top: int | None = Query(None),
skip: int = Query(0,ge=0), skip: int = Query(0,ge=0),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
try: try:
service=Offer(session=session) 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}) return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException: except HTTPException:
raise raise
@ -137,3 +157,37 @@ async def issue_offer(
raise raise
except Exception as e: except Exception as e:
raise HTTPException(status_code=500,detail=str(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" __tablename__ = "offers"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) 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") job_post_id: uuid.UUID = Field(foreign_key="job_posts.id")
candidate_user_id: uuid.UUID = Field(foreign_key="users.id") candidate_user_id: uuid.UUID = Field(foreign_key="users.id")
status: str = Field(default="draft") status: str = Field(default="draft")
@ -67,6 +71,7 @@ class Offers(SQLModel, table=True):
*, *,
status: str | None = None, status: str | None = None,
inbox_id: int | None = None, inbox_id: int | None = None,
job_post_id=None,
top: int | None = None, top: int | None = None,
skip: int = 0, skip: int = 0,
): ):
@ -75,6 +80,9 @@ class Offers(SQLModel, table=True):
statement = statement.where(cls.status == status) statement = statement.where(cls.status == status)
if inbox_id is not None: if inbox_id is not None:
statement = statement.where(cls.inbox_id == int(inbox_id)) 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()) count_statement = select(func.count()).select_from(statement.subquery())
total = (await session.execute(count_statement)).scalar_one() total = (await session.execute(count_statement)).scalar_one()
statement = statement.order_by(cls.created_at.desc()) statement = statement.order_by(cls.created_at.desc())
@ -147,6 +155,34 @@ class Offers(SQLModel, table=True):
result = await session.execute(statement) result = await session.execute(statement)
return int(result.scalar_one() or 0) 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): class OfferStatusHistory(SQLModel, table=True):
__tablename__ = "offer_status_history" __tablename__ = "offer_status_history"
@ -190,3 +226,5 @@ class OfferStatusHistory(SQLModel, table=True):
return row return row
import users.models as _users_models # noqa: E402, F401 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(): def non_validation_values():
fields=("base_salary","currency","salary_period","signing_bonus","annual_bonus_pct", fields=("base_salary","currency","salary_period","signing_bonus","annual_bonus_pct",
"equity_units","equity_instrument","start_date","expiry_date", "equity_units","equity_instrument","start_date","expiry_date",
"cadre","gross_salary_in_words","subsidized_services","probation_period", "cadre","gross_salary_in_words","subsidized_services","probation_period",
"notice_period","work_location","work_timings") "notice_period","work_location","work_timings")
return fields 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 { return {
"id": str(row.id) if row.id else None, "id": str(row.id) if row.id else None,
"inbox_id": row.inbox_id, "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, "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_user_id": str(row.candidate_user_id) if row.candidate_user_id else None,
"candidate_name": candidate_name,
"status": row.status, "status": row.status,
"base_salary": row.base_salary, "base_salary": row.base_salary,
"currency": row.currency, "currency": row.currency,
@ -26,6 +31,7 @@ def serialize_offer(row) -> dict:
"closed_at": row.closed_at.isoformat() if row.closed_at else None, "closed_at": row.closed_at.isoformat() if row.closed_at else None,
"issued_by": str(row.issued_by) if row.issued_by 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": 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, "created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_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, "actor_kind": row.actor_kind,
"change_reason": row.change_reason, "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 import uuid
from datetime import datetime,timezone from datetime import datetime,timezone
import httpx
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession 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.models import Offers,OfferStatusHistory
from offer.serializers import serialize_offer from offer.plugins import (
from offer.plugins import non_validation_values 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): def _as_uuid(value):
if value in (None,""): if value in (None,""):
@ -26,24 +51,172 @@ def _user_id(current_user):
return uid 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: class Offer:
def __init__(self,session:AsyncSession): def __init__(self,session:AsyncSession):
self.session=session 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: if offer_id is not None:
row=await Offers.get_offer_by_id(self.session,offer_id) row=await Offers.get_offer_by_id(self.session,offer_id)
if not row: if not row:
raise HTTPException(status_code=404,detail="Offer not found") 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( rows,total=await Offers.fetch_offers(
self.session, self.session,
status=status, status=status,
inbox_id=inbox_id, inbox_id=inbox_id,
job_post_id=job_post_id,
top=top, top=top,
skip=skip or 0, 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): async def create_offer(self,payload,current_user):
if not payload.get("inbox_id"): if not payload.get("inbox_id"):
@ -146,3 +319,241 @@ class Offer:
} }
await OfferStatusHistory.insert_history(self.session,history_data) await OfferStatusHistory.insert_history(self.session,history_data)
return serialize_offer(updated) 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 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: def scopes_to_own_requisitions(current_user: dict | None) -> bool:
"""Jobs and candidates limited to requisitions this user created (or is assigned). """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 Permissioned OFFERS_VIEW / OFFERS_CREATE / OFFERS_EDIT / OFFERS_APPROVE, so a
recruiter who can read offers still cannot issue one. recruiter who can read offers still cannot issue one.
serialize_offer returns FOREIGN KEYS ONLY inbox_id, job_post_id, List rows include candidate_name / created_by_name. Job title is still
candidate_user_id. There is no candidate name, job title, department or hydrated from /job/fetch?ids=. Create Offer uses POST /offers/jobs/sent.
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.
============================================================ */ ============================================================ */
/** `status` is a free-text column defaulting to "draft"; the vocabulary is decided here. */ /** `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 = { export const OFFER_STATUS_LABEL = {
draft: 'Draft', draft: 'Draft',
sent: 'Sent', sent: 'Sent',
failed: 'Failed',
negotiating: 'Negotiating', negotiating: 'Negotiating',
accepted: 'Accepted', accepted: 'Accepted',
declined: 'Declined', declined: 'Declined',
@ -38,20 +36,37 @@ const STATUS_CLASS = {
declined: 'b-red', declined: 'b-red',
expired: 'b-gray', expired: 'b-gray',
draft: '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', { return request('/offers/fetch', {
params: { params: {
offer_id: offerId, offer_id: offerId,
status, status,
inbox_id: inboxId, inbox_id: inboxId,
job_post_id: jobPostId,
top, top,
skip, 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 * 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) * 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 { return {
id: row.id, id: row.id,
inboxId: row.inbox_id, inboxId: row.inbox_id,
manualUploadId: row.manual_upload_candidate_id ?? null,
formDataId: row.form_data_id ?? null,
jobPostId: row.job_post_id, jobPostId: row.job_post_id,
candidateUserId: row.candidate_user_id, candidateUserId: row.candidate_user_id,
candidate: person?.name || 'Unknown candidate', candidate: person?.name || row.candidate_name || 'Unknown candidate',
email: person?.email ?? null, email: person?.email ?? null,
createdByName: row.created_by_name || null,
jobTitle: jobTitle || '—', jobTitle: jobTitle || '—',
status, status,
statusLabel: OFFER_STATUS_LABEL[status] ?? status, statusLabel: OFFER_STATUS_LABEL[status] ?? status,

View File

@ -128,7 +128,12 @@ export const qk = {
recruiters: (p = {}) => ['analytics', 'recruiters', p], recruiters: (p = {}) => ['analytics', 'recruiters', p],
jobApps: (p = {}) => ['analytics', 'job-apps', 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: { forms: {
all: () => ['forms'], all: () => ['forms'],
list: (p = {}) => ['forms', 'list', p], list: (p = {}) => ['forms', 'list', p],

View File

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

View File

@ -29,7 +29,8 @@ import * as assignmentsApi from '../api/assignments'
import * as tasksApi from '../api/tasks' import * as tasksApi from '../api/tasks'
import * as usersApi from '../api/users' import * as usersApi from '../api/users'
import * as requisitionsApi from '../api/requisitions' 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' import { empTypes } from '../data/seed'
// Backend allows up to 500; stay at 100 so we match Managers.jsx (shared qk.jobs.list). // 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 assignments = historyQuery.data ?? []
const statusRows = statusQuery.data ?? [] const statusRows = statusQuery.data ?? []
const offerRows = offersQuery?.data ?? []
if (historyQuery.isError && statusQuery.isError) { if (historyQuery.isError && statusQuery.isError && offersQuery?.isError) {
return ( return (
<p className="text-muted text-sm"> <p className="text-muted text-sm">
{friendlyAuthError(historyQuery.error, 'History did not load.')} {friendlyAuthError(historyQuery.error, 'History did not load.')}
</p> </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> return <p className="text-muted text-sm">Loading</p>
} }
@ -1258,6 +1264,14 @@ function JobHistory({ historyQuery, statusQuery }) {
at: toDate(row.created_at), at: toDate(row.created_at),
row, 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)) ].sort((a, b) => (b.at?.getTime() || 0) - (a.at?.getTime() || 0))
if (events.length === 0) { if (events.length === 0) {
@ -1266,11 +1280,11 @@ function JobHistory({ historyQuery, statusQuery }) {
return ( return (
<div className="list-tight"> <div className="list-tight">
{events.map((ev) => ( {events.map((ev) => {
ev.kind === 'status' if (ev.kind === 'status') return <StatusHistoryRow key={ev.id} row={ev.row} at={ev.at} />
? <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} />
: <AssignmentHistoryRow key={ev.id} row={ev.row} /> return <AssignmentHistoryRow key={ev.id} row={ev.row} />
))} })}
</div> </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 }) { function AssignmentHistoryRow({ row }) {
return ( return (
<div className="list-row"> <div className="list-row">
@ -1365,7 +1399,16 @@ function JobDetail({
enabled: Boolean(j.id), enabled: Boolean(j.id),
retry: false, 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 ( return (
<Modal <Modal
@ -1460,7 +1503,7 @@ function JobDetail({
</> </>
)} )}
{tab === 'history' && <JobHistory historyQuery={historyQuery} statusQuery={statusQuery} />} {tab === 'history' && <JobHistory historyQuery={historyQuery} statusQuery={statusQuery} offersQuery={offersQuery} />}
</Modal> </Modal>
) )
} }

View File

@ -1,26 +1,19 @@
/* ============================================================ /* ============================================================
Offers live on backend/offer/app.py. Offers live on backend/offer/app.py.
Read is GET /offers/fetch; Create Offer writes POST /offers/create; Send and Read is GET /offers/fetch. Create Offer writes POST /offers/jobs/sent
Resend write POST /offers/issue (which stamps issued_by + sent_at and moves (persist, email via Teams, then pipeline OFFER). Failed sends stay listed;
draft -> sent, logging the change to offer_status_history); the response GET /offers/fetch?offer_id= prefills the form for retry. Drafts still use
actions write PATCH /offers/update. POST /offers/create (Candidate Forms) and POST /offers/issue.
HYDRATION, NOT N+1. serialize_offer returns foreign keys only no candidate HYDRATION, NOT N+1. serialize_offer now includes candidate_name when listing;
name, job title, department or recruiter. Two reads the screen needs anyway job titles still come from /job/fetch?ids=. Equity is `equity_units` +
fill those in: the pipeline board (one row per application, carrying the `equity_instrument` server-side.
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.
============================================================ */ ============================================================ */
import { useMemo, useState } from 'react' import { useMemo, useState, useEffect, useRef } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useSearchParams } from 'react-router-dom'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import DataTable from '../ui/DataTable' import DataTable from '../ui/DataTable'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
@ -29,6 +22,7 @@ import { Avatar, Badge, EmptyState, FieldError, Icon, KpiCard, SkeletonRows } fr
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import { qk } from '../lib/queryKeys' import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors' import { friendlyAuthError } from '../lib/errors'
import { formatRole, toDateInput } from '../lib/format'
import * as offersApi from '../api/offers' import * as offersApi from '../api/offers'
import * as jobPostsApi from '../api/jobPosts' import * as jobPostsApi from '../api/jobPosts'
import { OFFER_STATUS_LABEL, OFFER_STATUS_VALUE } from '../api/offers' 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 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 = { const NEXT_STATUSES = {
sent: ['negotiating', 'accepted', 'declined'], sent: ['negotiating', 'accepted', 'declined'],
negotiating: ['accepted', 'declined'], negotiating: ['accepted', 'declined'],
draft: [], draft: [],
failed: [],
accepted: [], accepted: [],
declined: [], declined: [],
expired: [], expired: [],
@ -65,27 +54,22 @@ function useOffers(status) {
export default function Offers() { export default function Offers() {
const { toast } = useToast() const { toast } = useToast()
const qc = useQueryClient() const qc = useQueryClient()
const [searchParams, setSearchParams] = useSearchParams()
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [statusLabel, setStatusLabel] = useState('') const [statusLabel, setStatusLabel] = useState('')
const [viewing, setViewing] = useState(null) const [viewing, setViewing] = useState(null)
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(null)
const status = statusLabel ? OFFER_STATUS_VALUE[statusLabel] : '' const status = statusLabel ? OFFER_STATUS_VALUE[statusLabel] : ''
const offersQuery = useOffers(status) 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 allQuery = useOffers('')
const appsQuery = useApplications() 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]) 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 jobIds = useMemo(() => {
const ids = new Set() const ids = new Set()
for (const row of allQuery.data ?? []) { 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 decided = all.filter((o) => ['accepted', 'declined'].includes(o.status)).length
const accepted = all.filter((o) => o.status === 'accepted').length const accepted = all.filter((o) => o.status === 'accepted').length
return { return {
sent: all.filter((o) => o.status !== 'draft').length, sent: all.filter((o) => o.status !== 'draft' && o.status !== 'failed').length,
accepted, accepted,
pending: all.filter((o) => ['sent', 'negotiating'].includes(o.status)).length, pending: all.filter((o) => ['sent', 'negotiating'].includes(o.status)).length,
rate: Math.round((accepted / (decided || 1)) * 100), rate: Math.round((accepted / (decided || 1)) * 100),
@ -145,7 +129,20 @@ export default function Offers() {
[offers, q], [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({ const issue = useMutation({
mutationFn: (offerId) => offersApi.issue(offerId), mutationFn: (offerId) => offersApi.issue(offerId),
@ -174,14 +171,17 @@ export default function Offers() {
onError: (err) => toast(friendlyAuthError(err, 'Could not update the offer.'), 'error'), onError: (err) => toast(friendlyAuthError(err, 'Could not update the offer.'), 'error'),
}) })
const create = useMutation({ const send = useMutation({
mutationFn: (body) => offersApi.create(body), mutationFn: (body) => offersApi.send(body),
onSuccess: () => { onSuccess: () => {
invalidate() invalidate()
setCreating(false) setCreating(null)
toast('Offer created as a draft — issue it when ready', 'success') 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 const busy = issue.isPending || setStatus.isPending
@ -225,15 +225,17 @@ export default function Offers() {
key: '_a', label: 'Actions', align: 'right', key: '_a', label: 'Actions', align: 'right',
render: (o) => ( render: (o) => (
<div className="row-actions"> <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" /> <Icon name="eye" />
</button> </button>
<button <button
className="act-btn" className="act-btn"
data-tip={o.status === 'draft' ? 'Send offer' : 'Resend'} data-tip={o.status === 'failed' ? 'Edit and resend' : o.status === 'draft' ? 'Send offer' : 'Resend'}
aria-label={o.status === 'draft' ? 'Send offer' : 'Resend offer'} aria-label={o.status === 'failed' ? 'Edit and resend offer' : o.status === 'draft' ? 'Send offer' : 'Resend offer'}
disabled={busy || ['accepted', 'declined'].includes(o.status)} 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" /> <Icon name="send" />
</button> </button>
@ -248,7 +250,7 @@ export default function Offers() {
title="Offers" title="Offers"
sub="Track offer letters and acceptance" sub="Track offer letters and acceptance"
actions={ actions={
<button className="btn btn-primary" onClick={() => setCreating(true)}> <button className="btn btn-primary" onClick={() => setCreating({})}>
<Icon name="plus" /> Create Offer <Icon name="plus" /> Create Offer
</button> </button>
} }
@ -305,23 +307,23 @@ export default function Offers() {
busy={busy} busy={busy}
onClose={() => setViewing(null)} onClose={() => setViewing(null)}
onIssue={() => issue.mutate(viewing.id)} onIssue={() => issue.mutate(viewing.id)}
onRetry={() => { setViewing(null); setCreating({ offerId: viewing.id }) }}
onStatus={(next) => { setStatus.mutate({ offerId: viewing.id, next }); setViewing(null) }} onStatus={(next) => { setStatus.mutate({ offerId: viewing.id, next }); setViewing(null) }}
/> />
)} )}
{creating && ( {creating && (
<CreateOffer <CreateOffer
applications={(appsQuery.data ?? []).filter((a) => OFFER_READY_STAGES.includes(a.stage))} offerId={creating.offerId || null}
loading={appsQuery.isPending} busy={send.isPending}
busy={create.isPending} onClose={() => setCreating(null)}
onClose={() => setCreating(false)} onSubmit={(body) => send.mutate(body)}
onSubmit={(body) => create.mutate(body)}
/> />
)} )}
</div> </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 /* 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 is a one-off and is shown separately rather than folded in, because adding
it would overstate year two. */ it would overstate year two. */
@ -344,13 +346,19 @@ function OfferDetail({ offer: o, busy, onClose, onIssue, onStatus }) {
Mark {OFFER_STATUS_LABEL[s]} Mark {OFFER_STATUS_LABEL[s]}
</button> </button>
))} ))}
<button {o.status === 'failed' ? (
className="btn btn-primary" <button className="btn btn-primary" disabled={busy} onClick={onRetry}>
disabled={busy || ['accepted', 'declined'].includes(o.status)} <Icon name="send" /> Edit and resend
onClick={() => { onIssue(); onClose() }} </button>
> ) : (
<Icon name="send" /> {o.status === 'draft' ? 'Send Offer' : 'Resend Offer'} <button
</button> className="btn btn-primary"
disabled={busy || ['accepted', 'declined'].includes(o.status)}
onClick={() => { onIssue(); onClose() }}
>
<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 SOURCE_LABEL = { inbox: 'Inbox', manual: 'Upload', form: 'Form' }
const [form, setForm] = useState({
inboxId: '', 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: '', base: '',
currency: 'USD', currency: 'USD',
salaryPeriod: 'year', salaryPeriod: 'year',
@ -422,21 +461,188 @@ function CreateOffer({ applications, loading, busy, onClose, onSubmit }) {
equityInstrument: 'RSU', equityInstrument: 'RSU',
startDate: '', startDate: '',
expiryDate: '', 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 [errors, setErrors] = useState({})
const set = (k, v) => setForm((f) => ({ ...f, [k]: v })) const set = (k, v) => setForm((f) => ({ ...f, [k]: v }))
const inboxId = form.inboxId || (applications[0] ? String(applications[0].inboxId) : '') useEffect(() => {
const selected = applications.find((a) => String(a.inboxId) === String(inboxId)) ?? null 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() { function submit() {
if (busy) return if (busy) return
const next = {} const next = {}
if (!selected) next.inboxId = 'Pick a candidate' if (!selected) next.candidate = 'Pick a candidate'
/* All three links are required server-side (422 otherwise). A pipeline row if (selected && !selected.job_post_id) {
always carries them, so a miss here means the picker is stale. */ next.candidate = 'This application has no assigned role'
if (selected && (!selected.userId || !selected.jobPostId)) { }
next.inboxId = 'This application has no candidate account or 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) const base = form.base === '' ? null : Number(form.base)
if (base == null || !Number.isFinite(base) || base <= 0) next.base = 'Enter a base salary' 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 if (Object.keys(next).length) return
const signing = form.signingBonus === '' ? null : Number(form.signingBonus) const signing = form.signingBonus === '' ? null : Number(form.signingBonus)
onSubmit({ const body = {
inbox_id: Number(selected.inboxId), ...identity,
job_post_id: selected.jobPostId, job_post_id: selected.job_post_id,
candidate_user_id: selected.userId, candidate_user_id: selected.user_id || undefined,
status: 'draft',
base_salary: base, base_salary: base,
currency: form.currency, currency: form.currency,
salary_period: form.salaryPeriod, salary_period: form.salaryPeriod,
@ -460,21 +665,25 @@ function CreateOffer({ applications, loading, busy, onClose, onSubmit }) {
signing_bonus: Number.isFinite(signing) ? signing : null, signing_bonus: Number.isFinite(signing) ? signing : null,
equity_units: units, equity_units: units,
equity_instrument: units != null ? form.equityInstrument : null, equity_instrument: units != null ? form.equityInstrument : null,
start_date: form.startDate ? new Date(form.startDate).toISOString() : null, start_date: localDateIso(form.startDate),
expiry_date: form.expiryDate ? new Date(form.expiryDate).toISOString() : null, expiry_date: localDateIso(form.expiryDate),
}) }
if (offerId) body.offer_id = offerId
onSubmit(body)
} }
const sendDisabled = busy || loading || !selected
return ( return (
<Modal <Modal
title="Create Offer" title={offerId ? 'Resend Offer' : 'Create Offer'}
subtitle="Saved as a draft — nothing is sent until you issue it" subtitle={offerId ? 'Edit compensation and send again' : 'Emails the candidate and moves them to Offer'}
onClose={onClose} onClose={onClose}
footer={ footer={
<> <>
<button className="btn btn-secondary" onClick={onClose} disabled={busy}>Cancel</button> <button className="btn btn-secondary" onClick={onClose} disabled={busy}>Cancel</button>
<button className="btn btn-primary" onClick={submit} disabled={busy || !applications.length}> <button className="btn btn-primary" onClick={submit} disabled={sendDisabled}>
<Icon name="check" /> {busy ? 'Saving…' : 'Save Draft'} <Icon name="send" /> {busy ? 'Sending…' : 'Send Offer'}
</button> </button>
</> </>
} }
@ -483,23 +692,21 @@ function CreateOffer({ applications, loading, busy, onClose, onSubmit }) {
<div className="form-grid"> <div className="form-grid">
<div className="form-field col-span-2"> <div className="form-field col-span-2">
<label>Candidate <span className="req">*</span></label> <label>Candidate <span className="req">*</span></label>
<select <CandidatePicker
value={inboxId} selected={selected}
className={errors.inboxId ? 'err' : ''} options={applications}
onChange={(e) => set('inboxId', e.target.value)} loading={loading}
disabled={loading || !applications.length} disabled={Boolean(offerId)}
> error={Boolean(errors.candidate)}
{loading && <option value="">Loading applications</option>} onPick={pickCandidate}
{!loading && !applications.length && ( onSearch={setSearch}
<option value="">No candidates at interview stage or later</option> />
)} {offerQuery.isError && (
{applications.map((a) => ( <p className="text-muted" style={{ marginTop: 8 }}>
<option key={a.inboxId} value={a.inboxId}> {friendlyAuthError(offerQuery.error, 'Could not load this offer.')}
{a.name}{a.jobTitle ? `${a.jobTitle}` : ''} · {a.stage} </p>
</option> )}
))} <FieldError>{errors.candidate}</FieldError>
</select>
<FieldError>{errors.inboxId}</FieldError>
</div> </div>
<div className="form-field"> <div className="form-field">
@ -574,7 +781,7 @@ function CreateOffer({ applications, loading, busy, onClose, onSubmit }) {
<p className="text-muted" style={{ marginTop: 16, fontSize: 13 }}> <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. 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> </p>
</form> </form>
</Modal> </Modal>