HR-ATS-Portal/frontend/src/api/offers.js

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),
}
}