162 lines
5.3 KiB
JavaScript
162 lines
5.3 KiB
JavaScript
import { request } from '../lib/apiClient'
|
|
import { toDate } from '../lib/format'
|
|
|
|
/* ============================================================
|
|
offers.js — backend/offer/app.py.
|
|
|
|
Permissioned OFFERS_VIEW / OFFERS_CREATE / OFFERS_EDIT / OFFERS_APPROVE, so a
|
|
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.
|
|
============================================================ */
|
|
|
|
/** `status` is a free-text column defaulting to "draft"; the vocabulary is decided here. */
|
|
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',
|
|
expired: 'Expired',
|
|
}
|
|
|
|
/** Label -> wire value, for the filter select. */
|
|
export const OFFER_STATUS_VALUE = Object.fromEntries(
|
|
Object.entries(OFFER_STATUS_LABEL).map(([value, label]) => [label, value]),
|
|
)
|
|
|
|
const STATUS_CLASS = {
|
|
accepted: 'b-green',
|
|
sent: 'b-blue',
|
|
negotiating: 'b-amber',
|
|
declined: 'b-red',
|
|
expired: 'b-gray',
|
|
draft: 'b-gray',
|
|
failed: 'b-red',
|
|
}
|
|
|
|
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)
|
|
* — which is why the Create Offer picker is sourced from the pipeline board:
|
|
* that payload is the only one carrying all three on a single row.
|
|
*/
|
|
export function create(body) {
|
|
return request('/offers/create', { method: 'POST', body })
|
|
}
|
|
|
|
export function update(offerId, body) {
|
|
return request('/offers/update', {
|
|
method: 'PATCH',
|
|
params: { offer_id: offerId },
|
|
body,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Issue — stamps `issued_by` + `sent_at` and moves draft -> sent, writing a row
|
|
* to offer_status_history. Re-issuing an already-sent offer is allowed and
|
|
* re-stamps sent_at, which is what "Resend" means here.
|
|
*/
|
|
export function issue(offerId, body = {}) {
|
|
return request('/offers/issue', {
|
|
method: 'POST',
|
|
params: { offer_id: offerId },
|
|
body,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* `{equity_units, equity_instrument}` -> the one string the table shows.
|
|
* Returns null rather than "0 RSU" when nothing was agreed, so the cell reads
|
|
* as "not offered" instead of "offered nothing".
|
|
*/
|
|
function equityLabel(units, instrument) {
|
|
if (units == null || units === 0) return null
|
|
const n = Number(units)
|
|
const pretty = n >= 1000 && n % 1000 === 0 ? `${n / 1000}k` : String(n)
|
|
return `${pretty} ${instrument || 'RSU'}`
|
|
}
|
|
|
|
/**
|
|
* API row -> the shape Offers.jsx renders. `people` and `jobTitles` are the
|
|
* hydration maps the screen builds once per page; both are optional so this
|
|
* stays usable from a context that has neither.
|
|
*/
|
|
export function toOfferView(row, { people, jobTitles } = {}) {
|
|
const person = people?.get(String(row.candidate_user_id)) ?? null
|
|
const jobTitle = jobTitles?.get(String(row.job_post_id)) ?? null
|
|
const status = row.status || 'draft'
|
|
const bonusPct = row.annual_bonus_pct
|
|
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 || row.candidate_name || 'Unknown candidate',
|
|
email: person?.email ?? null,
|
|
createdByName: row.created_by_name || null,
|
|
jobTitle: jobTitle || '—',
|
|
status,
|
|
statusLabel: OFFER_STATUS_LABEL[status] ?? status,
|
|
statusClass: STATUS_CLASS[status] ?? 'b-gray',
|
|
base: row.base_salary ?? null,
|
|
currency: row.currency || 'USD',
|
|
salaryPeriod: row.salary_period || 'year',
|
|
signingBonus: row.signing_bonus ?? null,
|
|
bonusPct: bonusPct ?? null,
|
|
bonus: bonusPct != null ? `${bonusPct}%` : null,
|
|
equity: equityLabel(row.equity_units, row.equity_instrument),
|
|
equityUnits: row.equity_units ?? null,
|
|
equityInstrument: row.equity_instrument || null,
|
|
// Annexure J (offer email format) fields.
|
|
cadre: row.cadre || null,
|
|
grossSalaryInWords: row.gross_salary_in_words || null,
|
|
subsidizedServices: row.subsidized_services || null,
|
|
probationPeriod: row.probation_period || null,
|
|
noticePeriod: row.notice_period || null,
|
|
workLocation: row.work_location || null,
|
|
workTimings: row.work_timings || null,
|
|
startDate: toDate(row.start_date),
|
|
expiry: toDate(row.expiry_date),
|
|
sent: toDate(row.sent_at),
|
|
respondedAt: toDate(row.responded_at),
|
|
closedAt: toDate(row.closed_at),
|
|
created: toDate(row.created_at),
|
|
}
|
|
}
|