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