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):
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
candidate_user_id: str
candidate_user_id: str | None = None
status: str | None = "draft"
base_salary: float | None = None
currency: str | None = None
@ -74,9 +77,9 @@ class OfferSent(BaseModel):
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 | None = None
candidate_user_id: str | None = None
base_salary: float
base_salary: float | None = None
currency: str | None = "USD"
salary_period: str | None = "year"
signing_bonus: float | None = None

View File

@ -183,6 +183,20 @@ class Offers(SQLModel, table=True):
)
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):
__tablename__ = "offer_status_history"

View File

@ -219,39 +219,75 @@ class Offer:
existing["name"]=item.get("name")
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)
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={
"inbox_id": int(payload["inbox_id"]),
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,
"created_by": created_by,
"status": status,
}
for key in non_validation_values():
if key in payload and payload[key] is not None:
fields[key]=payload[key]
"status": "draft",
})
if fields.get("equity_units") in (None,0):
fields["equity_instrument"]=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")
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)
history_data={
await OfferStatusHistory.insert_history(self.session,{
"offer_id": row.id,
"from_status": None,
"to_status": status,
"to_status": "draft",
"changed_by": created_by,
"actor_kind": "user",
"change_reason": payload.get("change_reason"),
}
await OfferStatusHistory.insert_history(self.session,history_data)
return serialize_offer(row)
})
return (await self._hydrate_offers([row]))[0]
async def update_offer(self,offer_id,payload,current_user):
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):
created_by=_user_id(current_user)
payload=await self._payload_for_send(payload)
app=await self._resolve_application(payload)
await self._assert_offer_job(current_user,app["job_post_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)
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")
fields=_comp_fields(payload)
@ -341,8 +381,9 @@ class Offer:
"form_data_id": app.get("form_data_id"),
"job_post_id": job_post_id,
"candidate_user_id": candidate_user_id,
"status": "failed",
})
if not resend:
fields["status"]="draft"
if fields.get("equity_units") in (None,0):
fields["equity_instrument"]=None
@ -351,20 +392,23 @@ class Offer:
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")
if row.status in ("accepted","declined"):
raise HTTPException(status_code=409,detail="This offer is already closed")
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)
existing=await Offers.get_draft_or_failed_for_candidate_job(
self.session,candidate_user_id,job_post_id,
)
if existing:
row=await Offers.update_offer(self.session,existing.id,fields)
else:
fields["created_by"]=created_by
fields["status"]="draft"
row=await Offers.insert_offer(self.session,fields)
await OfferStatusHistory.insert_history(self.session,{
"offer_id": row.id,
"from_status": None,
"to_status": "failed",
"to_status": "draft",
"changed_by": created_by,
"actor_kind": "user",
"change_reason": payload.get("change_reason"),
@ -383,6 +427,9 @@ class Offer:
raise RuntimeError("candidate has no email")
await send_offer_mail(email,subject,html)
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)
raise HTTPException(status_code=502,detail=MAIL_FAIL_DETAIL) from e
@ -416,6 +463,33 @@ class Offer:
)
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):
inbox_id=payload.get("inbox_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.
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. */

View File

@ -205,7 +205,8 @@ export function hrefForPreviousApplication(item) {
/** Full application list for profile / inbox / add-candidate. */
export function PreviousApplications({ row, title = 'Total applications' }) {
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 heading = title === 'Total applications' ? `Total applications (${items.length})` : title
return (

View File

@ -1,14 +1,9 @@
/* ============================================================
Offers live on backend/offer/app.py.
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 now includes candidate_name when listing;
job titles still come from /job/fetch?ids=. Equity is `equity_units` +
`equity_instrument` server-side.
Create Offer writes POST /offers/create as a draft (no email). Send on the
list (and offer detail) writes POST /offers/jobs/sent: Teams mail, then
pipeline OFFER. Failed sends stay listed for retry from the row Send button.
============================================================ */
import { useMemo, useState, useEffect, useRef } from 'react'
@ -144,13 +139,27 @@ export default function Offers() {
}
}, [searchParams, setSearchParams])
const issue = useMutation({
mutationFn: (offerId) => offersApi.issue(offerId),
onSuccess: (_res, _id) => {
const save = useMutation({
mutationFn: (body) => offersApi.create(body),
onSuccess: () => {
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({
@ -171,20 +180,7 @@ export default function Offers() {
onError: (err) => toast(friendlyAuthError(err, 'Could not update the offer.'), 'error'),
})
const send = useMutation({
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 busy = send.isPending || setStatus.isPending
const columns = [
{
@ -225,17 +221,15 @@ export default function Offers() {
key: '_a', label: 'Actions', align: 'right',
render: (o) => (
<div className="row-actions">
<button className="act-btn" data-tip="View" aria-label="View offer" onClick={() => (
o.status === 'failed' ? setCreating({ offerId: o.id }) : setViewing(o)
)}>
<button className="act-btn" data-tip="View" aria-label="View offer" onClick={() => setViewing(o)}>
<Icon name="eye" />
</button>
<button
className="act-btn"
data-tip={o.status === 'failed' ? 'Edit and resend' : o.status === 'draft' ? 'Send offer' : 'Resend'}
aria-label={o.status === 'failed' ? 'Edit and resend offer' : o.status === 'draft' ? 'Send offer' : 'Resend offer'}
data-tip={o.status === 'draft' || o.status === 'failed' ? 'Send offer' : 'Resend'}
aria-label={o.status === 'draft' || o.status === 'failed' ? 'Send offer' : 'Resend offer'}
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" />
</button>
@ -306,24 +300,24 @@ export default function Offers() {
offer={viewing}
busy={busy}
onClose={() => setViewing(null)}
onIssue={() => issue.mutate(viewing.id)}
onRetry={() => { setViewing(null); setCreating({ offerId: viewing.id }) }}
onSend={() => send.mutate(sendPayloadFromOffer(viewing))}
onEdit={() => { setViewing(null); setCreating({ offerId: viewing.id }) }}
onStatus={(next) => { setStatus.mutate({ offerId: viewing.id, next }); setViewing(null) }}
/>
)}
{creating && (
<CreateOffer
offerId={creating.offerId || null}
busy={send.isPending}
busy={save.isPending}
onClose={() => setCreating(null)}
onSubmit={(body) => send.mutate(body)}
onSubmit={(body) => save.mutate(body)}
/>
)}
</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
is a one-off and is shown separately rather than folded in, because adding
it would overstate year two. */
@ -346,19 +340,16 @@ function OfferDetail({ offer: o, busy, onClose, onIssue, onRetry, onStatus }) {
Mark {OFFER_STATUS_LABEL[s]}
</button>
))}
{o.status === 'failed' ? (
<button className="btn btn-primary" disabled={busy} onClick={onRetry}>
<Icon name="send" /> Edit and resend
<button className="btn btn-secondary" disabled={busy} onClick={onEdit}>
Edit
</button>
) : (
<button
className="btn btn-primary"
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>
)}
</>
}
>
@ -435,6 +426,28 @@ function applicationIdentity(c) {
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) {
if (!date) return null
const d = new Date(`${date}T00:00`)
@ -658,6 +671,7 @@ function CreateOffer({ offerId, busy, onClose, onSubmit }) {
...identity,
job_post_id: selected.job_post_id,
candidate_user_id: selected.user_id || undefined,
status: 'draft',
base_salary: base,
currency: form.currency,
salary_period: form.salaryPeriod,
@ -676,14 +690,14 @@ function CreateOffer({ offerId, busy, onClose, onSubmit }) {
return (
<Modal
title={offerId ? 'Resend Offer' : 'Create Offer'}
subtitle={offerId ? 'Edit compensation and send again' : 'Emails the candidate and moves them to Offer'}
title={offerId ? 'Edit Offer' : 'Create 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}
footer={
<>
<button className="btn btn-secondary" onClick={onClose} disabled={busy}>Cancel</button>
<button className="btn btn-primary" onClick={submit} disabled={sendDisabled}>
<Icon name="send" /> {busy ? 'Sending…' : 'Send Offer'}
<Icon name="check" /> {busy ? 'Saving…' : 'Save Offer'}
</button>
</>
}
@ -781,7 +795,7 @@ function CreateOffer({ offerId, busy, onClose, onSubmit }) {
<p className="text-muted" style={{ marginTop: 16, fontSize: 13 }}>
Equity is stored as a unit count plus an instrument, so 20k RSU is entered as 20000 and RSU.
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>
</form>
</Modal>