offerws page
parent
3969d62a51
commit
0e4902d486
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue