From 0e4902d486f900ddd0b7fbea34e97210f4714248 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Wed, 9 Sep 2026 19:19:05 +0500 Subject: [PATCH] offerws page --- backend/g_sheet/models.py | 59 +++ backend/inbox/models.py | 2 + backend/job/candidate/models.py | 3 +- backend/job/history/enums.py | 1 + .../manual/034_offers_sent_identity.sql | 25 ++ backend/notifications/views.py | 3 + backend/offer/app.py | 56 ++- backend/offer/models.py | 40 +- backend/offer/plugins.py | 141 +++++- backend/offer/serializers.py | 28 +- backend/offer/views.py | 421 +++++++++++++++++- backend/users/permissions.py | 12 + frontend/src/api/offers.js | 34 +- frontend/src/lib/queryKeys.js | 7 +- frontend/src/screens/CandidateProfile.jsx | 2 + frontend/src/screens/Jobs.jsx | 65 ++- frontend/src/screens/Offers.jsx | 399 +++++++++++++---- 17 files changed, 1172 insertions(+), 126 deletions(-) create mode 100644 backend/migrations/manual/034_offers_sent_identity.sql diff --git a/backend/g_sheet/models.py b/backend/g_sheet/models.py index 83dffa1..a16fd06 100644 --- a/backend/g_sheet/models.py +++ b/backend/g_sheet/models.py @@ -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.""" diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 91101f3..2c974a9 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -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, diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index 2c67ea6..05a8875 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -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, diff --git a/backend/job/history/enums.py b/backend/job/history/enums.py index 8d955d6..9a1a30d 100644 --- a/backend/job/history/enums.py +++ b/backend/job/history/enums.py @@ -20,3 +20,4 @@ class HistoryEvent(str, Enum): ATS_SCORED = "ats.scored" FORM_CREATED = "form.created" FORM_UPDATED = "form.updated" + OFFER_SENT = "offer.sent" diff --git a/backend/migrations/manual/034_offers_sent_identity.sql b/backend/migrations/manual/034_offers_sent_identity.sql new file mode 100644 index 0000000..5964bc1 --- /dev/null +++ b/backend/migrations/manual/034_offers_sent_identity.sql @@ -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); diff --git a/backend/notifications/views.py b/backend/notifications/views.py index ad0ee4a..58934b5 100644 --- a/backend/notifications/views.py +++ b/backend/notifications/views.py @@ -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", diff --git a/backend/offer/app.py b/backend/offer/app.py index d4132d9..7444b2b 100644 --- a/backend/offer/app.py +++ b/backend/offer/app.py @@ -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)) diff --git a/backend/offer/models.py b/backend/offer/models.py index f83a0bf..604dd60 100644 --- a/backend/offer/models.py +++ b/backend/offer/models.py @@ -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 diff --git a/backend/offer/plugins.py b/backend/offer/plugins.py index 006bdb0..b962c02 100644 --- a/backend/offer/plugins.py +++ b/backend/offer/plugins.py @@ -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 \ No newline at end of file + 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"

Dear {name},

" + "

We are pleased to extend an offer of employment from UtopiaBrands.

" + f"

Base salary: {salary} {period}.

" + ) + if annual_bonus_pct not in (None,""): + html+=f"

Annual bonus: {annual_bonus_pct}%.

" + if signing_bonus not in (None,""): + html+=f"

Signing bonus: {_money_line(signing_bonus,currency)}.

" + if equity_units not in (None,"",0): + instrument=(equity_instrument or "RSU").strip() or "RSU" + html+=f"

Equity: {equity_units} {instrument}.

" + html+="

Please reply to this email if you have questions about the offer.

" + 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, + ) diff --git a/backend/offer/serializers.py b/backend/offer/serializers.py index 165f0b4..a0733f8 100644 --- a/backend/offer/serializers.py +++ b/backend/offer/serializers.py @@ -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"), + } diff --git a/backend/offer/views.py b/backend/offer/views.py index 7c08a76..bfea53d 100644 --- a/backend/offer/views.py +++ b/backend/offer/views.py @@ -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) 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). diff --git a/frontend/src/api/offers.js b/frontend/src/api/offers.js index 7b7e8ae..5a8da5f 100644 --- a/frontend/src/api/offers.js +++ b/frontend/src/api/offers.js @@ -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, diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index ac2aab1..32463ba 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -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], diff --git a/frontend/src/screens/CandidateProfile.jsx b/frontend/src/screens/CandidateProfile.jsx index 477b10f..918db3f 100644 --- a/frontend/src/screens/CandidateProfile.jsx +++ b/frontend/src/screens/CandidateProfile.jsx @@ -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) { diff --git a/frontend/src/screens/Jobs.jsx b/frontend/src/screens/Jobs.jsx index fad60bb..8bc066e 100644 --- a/frontend/src/screens/Jobs.jsx +++ b/frontend/src/screens/Jobs.jsx @@ -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 (

{friendlyAuthError(historyQuery.error, 'History did not load.')}

) } - if ((historyQuery.isPending && !historyQuery.data) || (statusQuery.isPending && !statusQuery.data)) { + if ( + (historyQuery.isPending && !historyQuery.data) + || (statusQuery.isPending && !statusQuery.data) + || (offersQuery?.isPending && !offersQuery.data) + ) { return

Loading…

} @@ -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 (
- {events.map((ev) => ( - ev.kind === 'status' - ? - : - ))} + {events.map((ev) => { + if (ev.kind === 'status') return + if (ev.kind === 'offer') return + return + })}
) } @@ -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 ( +
+ + + +
+
{sent ? `Offer sent to ${candidate}` : `Offer for ${candidate}`}
+
+ {[actor, fmtDate(row.sent_at) || (at ? fmtDate(at) : null)].filter(Boolean).join(' · ')} +
+
+ {sent ? 'Offer' : (row.status || 'Offer')} +
+ ) +} + function AssignmentHistoryRow({ row }) { return (
@@ -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 ( )} - {tab === 'history' && } + {tab === 'history' && } ) } diff --git a/frontend/src/screens/Offers.jsx b/frontend/src/screens/Offers.jsx index a1de046..3ad180e 100644 --- a/frontend/src/screens/Offers.jsx +++ b/frontend/src/screens/Offers.jsx @@ -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) => (
- @@ -248,7 +250,7 @@ export default function Offers() { title="Offers" sub="Track offer letters and acceptance" actions={ - } @@ -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 && ( 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)} /> )}
) } -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,13 +346,19 @@ function OfferDetail({ offer: o, busy, onClose, onIssue, onStatus }) { Mark {OFFER_STATUS_LABEL[s]} ))} - + {o.status === 'failed' ? ( + + ) : ( + + )} } > @@ -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 ( +
+ { + if (disabled) return + setOpen(true) + setQ('') + }} + onChange={(e) => { + setQ(e.target.value) + setOpen(true) + }} + /> + {open && !disabled && ( +
+ {loading && ( +
Loading…
+ )} + {!loading && options.length === 0 && ( +
{emptyCopy}
+ )} + {!loading && options.map((a) => ( + + ))} +
+ )} +
+ ) +} + +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 ( - } @@ -483,23 +692,21 @@ function CreateOffer({ applications, loading, busy, onClose, onSubmit }) {
- - {errors.inboxId} + + {offerQuery.isError && ( +

+ {friendlyAuthError(offerQuery.error, 'Could not load this offer.')} +

+ )} + {errors.candidate}
@@ -574,7 +781,7 @@ function CreateOffer({ applications, loading, busy, onClose, onSubmit }) {

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 (offers.approve). + Sending emails the candidate and moves the pipeline to Offer only after the mail succeeds.