Merge pull request 'SQS_BROKER' (#86) from SQS_BROKER into main
Deploy to S3 / deploy (push) Successful in 36s
Details
Deploy to S3 / deploy (push) Successful in 36s
Details
Reviewed-on: #86pull/87/head^2
commit
52624d99d8
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -20,3 +20,4 @@ class HistoryEvent(str, Enum):
|
|||
ATS_SCORED = "ats.scored"
|
||||
FORM_CREATED = "form.created"
|
||||
FORM_UPDATED = "form.updated"
|
||||
OFFER_SENT = "offer.sent"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue