total applications removed

pull/87/head
ahmed.mujtaba 2026-09-09 19:42:01 +05:00
parent 25220d237b
commit c0e4a94d58
6 changed files with 198 additions and 91 deletions

View File

@ -14,9 +14,12 @@ router = APIRouter()
class OfferCreate(BaseModel): class OfferCreate(BaseModel):
inbox_id: int 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 job_post_id: str
candidate_user_id: str candidate_user_id: str | None = None
status: str | None = "draft" status: str | None = "draft"
base_salary: float | None = None base_salary: float | None = None
currency: str | None = None currency: str | None = None
@ -74,9 +77,9 @@ class OfferSent(BaseModel):
inbox_id: int | None = None inbox_id: int | None = None
manual_upload_candidate_id: str | None = None manual_upload_candidate_id: str | None = None
form_data_id: str | None = None form_data_id: str | None = None
job_post_id: str job_post_id: str | None = None
candidate_user_id: str | None = None candidate_user_id: str | None = None
base_salary: float base_salary: float | None = None
currency: str | None = "USD" currency: str | None = "USD"
salary_period: str | None = "year" salary_period: str | None = "year"
signing_bonus: float | None = None signing_bonus: float | None = None

View File

@ -183,6 +183,20 @@ class Offers(SQLModel, table=True):
) )
return result.scalars().first() return result.scalars().first()
@classmethod
async def get_draft_or_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.in_(("draft", "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"

View File

@ -219,39 +219,75 @@ class Offer:
existing["name"]=item.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"):
raise HTTPException(status_code=422,detail="inbox_id is required")
job_post_id=_as_uuid(payload.get("job_post_id"))
if job_post_id is None:
raise HTTPException(status_code=422,detail="job_post_id is required")
candidate_user_id=_as_uuid(payload.get("candidate_user_id"))
if candidate_user_id is None:
raise HTTPException(status_code=422,detail="candidate_user_id is required")
created_by=_user_id(current_user) created_by=_user_id(current_user)
status=payload.get("status") or "draft" 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"]
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={ fields=_comp_fields(payload)
"inbox_id": int(payload["inbox_id"]), 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, "job_post_id": job_post_id,
"candidate_user_id": candidate_user_id, "candidate_user_id": candidate_user_id,
"created_by": created_by, "status": "draft",
"status": status, })
} if fields.get("equity_units") in (None,0):
for key in non_validation_values(): fields["equity_instrument"]=None
if key in payload and payload[key] is not None:
fields[key]=payload[key]
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")
from_status=row.status
row=await Offers.update_offer(self.session,retry_id,fields)
if from_status!="draft":
await OfferStatusHistory.insert_history(self.session,{
"offer_id": row.id,
"from_status": from_status,
"to_status": "draft",
"changed_by": created_by,
"actor_kind": "user",
"change_reason": payload.get("change_reason") or "saved",
})
return (await self._hydrate_offers([row]))[0]
existing=await Offers.get_draft_or_failed_for_candidate_job(
self.session,candidate_user_id,job_post_id,
)
if existing:
from_status=existing.status
row=await Offers.update_offer(self.session,existing.id,fields)
if from_status!="draft":
await OfferStatusHistory.insert_history(self.session,{
"offer_id": row.id,
"from_status": from_status,
"to_status": "draft",
"changed_by": created_by,
"actor_kind": "user",
"change_reason": payload.get("change_reason") or "saved",
})
return (await self._hydrate_offers([row]))[0]
fields["created_by"]=created_by
row=await Offers.insert_offer(self.session,fields) row=await Offers.insert_offer(self.session,fields)
history_data={ await OfferStatusHistory.insert_history(self.session,{
"offer_id": row.id, "offer_id": row.id,
"from_status": None, "from_status": None,
"to_status": status, "to_status": "draft",
"changed_by": created_by, "changed_by": created_by,
"actor_kind": "user", "actor_kind": "user",
"change_reason": payload.get("change_reason"), "change_reason": payload.get("change_reason"),
} })
await OfferStatusHistory.insert_history(self.session,history_data) return (await self._hydrate_offers([row]))[0]
return serialize_offer(row)
async def update_offer(self,offer_id,payload,current_user): async def update_offer(self,offer_id,payload,current_user):
row=await Offers.get_offer_by_id(self.session,offer_id) row=await Offers.get_offer_by_id(self.session,offer_id)
@ -322,6 +358,7 @@ class Offer:
async def send_offer(self,payload,current_user): async def send_offer(self,payload,current_user):
created_by=_user_id(current_user) created_by=_user_id(current_user)
payload=await self._payload_for_send(payload)
app=await self._resolve_application(payload) app=await self._resolve_application(payload)
await self._assert_offer_job(current_user,app["job_post_id"]) await self._assert_offer_job(current_user,app["job_post_id"])
candidate_user_id=app["candidate_user_id"] candidate_user_id=app["candidate_user_id"]
@ -331,7 +368,10 @@ class Offer:
open_row=await Offers.get_open_for_candidate_job(self.session,candidate_user_id,job_post_id) 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")) retry_id=_as_uuid(payload.get("offer_id"))
if open_row and (retry_id is None or open_row.id!=retry_id): resend=False
if open_row and retry_id is not None and open_row.id==retry_id:
resend=open_row.status in ("sent","negotiating")
elif 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") raise HTTPException(status_code=409,detail="An offer is already in progress for this candidate and job")
fields=_comp_fields(payload) fields=_comp_fields(payload)
@ -341,8 +381,9 @@ class Offer:
"form_data_id": app.get("form_data_id"), "form_data_id": app.get("form_data_id"),
"job_post_id": job_post_id, "job_post_id": job_post_id,
"candidate_user_id": candidate_user_id, "candidate_user_id": candidate_user_id,
"status": "failed",
}) })
if not resend:
fields["status"]="draft"
if fields.get("equity_units") in (None,0): if fields.get("equity_units") in (None,0):
fields["equity_instrument"]=None fields["equity_instrument"]=None
@ -351,20 +392,23 @@ class Offer:
row=await Offers.get_offer_by_id(self.session,retry_id) row=await Offers.get_offer_by_id(self.session,retry_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")
if row.status in ("sent","negotiating"): if row.status in ("accepted","declined"):
raise HTTPException(status_code=409,detail="This offer has already been sent") raise HTTPException(status_code=409,detail="This offer is already closed")
row=await Offers.update_offer(self.session,retry_id,fields) row=await Offers.update_offer(self.session,retry_id,fields)
else: else:
failed=await Offers.get_failed_for_candidate_job(self.session,candidate_user_id,job_post_id) existing=await Offers.get_draft_or_failed_for_candidate_job(
if failed: self.session,candidate_user_id,job_post_id,
row=await Offers.update_offer(self.session,failed.id,fields) )
if existing:
row=await Offers.update_offer(self.session,existing.id,fields)
else: else:
fields["created_by"]=created_by fields["created_by"]=created_by
fields["status"]="draft"
row=await Offers.insert_offer(self.session,fields) row=await Offers.insert_offer(self.session,fields)
await OfferStatusHistory.insert_history(self.session,{ await OfferStatusHistory.insert_history(self.session,{
"offer_id": row.id, "offer_id": row.id,
"from_status": None, "from_status": None,
"to_status": "failed", "to_status": "draft",
"changed_by": created_by, "changed_by": created_by,
"actor_kind": "user", "actor_kind": "user",
"change_reason": payload.get("change_reason"), "change_reason": payload.get("change_reason"),
@ -383,6 +427,9 @@ class Offer:
raise RuntimeError("candidate has no email") raise RuntimeError("candidate has no email")
await send_offer_mail(email,subject,html) await send_offer_mail(email,subject,html)
except (httpx.HTTPError,RuntimeError) as e: except (httpx.HTTPError,RuntimeError) as e:
if row.status not in ("sent","negotiating"):
await Offers.update_offer(self.session,row.id,{"status":"failed"})
row=await Offers.get_offer_by_id(self.session,row.id)
await self._notify_send_failed(created_by,name,job_title,row) await self._notify_send_failed(created_by,name,job_title,row)
raise HTTPException(status_code=502,detail=MAIL_FAIL_DETAIL) from e raise HTTPException(status_code=502,detail=MAIL_FAIL_DETAIL) from e
@ -416,6 +463,33 @@ class Offer:
) )
return (await self._hydrate_offers([updated]))[0] return (await self._hydrate_offers([updated]))[0]
async def _payload_for_send(self,payload):
data=dict(payload or {})
offer_id=_as_uuid(data.get("offer_id"))
if offer_id is None:
return data
row=await Offers.get_offer_by_id(self.session,offer_id)
if not row:
raise HTTPException(status_code=404,detail="Offer not found")
if data.get("inbox_id") in (None,"") and not data.get("manual_upload_candidate_id") and not data.get("form_data_id"):
if row.inbox_id is not None:
data["inbox_id"]=row.inbox_id
elif row.manual_upload_candidate_id is not None:
data["manual_upload_candidate_id"]=str(row.manual_upload_candidate_id)
elif row.form_data_id is not None:
data["form_data_id"]=str(row.form_data_id)
if data.get("job_post_id") in (None,""):
data["job_post_id"]=str(row.job_post_id) if row.job_post_id else None
if data.get("candidate_user_id") in (None,""):
data["candidate_user_id"]=str(row.candidate_user_id) if row.candidate_user_id else None
if data.get("base_salary") in (None,""):
data["base_salary"]=row.base_salary
for key in ("currency","salary_period","signing_bonus","annual_bonus_pct",
"equity_units","equity_instrument","start_date","expiry_date"):
if key not in data or data.get(key) in (None,""):
data[key]=getattr(row,key)
return data
async def _resolve_application(self,payload): async def _resolve_application(self,payload):
inbox_id=payload.get("inbox_id") inbox_id=payload.get("inbox_id")
manual_id=_as_uuid(payload.get("manual_upload_candidate_id")) manual_id=_as_uuid(payload.get("manual_upload_candidate_id"))

View File

@ -8,7 +8,8 @@ import { toDate } from '../lib/format'
recruiter who can read offers still cannot issue one. recruiter who can read offers still cannot issue one.
List rows include candidate_name / created_by_name. Job title is still List rows include candidate_name / created_by_name. Job title is still
hydrated from /job/fetch?ids=. Create Offer uses POST /offers/jobs/sent. hydrated from /job/fetch?ids=. Create Offer saves POST /offers/create
(draft). Send on the list is POST /offers/jobs/sent.
============================================================ */ ============================================================ */
/** `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. */

View File

@ -205,7 +205,8 @@ export function hrefForPreviousApplication(item) {
/** Full application list for profile / inbox / add-candidate. */ /** Full application list for profile / inbox / add-candidate. */
export function PreviousApplications({ row, title = 'Total applications' }) { export function PreviousApplications({ row, title = 'Total applications' }) {
const items = candidateApplicationsOf(row) const items = candidateApplicationsOf(row)
if (!items.length) return null // One row is the application already on screen do not show a history card.
if (items.length < 2) return null
const current = currentRowIds(row) const current = currentRowIds(row)
const heading = title === 'Total applications' ? `Total applications (${items.length})` : title const heading = title === 'Total applications' ? `Total applications (${items.length})` : title
return ( return (

View File

@ -1,14 +1,9 @@
/* ============================================================ /* ============================================================
Offers live on backend/offer/app.py. Offers live on backend/offer/app.py.
Read is GET /offers/fetch. Create Offer writes POST /offers/jobs/sent Create Offer writes POST /offers/create as a draft (no email). Send on the
(persist, email via Teams, then pipeline OFFER). Failed sends stay listed; list (and offer detail) writes POST /offers/jobs/sent: Teams mail, then
GET /offers/fetch?offer_id= prefills the form for retry. Drafts still use pipeline OFFER. Failed sends stay listed for retry from the row Send button.
POST /offers/create (Candidate Forms) and POST /offers/issue.
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, useEffect, useRef } from 'react' import { useMemo, useState, useEffect, useRef } from 'react'
@ -144,13 +139,27 @@ export default function Offers() {
} }
}, [searchParams, setSearchParams]) }, [searchParams, setSearchParams])
const issue = useMutation({ const save = useMutation({
mutationFn: (offerId) => offersApi.issue(offerId), mutationFn: (body) => offersApi.create(body),
onSuccess: (_res, _id) => { onSuccess: () => {
invalidate() invalidate()
toast('Offer issued and marked sent', 'success') setCreating(null)
toast('Offer saved — review it on the list, then Send', 'success')
},
onError: (err) => toast(friendlyAuthError(err, 'Could not save the offer.'), 'error'),
})
const send = useMutation({
mutationFn: (body) => offersApi.send(body),
onSuccess: () => {
invalidate()
setViewing(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 issue the offer.'), 'error'),
}) })
const setStatus = useMutation({ const setStatus = useMutation({
@ -171,20 +180,7 @@ 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 send = useMutation({ const busy = send.isPending || setStatus.isPending
mutationFn: (body) => offersApi.send(body),
onSuccess: () => {
invalidate()
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')
},
})
const busy = issue.isPending || setStatus.isPending
const columns = [ const columns = [
{ {
@ -225,17 +221,15 @@ 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={() => ( <button className="act-btn" data-tip="View" aria-label="View offer" onClick={() => setViewing(o)}>
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 === 'failed' ? 'Edit and resend' : o.status === 'draft' ? 'Send offer' : 'Resend'} data-tip={o.status === 'draft' || o.status === 'failed' ? 'Send offer' : 'Resend'}
aria-label={o.status === 'failed' ? 'Edit and resend offer' : o.status === 'draft' ? 'Send offer' : 'Resend offer'} aria-label={o.status === 'draft' || o.status === 'failed' ? 'Send offer' : 'Resend offer'}
disabled={busy || ['accepted', 'declined'].includes(o.status)} disabled={busy || ['accepted', 'declined'].includes(o.status)}
onClick={() => (o.status === 'failed' ? setCreating({ offerId: o.id }) : issue.mutate(o.id))} onClick={() => send.mutate(sendPayloadFromOffer(o))}
> >
<Icon name="send" /> <Icon name="send" />
</button> </button>
@ -306,24 +300,24 @@ export default function Offers() {
offer={viewing} offer={viewing}
busy={busy} busy={busy}
onClose={() => setViewing(null)} onClose={() => setViewing(null)}
onIssue={() => issue.mutate(viewing.id)} onSend={() => send.mutate(sendPayloadFromOffer(viewing))}
onRetry={() => { setViewing(null); setCreating({ offerId: viewing.id }) }} onEdit={() => { 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
offerId={creating.offerId || null} offerId={creating.offerId || null}
busy={send.isPending} busy={save.isPending}
onClose={() => setCreating(null)} onClose={() => setCreating(null)}
onSubmit={(body) => send.mutate(body)} onSubmit={(body) => save.mutate(body)}
/> />
)} )}
</div> </div>
) )
} }
function OfferDetail({ offer: o, busy, onClose, onIssue, onRetry, onStatus }) { function OfferDetail({ offer: o, busy, onClose, onSend, onEdit, 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. */
@ -346,19 +340,16 @@ function OfferDetail({ offer: o, busy, onClose, onIssue, onRetry, onStatus }) {
Mark {OFFER_STATUS_LABEL[s]} Mark {OFFER_STATUS_LABEL[s]}
</button> </button>
))} ))}
{o.status === 'failed' ? ( <button className="btn btn-secondary" disabled={busy} onClick={onEdit}>
<button className="btn btn-primary" disabled={busy} onClick={onRetry}> Edit
<Icon name="send" /> Edit and resend
</button> </button>
) : (
<button <button
className="btn btn-primary" className="btn btn-primary"
disabled={busy || ['accepted', 'declined'].includes(o.status)} disabled={busy || ['accepted', 'declined'].includes(o.status)}
onClick={() => { onIssue(); onClose() }} onClick={onSend}
> >
<Icon name="send" /> {o.status === 'draft' ? 'Send Offer' : 'Resend Offer'} <Icon name="send" /> {o.status === 'draft' || o.status === 'failed' ? 'Send Offer' : 'Resend Offer'}
</button> </button>
)}
</> </>
} }
> >
@ -435,6 +426,28 @@ function applicationIdentity(c) {
return {} return {}
} }
function sendPayloadFromOffer(o) {
return {
offer_id: o.id,
...applicationIdentity({
inbox_id: o.inboxId,
manual_upload_candidate_id: o.manualUploadId,
form_data_id: o.formDataId,
}),
job_post_id: o.jobPostId,
candidate_user_id: o.candidateUserId || undefined,
base_salary: o.base,
currency: o.currency,
salary_period: o.salaryPeriod,
annual_bonus_pct: o.bonusPct,
signing_bonus: o.signingBonus,
equity_units: o.equityUnits,
equity_instrument: o.equityInstrument,
start_date: localDateIso(toDateInput(o.startDate)),
expiry_date: localDateIso(toDateInput(o.expiry)),
}
}
function localDateIso(date) { function localDateIso(date) {
if (!date) return null if (!date) return null
const d = new Date(`${date}T00:00`) const d = new Date(`${date}T00:00`)
@ -658,6 +671,7 @@ function CreateOffer({ offerId, busy, onClose, onSubmit }) {
...identity, ...identity,
job_post_id: selected.job_post_id, job_post_id: selected.job_post_id,
candidate_user_id: selected.user_id || undefined, 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,
@ -676,14 +690,14 @@ function CreateOffer({ offerId, busy, onClose, onSubmit }) {
return ( return (
<Modal <Modal
title={offerId ? 'Resend Offer' : 'Create Offer'} title={offerId ? 'Edit Offer' : 'Create Offer'}
subtitle={offerId ? 'Edit compensation and send again' : 'Emails the candidate and moves them to Offer'} subtitle={offerId ? 'Update compensation and save — send from the list' : 'Saved as a draft — send from the Offers list after you review it'}
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={sendDisabled}> <button className="btn btn-primary" onClick={submit} disabled={sendDisabled}>
<Icon name="send" /> {busy ? 'Sending…' : 'Send Offer'} <Icon name="check" /> {busy ? 'Saving…' : 'Save Offer'}
</button> </button>
</> </>
} }
@ -781,7 +795,7 @@ function CreateOffer({ offerId, 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.
Sending emails the candidate and moves the pipeline to Offer only after the mail succeeds. This saves a draft only. Use Send on the Offers list after you review it that emails the candidate and moves them to Offer.
</p> </p>
</form> </form>
</Modal> </Modal>