804 lines
29 KiB
JavaScript
804 lines
29 KiB
JavaScript
/* ============================================================
|
||
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) => (
|
||
<div className="user-cell">
|
||
<Avatar name={o.candidate} initials={initialsOf(o.candidate)} color={avatarColor(o.candidate)} />
|
||
<div>
|
||
<div className="cell-primary">{o.candidate}</div>
|
||
<div className="cell-sub">{o.jobTitle}</div>
|
||
</div>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: 'base', label: 'Base Salary', sortable: true, align: 'right',
|
||
sortValue: (o) => o.base ?? 0,
|
||
render: (o) => (o.base != null ? <b>{money(o.base)}</b> : <span className="text-muted">—</span>),
|
||
},
|
||
{
|
||
key: 'equity', label: 'Equity',
|
||
render: (o) => <span className="text-muted">{o.equity ?? '—'}</span>,
|
||
},
|
||
{
|
||
key: 'bonus', label: 'Bonus', align: 'center',
|
||
render: (o) => <span className="text-muted">{o.bonus ?? '—'}</span>,
|
||
},
|
||
{
|
||
key: 'sent', label: 'Sent', sortable: true,
|
||
sortValue: (o) => (o.sent ? o.sent.getTime() : 0),
|
||
render: (o) => <span className="text-muted">{o.sent ? fmtShort(o.sent) : '—'}</span>,
|
||
},
|
||
{
|
||
key: 'status', label: 'Status', sortable: true,
|
||
render: (o) => <Badge className={o.statusClass}>{o.statusLabel}</Badge>,
|
||
},
|
||
{
|
||
key: '_a', label: 'Actions', align: 'right',
|
||
render: (o) => (
|
||
<div className="row-actions">
|
||
<button className="act-btn" data-tip="View" aria-label="View offer" onClick={() => setViewing(o)}>
|
||
<Icon name="eye" />
|
||
</button>
|
||
<button
|
||
className="act-btn"
|
||
data-tip={o.status === 'draft' || o.status === 'failed' ? 'Send offer' : 'Resend'}
|
||
aria-label={o.status === 'draft' || o.status === 'failed' ? 'Send offer' : 'Resend offer'}
|
||
disabled={busy || ['accepted', 'declined'].includes(o.status)}
|
||
onClick={() => send.mutate(sendPayloadFromOffer(o))}
|
||
>
|
||
<Icon name="send" />
|
||
</button>
|
||
</div>
|
||
),
|
||
},
|
||
]
|
||
|
||
return (
|
||
<div className="page">
|
||
<PageHeader
|
||
title="Offers"
|
||
sub="Track offer letters and acceptance"
|
||
actions={
|
||
<button className="btn btn-primary" onClick={() => setCreating({})}>
|
||
<Icon name="plus" /> Create Offer
|
||
</button>
|
||
}
|
||
/>
|
||
|
||
<div className="grid g-kpi mb-18">
|
||
<KpiCard label="Offers Sent" value={allQuery.isPending ? '—' : stats.sent} icon="send" tone="i-indigo" />
|
||
<KpiCard label="Accepted" value={allQuery.isPending ? '—' : stats.accepted} icon="check-circle" tone="i-green" />
|
||
<KpiCard label="Awaiting Response" value={allQuery.isPending ? '—' : stats.pending} icon="clock" tone="i-amber" />
|
||
<KpiCard
|
||
label="Acceptance Rate"
|
||
value={allQuery.isPending ? '—' : `${stats.rate}%`}
|
||
icon="trending-up"
|
||
tone="i-teal"
|
||
foot="of offers that got an answer"
|
||
/>
|
||
</div>
|
||
|
||
<div className="card">
|
||
<div className="card-body" style={{ paddingBottom: 0 }}>
|
||
<div className="toolbar">
|
||
<div className="toolbar-search">
|
||
<Icon name="search" />
|
||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search candidate or role…" />
|
||
</div>
|
||
<select className="select" value={statusLabel} onChange={(e) => setStatusLabel(e.target.value)}>
|
||
<option value="">All Status</option>
|
||
{Object.values(OFFER_STATUS_LABEL).map((s) => <option key={s}>{s}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{offersQuery.isPending && (
|
||
<div className="card-body">
|
||
<SkeletonRows rows={6} />
|
||
</div>
|
||
)}
|
||
{offersQuery.isError && (
|
||
<div className="card-body">
|
||
<EmptyState icon="alert" title="Couldn’t load offers">
|
||
{friendlyAuthError(offersQuery.error, 'The server did not return offers.')}
|
||
{' '}This screen needs the <code>offers.view</code> permission.
|
||
</EmptyState>
|
||
</div>
|
||
)}
|
||
{!offersQuery.isPending && !offersQuery.isError && (
|
||
<DataTable columns={columns} rows={rows} pageSize={50} empty="No offers match these filters." />
|
||
)}
|
||
</div>
|
||
|
||
{viewing && (
|
||
<OfferDetail
|
||
offer={viewing}
|
||
busy={busy}
|
||
onClose={() => 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 && (
|
||
<CreateOffer
|
||
offerId={creating.offerId || null}
|
||
busy={save.isPending}
|
||
onClose={() => setCreating(null)}
|
||
onSubmit={(body) => save.mutate(body)}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<Modal
|
||
title="Offer Details"
|
||
subtitle={o.jobTitle}
|
||
size="modal-lg"
|
||
onClose={onClose}
|
||
footer={
|
||
<>
|
||
<button className="btn btn-secondary" onClick={onClose}>Close</button>
|
||
{next.map((s) => (
|
||
<button key={s} className="btn btn-secondary" disabled={busy} onClick={() => onStatus(s)}>
|
||
Mark {OFFER_STATUS_LABEL[s]}
|
||
</button>
|
||
))}
|
||
<button className="btn btn-secondary" disabled={busy} onClick={onEdit}>
|
||
Edit
|
||
</button>
|
||
<button
|
||
className="btn btn-primary"
|
||
disabled={busy || ['accepted', 'declined'].includes(o.status)}
|
||
onClick={onSend}
|
||
>
|
||
<Icon name="send" /> {o.status === 'draft' || o.status === 'failed' ? 'Send Offer' : 'Resend Offer'}
|
||
</button>
|
||
</>
|
||
}
|
||
>
|
||
<div className="flex items-center gap-12 mb-18">
|
||
<Avatar
|
||
name={o.candidate}
|
||
initials={initialsOf(o.candidate)}
|
||
color={avatarColor(o.candidate)}
|
||
className="avatar-lg"
|
||
/>
|
||
<div>
|
||
<div className="ph-name" style={{ fontSize: 17 }}>{o.candidate}</div>
|
||
<div className="ph-role">{o.jobTitle}{o.email ? ` · ${o.email}` : ''}</div>
|
||
</div>
|
||
<div style={{ marginLeft: 'auto' }}><Badge className={o.statusClass}>{o.statusLabel}</Badge></div>
|
||
</div>
|
||
|
||
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 18 }}>
|
||
<div className="card-body">
|
||
<h3 className="form-section-title" style={{ marginTop: 0 }}>Compensation Package</h3>
|
||
<div className="info-grid">
|
||
<div className="info-item">
|
||
<div className="il">Base Salary</div>
|
||
<div className="iv" style={{ fontSize: 18 }}>
|
||
{o.base != null ? `${money(o.base)} / ${o.salaryPeriod}` : '—'}
|
||
</div>
|
||
</div>
|
||
<div className="info-item">
|
||
<div className="il">Annual Bonus</div>
|
||
<div className="iv" style={{ fontSize: 18 }}>{o.bonus ?? '—'}</div>
|
||
</div>
|
||
<div className="info-item">
|
||
<div className="il">Equity</div>
|
||
<div className="iv" style={{ fontSize: 18 }}>{o.equity ?? '—'}</div>
|
||
</div>
|
||
<div className="info-item">
|
||
<div className="il">Est. Total Cash</div>
|
||
<div className="iv" style={{ fontSize: 18, color: 'var(--success)' }}>
|
||
{total != null ? money(total) : '—'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="info-grid">
|
||
<div className="info-item"><div className="il">Signing Bonus</div><div className="iv">{o.signingBonus != null ? money(o.signingBonus) : '—'}</div></div>
|
||
<div className="info-item"><div className="il">Currency</div><div className="iv">{o.currency}</div></div>
|
||
<div className="info-item"><div className="il">Start Date</div><div className="iv">{o.startDate ? fmtDate(o.startDate) : '—'}</div></div>
|
||
<div className="info-item"><div className="il">Expires</div><div className="iv">{o.expiry ? fmtDate(o.expiry) : '—'}</div></div>
|
||
<div className="info-item"><div className="il">Sent On</div><div className="iv">{o.sent ? fmtDate(o.sent) : 'Not sent yet'}</div></div>
|
||
<div className="info-item"><div className="il">Responded</div><div className="iv">{o.respondedAt ? fmtDate(o.respondedAt) : '—'}</div></div>
|
||
<div className="info-item"><div className="il">Created</div><div className="iv">{o.created ? fmtDate(o.created) : '—'}</div></div>
|
||
<div className="info-item"><div className="il">Offer ID</div><div className="iv mono">{o.id}</div></div>
|
||
</div>
|
||
</Modal>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<div className={`dropdown${open ? ' open' : ''}`} ref={root} style={{ width: '100%' }}>
|
||
<input
|
||
className={error ? 'err' : ''}
|
||
value={display}
|
||
disabled={disabled}
|
||
placeholder={loading && !open ? 'Loading candidates…' : 'Search candidate by name or email…'}
|
||
autoComplete="off"
|
||
onFocus={() => {
|
||
if (disabled) return
|
||
setOpen(true)
|
||
setQ('')
|
||
}}
|
||
onChange={(e) => {
|
||
setQ(e.target.value)
|
||
setOpen(true)
|
||
}}
|
||
/>
|
||
{open && !disabled && (
|
||
<div
|
||
className="dropdown-menu"
|
||
style={{ left: 0, right: 'auto', width: '100%', minWidth: 0, maxHeight: 240, overflowY: 'auto' }}
|
||
>
|
||
{loading && (
|
||
<div className="dropdown-link" style={{ color: 'var(--text-3)' }}>Loading…</div>
|
||
)}
|
||
{!loading && options.length === 0 && (
|
||
<div className="dropdown-link" style={{ color: 'var(--text-3)' }}>{emptyCopy}</div>
|
||
)}
|
||
{!loading && options.map((a) => (
|
||
<button
|
||
type="button"
|
||
key={candidateKey(a)}
|
||
className="dropdown-link"
|
||
onClick={() => {
|
||
onPick(a)
|
||
setQ('')
|
||
setOpen(false)
|
||
}}
|
||
>
|
||
<span>
|
||
{a.name || a.email || 'Candidate'}
|
||
{a.job_title ? ` — ${a.job_title}` : ''}
|
||
<span className="cell-sub" style={{ display: 'block' }}>
|
||
{[formatRole(a.stage || a.application_status || ''), SOURCE_LABEL[a.source] || a.source]
|
||
.filter(Boolean)
|
||
.join(' · ')}
|
||
</span>
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<Modal
|
||
title={offerId ? 'Edit Offer' : 'Create Offer'}
|
||
subtitle={offerId ? 'Update compensation and save — send from the list' : 'Saved as a draft — send from the Offers list after you review it'}
|
||
onClose={onClose}
|
||
footer={
|
||
<>
|
||
<button className="btn btn-secondary" onClick={onClose} disabled={busy}>Cancel</button>
|
||
<button className="btn btn-primary" onClick={submit} disabled={sendDisabled}>
|
||
<Icon name="check" /> {busy ? 'Saving…' : 'Save Offer'}
|
||
</button>
|
||
</>
|
||
}
|
||
>
|
||
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
|
||
<div className="form-grid">
|
||
<div className="form-field col-span-2">
|
||
<label>Candidate <span className="req">*</span></label>
|
||
<CandidatePicker
|
||
selected={selected}
|
||
options={applications}
|
||
loading={loading}
|
||
disabled={Boolean(offerId)}
|
||
error={Boolean(errors.candidate)}
|
||
onPick={pickCandidate}
|
||
onSearch={setSearch}
|
||
/>
|
||
{offerQuery.isError && (
|
||
<p className="text-muted" style={{ marginTop: 8 }}>
|
||
{friendlyAuthError(offerQuery.error, 'Could not load this offer.')}
|
||
</p>
|
||
)}
|
||
<FieldError>{errors.candidate}</FieldError>
|
||
</div>
|
||
|
||
<div className="form-field">
|
||
<label>Base Salary <span className="req">*</span></label>
|
||
<input
|
||
type="number" min="0" placeholder="140000"
|
||
className={errors.base ? 'err' : ''}
|
||
value={form.base}
|
||
onChange={(e) => set('base', e.target.value)}
|
||
/>
|
||
<FieldError>{errors.base}</FieldError>
|
||
</div>
|
||
<div className="form-field">
|
||
<label>Currency</label>
|
||
<select value={form.currency} onChange={(e) => set('currency', e.target.value)}>
|
||
{['USD', 'EUR', 'GBP', 'PKR', 'AED'].map((c) => <option key={c}>{c}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="form-field">
|
||
<label>Period</label>
|
||
<select value={form.salaryPeriod} onChange={(e) => set('salaryPeriod', e.target.value)}>
|
||
<option value="year">Per year</option>
|
||
<option value="month">Per month</option>
|
||
<option value="hour">Per hour</option>
|
||
</select>
|
||
</div>
|
||
<div className="form-field">
|
||
<label>Annual Bonus (%)</label>
|
||
<input
|
||
type="number" min="0"
|
||
className={errors.bonusPct ? 'err' : ''}
|
||
value={form.bonusPct}
|
||
onChange={(e) => set('bonusPct', e.target.value)}
|
||
/>
|
||
<FieldError>{errors.bonusPct}</FieldError>
|
||
</div>
|
||
|
||
<div className="form-field">
|
||
<label>Signing Bonus</label>
|
||
<input
|
||
type="number" min="0" placeholder="10000"
|
||
value={form.signingBonus}
|
||
onChange={(e) => set('signingBonus', e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="form-field">
|
||
<label>Equity Units</label>
|
||
<input
|
||
type="number" min="0" placeholder="20000"
|
||
className={errors.equityUnits ? 'err' : ''}
|
||
value={form.equityUnits}
|
||
onChange={(e) => set('equityUnits', e.target.value)}
|
||
/>
|
||
<FieldError>{errors.equityUnits}</FieldError>
|
||
</div>
|
||
<div className="form-field">
|
||
<label>Instrument</label>
|
||
<select value={form.equityInstrument} onChange={(e) => set('equityInstrument', e.target.value)}>
|
||
{['RSU', 'ISO', 'NSO', 'Options'].map((c) => <option key={c}>{c}</option>)}
|
||
</select>
|
||
</div>
|
||
|
||
<div className="form-field">
|
||
<label>Start Date</label>
|
||
<input type="date" value={form.startDate} onChange={(e) => set('startDate', e.target.value)} />
|
||
</div>
|
||
<div className="form-field">
|
||
<label>Expiration Date</label>
|
||
<input type="date" value={form.expiryDate} onChange={(e) => set('expiryDate', e.target.value)} />
|
||
</div>
|
||
</div>
|
||
|
||
<p className="text-muted" style={{ marginTop: 16, fontSize: 13 }}>
|
||
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.
|
||
</p>
|
||
</form>
|
||
</Modal>
|
||
)
|
||
}
|