260 lines
11 KiB
JavaScript
260 lines
11 KiB
JavaScript
import { useMemo, useState } from 'react'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
|
|
import DataTable from '../ui/DataTable'
|
|
import Modal from '../ui/Modal'
|
|
import { Avatar, Badge, FieldError, Icon, KpiCard } from '../ui/primitives'
|
|
import { useToast } from '../ui/Toast'
|
|
import { seedQuery, useSeedMutation } from '../data/seedQueries'
|
|
import { useFormState } from '../components/AuthLayout'
|
|
import { fmtDate, fmtShort, money, TODAY } from '../data/seed'
|
|
|
|
export default function Offers() {
|
|
const { toast } = useToast()
|
|
const { data: offers = [] } = useQuery(seedQuery('offers'))
|
|
const { data: candidates = [] } = useQuery(seedQuery('candidates'))
|
|
const updateOffers = useSeedMutation('offers')
|
|
|
|
const [q, setQ] = useState('')
|
|
const [status, setStatus] = useState('')
|
|
const [viewing, setViewing] = useState(null)
|
|
const [creating, setCreating] = useState(false)
|
|
|
|
const stats = useMemo(() => {
|
|
const decided = offers.filter((o) => ['Accepted', 'Declined'].includes(o.status)).length
|
|
return {
|
|
sent: offers.filter((o) => o.status !== 'Draft').length,
|
|
accepted: offers.filter((o) => o.status === 'Accepted').length,
|
|
pending: offers.filter((o) => ['Sent', 'Negotiating'].includes(o.status)).length,
|
|
rate: Math.round((offers.filter((o) => o.status === 'Accepted').length / (decided || 1)) * 100),
|
|
}
|
|
}, [offers])
|
|
|
|
const rows = useMemo(
|
|
() =>
|
|
offers.filter((o) => {
|
|
if (status && o.status !== status) return false
|
|
if (q && !(o.candidate + o.jobTitle + o.recruiter).toLowerCase().includes(q.toLowerCase())) return false
|
|
return true
|
|
}),
|
|
[offers, q, status],
|
|
)
|
|
|
|
const columns = [
|
|
{
|
|
key: 'candidate', label: 'Candidate', sortable: true,
|
|
render: (o) => (
|
|
<div className="user-cell">
|
|
<Avatar name={o.candidate} initials={o.initials} color={o.color} />
|
|
<div>
|
|
<div className="cell-primary">{o.candidate}</div>
|
|
<div className="cell-sub">{o.jobTitle}</div>
|
|
</div>
|
|
</div>
|
|
),
|
|
},
|
|
{ key: 'department', label: 'Department', sortable: true },
|
|
{ key: 'base', label: 'Base Salary', sortable: true, align: 'right', render: (o) => <b>{money(o.base)}</b> },
|
|
{ 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.getTime(), render: (o) => <span className="text-muted">{fmtShort(o.sent)}</span> },
|
|
{ key: 'status', label: 'Status', sortable: true, render: (o) => <Badge>{o.status}</Badge> },
|
|
{
|
|
key: '_a', label: 'Actions', align: 'right',
|
|
render: (o) => (
|
|
<div className="row-actions">
|
|
<button className="act-btn" data-tip="View" onClick={() => setViewing(o)}><Icon name="eye" /></button>
|
|
<button className="act-btn" data-tip="Resend" onClick={() => toast(`Offer resent to ${o.candidate}`, 'info')}><Icon name="send" /></button>
|
|
</div>
|
|
),
|
|
},
|
|
]
|
|
|
|
return (
|
|
<div className="page">
|
|
<div className="page-head">
|
|
<div>
|
|
<h1 className="page-title">Offers</h1>
|
|
<p className="page-sub">Track offer letters and acceptance</p>
|
|
</div>
|
|
<div className="page-head-actions">
|
|
<button className="btn btn-primary" onClick={() => setCreating(true)}>
|
|
<Icon name="plus" /> Create Offer
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid g-kpi mb-18">
|
|
<KpiCard label="Offers Sent" value={stats.sent} icon="send" tone="i-indigo" />
|
|
<KpiCard label="Accepted" value={stats.accepted} icon="check-circle" tone="i-green" />
|
|
<KpiCard label="Awaiting Response" value={stats.pending} icon="clock" tone="i-amber" />
|
|
<KpiCard label="Acceptance Rate" value={`${stats.rate}%`} icon="trending-up" tone="i-teal" />
|
|
</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={status} onChange={(e) => setStatus(e.target.value)}>
|
|
<option value="">All Status</option>
|
|
{['Sent', 'Accepted', 'Negotiating', 'Declined', 'Draft', 'Expired'].map((s) => <option key={s}>{s}</option>)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<DataTable columns={columns} rows={rows} pageSize={8} />
|
|
</div>
|
|
|
|
{viewing && <OfferDetail offer={viewing} onClose={() => setViewing(null)} toast={toast} />}
|
|
{creating && (
|
|
<CreateOffer
|
|
candidates={candidates}
|
|
onClose={() => setCreating(false)}
|
|
onSave={(offer) => {
|
|
updateOffers((os) => [offer, ...os])
|
|
setCreating(false)
|
|
toast('Offer sent successfully', 'success')
|
|
}}
|
|
toast={toast}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function OfferDetail({ offer: o, onClose, toast }) {
|
|
const total = o.base + Math.round((o.base * parseInt(o.bonus, 10)) / 100)
|
|
return (
|
|
<Modal
|
|
title="Offer Details"
|
|
subtitle={o.id}
|
|
size="modal-lg"
|
|
onClose={onClose}
|
|
footer={
|
|
<>
|
|
<button className="btn btn-secondary" onClick={onClose}>Close</button>
|
|
<button className="btn btn-secondary" onClick={() => toast('Offer PDF downloaded', 'info')}>
|
|
<Icon name="download" /> Download
|
|
</button>
|
|
<button className="btn btn-primary" onClick={() => { onClose(); toast('Offer resent', 'success') }}>
|
|
<Icon name="send" /> Resend Offer
|
|
</button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="flex items-center gap-12 mb-18">
|
|
<Avatar name={o.candidate} initials={o.initials} color={o.color} className="avatar-lg" />
|
|
<div>
|
|
<div className="ph-name" style={{ fontSize: 17 }}>{o.candidate}</div>
|
|
<div className="ph-role">{o.jobTitle} · {o.department}</div>
|
|
</div>
|
|
<div style={{ marginLeft: 'auto' }}><Badge>{o.status}</Badge></div>
|
|
</div>
|
|
|
|
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 18 }}>
|
|
<div className="card-body">
|
|
<div className="form-section-title" style={{ marginTop: 0 }}>Compensation Package</div>
|
|
<div className="info-grid">
|
|
<div className="info-item"><div className="il">Base Salary</div><div className="iv" style={{ fontSize: 18 }}>{money(o.base)}</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)' }}>{money(total)}</div></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="info-grid">
|
|
<div className="info-item"><div className="il">Sent On</div><div className="iv">{fmtDate(o.sent)}</div></div>
|
|
<div className="info-item"><div className="il">Expires</div><div className="iv">{fmtDate(o.expires)}</div></div>
|
|
<div className="info-item"><div className="il">Recruiter</div><div className="iv">{o.recruiter}</div></div>
|
|
<div className="info-item"><div className="il">Offer ID</div><div className="iv mono">{o.id}</div></div>
|
|
</div>
|
|
</Modal>
|
|
)
|
|
}
|
|
|
|
function CreateOffer({ candidates, onClose, onSave, toast }) {
|
|
const eligible = candidates.filter((c) => ['Interview', 'Offer'].includes(c.stage))
|
|
const form = useFormState({
|
|
candidate: eligible[0]?.name ?? '',
|
|
base: '', bonus: '10', equity: '', expires: '', notes: '',
|
|
})
|
|
|
|
function submit() {
|
|
if (!form.values.base || Number(form.values.base) <= 0) {
|
|
form.setErrors({ base: 'Required' })
|
|
toast('Enter a base salary', 'error')
|
|
return
|
|
}
|
|
const cand = candidates.find((c) => c.name === form.values.candidate) || candidates[0]
|
|
onSave({
|
|
id: `OFR-${9001 + candidates.length}`,
|
|
candidate: cand.name, candidateId: cand.id, initials: cand.initials, color: cand.color,
|
|
jobTitle: cand.jobTitle, department: cand.department, status: 'Sent',
|
|
base: Number(form.values.base),
|
|
equity: form.values.equity || '10k RSU',
|
|
bonus: `${form.values.bonus || 10}%`,
|
|
sent: new Date(TODAY),
|
|
expires: form.values.expires ? new Date(form.values.expires) : new Date('2026-07-23'),
|
|
recruiter: cand.recruiter,
|
|
})
|
|
}
|
|
|
|
return (
|
|
<Modal
|
|
title="Create Offer"
|
|
subtitle="Generate and send an offer letter"
|
|
onClose={onClose}
|
|
footer={
|
|
<>
|
|
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
|
<button className="btn btn-primary" onClick={submit}><Icon name="send" /> Send 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>
|
|
<select value={form.values.candidate} onChange={(e) => form.setField('candidate', e.target.value)}>
|
|
{eligible.map((c) => <option key={c.id}>{c.name}</option>)}
|
|
</select>
|
|
</div>
|
|
<div className="form-field">
|
|
<label>Base Salary ($) <span className="req">*</span></label>
|
|
<input
|
|
type="number" placeholder="140000"
|
|
className={form.errors.base ? 'err' : ''}
|
|
value={form.values.base}
|
|
onChange={(e) => form.setField('base', e.target.value)}
|
|
/>
|
|
<FieldError>{form.errors.base}</FieldError>
|
|
</div>
|
|
<div className="form-field">
|
|
<label>Annual Bonus (%)</label>
|
|
<input type="number" value={form.values.bonus} onChange={(e) => form.setField('bonus', e.target.value)} />
|
|
</div>
|
|
<div className="form-field">
|
|
<label>Equity (RSU)</label>
|
|
<input placeholder="20k RSU" value={form.values.equity} onChange={(e) => form.setField('equity', e.target.value)} />
|
|
</div>
|
|
<div className="form-field">
|
|
<label>Expiration Date</label>
|
|
<input type="date" value={form.values.expires} onChange={(e) => form.setField('expires', e.target.value)} />
|
|
</div>
|
|
<div className="form-field col-span-2">
|
|
<label>Notes</label>
|
|
<textarea
|
|
placeholder="Additional details for the offer…"
|
|
value={form.values.notes}
|
|
onChange={(e) => form.setField('notes', e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
)
|
|
}
|