HR-ATS-Portal/frontend/src/screens/Offers.jsx

804 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/* ============================================================
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="Couldnt 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>
)
}