/* ============================================================
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) => (
setViewing(o)}>
send.mutate(sendPayloadFromOffer(o))}
>
),
},
]
return (
setCreating({})}>
Create Offer
}
/>
{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 (
Close
{next.map((s) => (
onStatus(s)}>
Mark {OFFER_STATUS_LABEL[s]}
))}
Edit
{o.status === 'draft' || o.status === 'failed' ? 'Send Offer' : 'Resend Offer'}
>
}
>
{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 ?? '—'}
Est. Total Cash
{total != null ? money(total) : '—'}
Signing Bonus
{o.signingBonus != null ? money(o.signingBonus) : '—'}
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) : '—'}
)
}
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) => (
{
onPick(a)
setQ('')
setOpen(false)
}}
>
{a.name || a.email || 'Candidate'}
{a.job_title ? ` — ${a.job_title}` : ''}
{[formatRole(a.stage || a.application_status || ''), SOURCE_LABEL[a.source] || a.source]
.filter(Boolean)
.join(' · ')}
))}
)}
)
}
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 (
Cancel
{busy ? 'Saving…' : 'Save Offer'}
>
}
>
)
}