total applications removed
parent
25220d237b
commit
c0e4a94d58
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
) : (
|
className="btn btn-primary"
|
||||||
<button
|
disabled={busy || ['accepted', 'declined'].includes(o.status)}
|
||||||
className="btn btn-primary"
|
onClick={onSend}
|
||||||
disabled={busy || ['accepted', 'declined'].includes(o.status)}
|
>
|
||||||
onClick={() => { onIssue(); onClose() }}
|
<Icon name="send" /> {o.status === 'draft' || o.status === 'failed' ? 'Send Offer' : 'Resend Offer'}
|
||||||
>
|
</button>
|
||||||
<Icon name="send" /> {o.status === 'draft' ? 'Send Offer' : 'Resend Offer'}
|
|
||||||
</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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue