/* ============================================================ Offers — live on backend/offer/app.py. 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' import { useSearchParams } from 'react-router-dom' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import DataTable from '../ui/DataTable' import Modal from '../ui/Modal' import PageHeader from '../ui/PageHeader' import { Avatar, Badge, EmptyState, FieldError, Icon, KpiCard, SkeletonRows } from '../ui/primitives' import { useToast } from '../ui/Toast' import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' import { formatRole, toDateInput } from '../lib/format' import * as offersApi from '../api/offers' import * as jobPostsApi from '../api/jobPosts' import { OFFER_STATUS_LABEL, OFFER_STATUS_VALUE } from '../api/offers' import { byUserId, useApplications } from '../lib/useApplications' import { avatarColor, fmtDate, fmtShort, initials as initialsOf, money } from '../data/seed' const FETCH_TOP = 200 const NEXT_STATUSES = { sent: ['negotiating', 'accepted', 'declined'], negotiating: ['accepted', 'declined'], draft: [], failed: [], accepted: [], declined: [], expired: [], } function useOffers(status) { return useQuery({ queryKey: qk.offers.list({ top: FETCH_TOP, status: status || null }), queryFn: async () => { const res = await offersApi.list({ top: FETCH_TOP, status: status || undefined }) return Array.isArray(res?.data) ? res.data : [] }, }) } export default function Offers() { const { toast } = useToast() const qc = useQueryClient() const [searchParams, setSearchParams] = useSearchParams() const [q, setQ] = useState('') const [statusLabel, setStatusLabel] = useState('') const [viewing, setViewing] = useState(null) const [creating, setCreating] = useState(null) const status = statusLabel ? OFFER_STATUS_VALUE[statusLabel] : '' const offersQuery = useOffers(status) const allQuery = useOffers('') const appsQuery = useApplications() const peopleByUserId = useMemo(() => byUserId(appsQuery.data), [appsQuery.data]) const jobIds = useMemo(() => { const ids = new Set() for (const row of allQuery.data ?? []) { if (row.job_post_id) ids.add(String(row.job_post_id)) } return [...ids] }, [allQuery.data]) const titlesQuery = useQuery({ queryKey: qk.jobPosts.list({ ids: jobIds }), queryFn: async () => { if (!jobIds.length) return [] const res = await jobPostsApi.list({ ids: jobIds, activeOnly: false }) return Array.isArray(res?.data) ? res.data : [] }, enabled: jobIds.length > 0, }) const jobTitles = useMemo(() => { const map = new Map() for (const p of titlesQuery.data ?? []) map.set(String(p.id), p.title) return map }, [titlesQuery.data]) const hydration = useMemo( () => ({ people: peopleByUserId, jobTitles }), [peopleByUserId, jobTitles], ) const offers = useMemo( () => (offersQuery.data ?? []).map((row) => offersApi.toOfferView(row, hydration)), [offersQuery.data, hydration], ) const all = useMemo( () => (allQuery.data ?? []).map((row) => offersApi.toOfferView(row, hydration)), [allQuery.data, hydration], ) const stats = useMemo(() => { const decided = all.filter((o) => ['accepted', 'declined'].includes(o.status)).length const accepted = all.filter((o) => o.status === 'accepted').length return { sent: all.filter((o) => o.status !== 'draft' && o.status !== 'failed').length, accepted, pending: all.filter((o) => ['sent', 'negotiating'].includes(o.status)).length, rate: Math.round((accepted / (decided || 1)) * 100), } }, [all]) const rows = useMemo( () => offers.filter((o) => { if (!q) return true const hay = `${o.candidate} ${o.jobTitle} ${o.email ?? ''}`.toLowerCase() return hay.includes(q.toLowerCase()) }), [offers, q], ) const invalidate = () => { qc.invalidateQueries({ queryKey: qk.offers.all() }) qc.invalidateQueries({ queryKey: qk.notifications.all() }) qc.invalidateQueries({ queryKey: qk.pipeline.all() }) } useEffect(() => { const offerId = searchParams.get('offer') if (offerId) { setCreating({ offerId }) searchParams.delete('offer') setSearchParams(searchParams, { replace: true }) } }, [searchParams, setSearchParams]) const save = useMutation({ mutationFn: (body) => offersApi.create(body), onSuccess: () => { invalidate() 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') }, }) const setStatus = useMutation({ mutationFn: ({ offerId, next }) => { const body = { status: next } /* responded_at is what separates "we sent it" from "they answered". The server does not stamp it, so the client does, on the two statuses that actually represent a candidate response. */ if (next === 'accepted' || next === 'declined') { body.responded_at = new Date().toISOString() } return offersApi.update(offerId, body) }, onSuccess: (_res, { next }) => { invalidate() toast(`Offer marked ${OFFER_STATUS_LABEL[next].toLowerCase()}`, 'success') }, onError: (err) => toast(friendlyAuthError(err, 'Could not update the offer.'), 'error'), }) const busy = send.isPending || setStatus.isPending const columns = [ { key: 'candidate', label: 'Candidate', sortable: true, render: (o) => (
{o.candidate}
{o.jobTitle}
), }, { key: 'base', label: 'Base Salary', sortable: true, align: 'right', sortValue: (o) => o.base ?? 0, render: (o) => (o.base != null ? {money(o.base)} : ), }, { key: 'equity', label: 'Equity', render: (o) => {o.equity ?? '—'}, }, { key: 'bonus', label: 'Bonus', align: 'center', render: (o) => {o.bonus ?? '—'}, }, { key: 'sent', label: 'Sent', sortable: true, sortValue: (o) => (o.sent ? o.sent.getTime() : 0), render: (o) => {o.sent ? fmtShort(o.sent) : '—'}, }, { key: 'status', label: 'Status', sortable: true, render: (o) => {o.statusLabel}, }, { key: '_a', label: 'Actions', align: 'right', render: (o) => (
), }, ] return (
setCreating({})}> Create Offer } />
setQ(e.target.value)} placeholder="Search candidate or role…" />
{offersQuery.isPending && (
)} {offersQuery.isError && (
{friendlyAuthError(offersQuery.error, 'The server did not return offers.')} {' '}This screen needs the offers.view permission.
)} {!offersQuery.isPending && !offersQuery.isError && ( )}
{viewing && ( setViewing(null)} onSend={() => send.mutate(sendPayloadFromOffer(viewing))} onEdit={() => { setViewing(null); setCreating({ offerId: viewing.id }) }} onStatus={(next) => { setStatus.mutate({ offerId: viewing.id, next }); setViewing(null) }} /> )} {creating && ( setCreating(null)} onSubmit={(body) => save.mutate(body)} /> )}
) } 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. */ const total = o.base != null ? o.base + Math.round((o.base * (o.bonusPct ?? 0)) / 100) : null const next = NEXT_STATUSES[o.status] ?? [] return ( {next.map((s) => ( ))} } >
{o.candidate}
{o.jobTitle}{o.email ? ` · ${o.email}` : ''}
{o.statusLabel}

Compensation Package

Base Salary
{o.base != null ? `${money(o.base)} / ${o.salaryPeriod}` : '—'}
Annual Bonus
{o.bonus ?? '—'}
Equity
{o.equity ?? '—'}
Est. Total Cash
{total != null ? money(total) : '—'}
Signing Bonus
{o.signingBonus != null ? money(o.signingBonus) : '—'}
Currency
{o.currency}
Start Date
{o.startDate ? fmtDate(o.startDate) : '—'}
Expires
{o.expiry ? fmtDate(o.expiry) : '—'}
Sent On
{o.sent ? fmtDate(o.sent) : 'Not sent yet'}
Responded
{o.respondedAt ? fmtDate(o.respondedAt) : '—'}
Created
{o.created ? fmtDate(o.created) : '—'}
Offer ID
{o.id}
) } const SOURCE_LABEL = { inbox: 'Inbox', manual: 'Upload', form: 'Form' } function candidateKey(c) { if (c?.inbox_id != null && c.inbox_id !== '') return `inbox:${c.inbox_id}` if (c?.manual_upload_candidate_id) return `manual:${c.manual_upload_candidate_id}` if (c?.form_data_id) return `form:${c.form_data_id}` return '' } function applicationIdentity(c) { if (!c) return {} if (c.inbox_id != null && c.inbox_id !== '') return { inbox_id: Number(c.inbox_id) } if (c.manual_upload_candidate_id) return { manual_upload_candidate_id: c.manual_upload_candidate_id } if (c.form_data_id) return { form_data_id: c.form_data_id } 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`) return Number.isNaN(d.getTime()) ? null : d.toISOString() } function candidateOptionLabel(c) { const name = c.name || c.email || 'Candidate' const job = c.job_title ? ` — ${c.job_title}` : '' const stage = formatRole(c.stage || c.application_status || '') const source = SOURCE_LABEL[c.source] || c.source || '' return [name + job, stage, source].filter(Boolean).join(' · ') } function blankOfferForm() { return { candidateKey: '', base: '', currency: 'USD', salaryPeriod: 'year', bonusPct: '10', signingBonus: '', equityUnits: '', equityInstrument: 'RSU', startDate: '', expiryDate: '', } } function formFromOffer(row) { return { candidateKey: candidateKey({ inbox_id: row.inbox_id, manual_upload_candidate_id: row.manual_upload_candidate_id, form_data_id: row.form_data_id, }), base: row.base_salary != null ? String(row.base_salary) : '', currency: row.currency || 'USD', salaryPeriod: row.salary_period || 'year', bonusPct: row.annual_bonus_pct != null ? String(row.annual_bonus_pct) : '', signingBonus: row.signing_bonus != null ? String(row.signing_bonus) : '', equityUnits: row.equity_units != null ? String(row.equity_units) : '', equityInstrument: row.equity_instrument || 'RSU', startDate: toDateInput(row.start_date), expiryDate: toDateInput(row.expiry_date), } } function CandidatePicker({ selected, options, loading, disabled, error, onPick, onSearch, }) { const [q, setQ] = useState('') const [open, setOpen] = useState(false) const root = useRef(null) useEffect(() => { function onDoc(e) { if (root.current && !root.current.contains(e.target)) setOpen(false) } document.addEventListener('mousedown', onDoc) return () => document.removeEventListener('mousedown', onDoc) }, []) useEffect(() => { if (!open || disabled) return onSearch?.(q) }, [q, open, disabled, onSearch]) const display = open ? q : (selected ? candidateOptionLabel(selected) : '') const emptyCopy = q.trim() ? 'No matches' : 'No candidates at interview stage or later' return (
{ if (disabled) return setOpen(true) setQ('') }} onChange={(e) => { setQ(e.target.value) setOpen(true) }} /> {open && !disabled && (
{loading && (
Loading…
)} {!loading && options.length === 0 && (
{emptyCopy}
)} {!loading && options.map((a) => ( ))}
)}
) } function CreateOffer({ offerId, busy, onClose, onSubmit }) { const [search, setSearch] = useState('') const [debounced, setDebounced] = useState('') const [picked, setPicked] = useState(null) const [form, setForm] = useState(blankOfferForm) const [errors, setErrors] = useState({}) const set = (k, v) => setForm((f) => ({ ...f, [k]: v })) useEffect(() => { const t = setTimeout(() => setDebounced(search.trim()), 250) return () => clearTimeout(t) }, [search]) const candidatesQuery = useQuery({ queryKey: qk.offers.candidates({ search: debounced, top: 200 }), queryFn: async () => { const res = await offersApi.listCandidates({ search: debounced || undefined, top: 200 }) return Array.isArray(res?.data) ? res.data : [] }, placeholderData: keepPreviousData, enabled: !offerId, }) const offerQuery = useQuery({ queryKey: qk.offers.detail(offerId), queryFn: async () => { const res = await offersApi.get(offerId) return res?.data ?? null }, enabled: Boolean(offerId), }) useEffect(() => { if (!offerQuery.data) return setForm(formFromOffer(offerQuery.data)) setPicked({ inbox_id: offerQuery.data.inbox_id, manual_upload_candidate_id: offerQuery.data.manual_upload_candidate_id, form_data_id: offerQuery.data.form_data_id, user_id: offerQuery.data.candidate_user_id, job_post_id: offerQuery.data.job_post_id, name: offerQuery.data.candidate_name, job_title: null, source: offerQuery.data.inbox_id != null ? 'inbox' : offerQuery.data.manual_upload_candidate_id ? 'manual' : 'form', stage: offerQuery.data.status, }) }, [offerQuery.data]) const applications = candidatesQuery.data ?? [] const selected = picked || applications.find((a) => candidateKey(a) === form.candidateKey) || null const loading = offerId ? offerQuery.isPending : candidatesQuery.isPending function pickCandidate(a) { setPicked(a) set('candidateKey', candidateKey(a)) setErrors((e) => ({ ...e, candidate: undefined })) } function submit() { if (busy) return const next = {} if (!selected) next.candidate = 'Pick a candidate' if (selected && !selected.job_post_id) { next.candidate = 'This application has no assigned role' } const identity = applicationIdentity(selected) if (selected && !Object.keys(identity).length) { next.candidate = 'This application has no inbox, upload, or form id' } const base = form.base === '' ? null : Number(form.base) if (base == null || !Number.isFinite(base) || base <= 0) next.base = 'Enter a base salary' const bonus = form.bonusPct === '' ? null : Number(form.bonusPct) if (bonus != null && (!Number.isFinite(bonus) || bonus < 0)) next.bonusPct = 'Enter a valid percentage' const units = form.equityUnits === '' ? null : Number(form.equityUnits) if (units != null && (!Number.isInteger(units) || units < 0)) next.equityUnits = 'Whole units only' setErrors(next) if (Object.keys(next).length) return const signing = form.signingBonus === '' ? null : Number(form.signingBonus) const body = { ...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, annual_bonus_pct: bonus, signing_bonus: Number.isFinite(signing) ? signing : null, equity_units: units, equity_instrument: units != null ? form.equityInstrument : null, start_date: localDateIso(form.startDate), expiry_date: localDateIso(form.expiryDate), } if (offerId) body.offer_id = offerId onSubmit(body) } const sendDisabled = busy || loading || !selected return ( } >
{ e.preventDefault(); submit() }}>
{offerQuery.isError && (

{friendlyAuthError(offerQuery.error, 'Could not load this offer.')}

)} {errors.candidate}
set('base', e.target.value)} /> {errors.base}
set('bonusPct', e.target.value)} /> {errors.bonusPct}
set('signingBonus', e.target.value)} />
set('equityUnits', e.target.value)} /> {errors.equityUnits}
set('startDate', e.target.value)} />
set('expiryDate', e.target.value)} />

Equity is stored as a unit count plus an instrument, so “20k RSU” is entered as 20000 and RSU. This saves a draft only. Use Send on the Offers list after you review it — that emails the candidate and moves them to Offer.

) }