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

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>
)
}