174 lines
6.0 KiB
JavaScript
174 lines
6.0 KiB
JavaScript
/* ============================================================
|
||
primitives.jsx — Avatar, Badge, ScoreChip, ProgressBar, EmptyState, KpiCard.
|
||
|
||
Ported one-for-one from js/ui.js. Every class name is preserved verbatim so
|
||
css/styles.css keeps matching without a single selector change (ADR 0013 §3).
|
||
============================================================ */
|
||
|
||
import Icon from './icons'
|
||
import Chart from './Chart'
|
||
import { avatarColor, initials as initialsOf } from '../data/seed'
|
||
|
||
export function Avatar({ name = '', initials, color, className = '' }) {
|
||
const bg = color || avatarColor(name || '')
|
||
const text = initials || initialsOf(name || '?')
|
||
return (
|
||
<span className={`avatar ${className}`} style={{ background: bg }}>
|
||
{text}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
export function AvatarStack({ names = [], max = 3 }) {
|
||
const shown = names.slice(0, max)
|
||
const extra = names.length - max
|
||
return (
|
||
<div className="avatar-stack">
|
||
{shown.map((n, i) => (
|
||
<Avatar key={`${n}-${i}`} name={n} />
|
||
))}
|
||
{extra > 0 && <span className="more-count">+{extra}</span>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// The 30-entry status -> class map from js/ui.js:73-81, verbatim.
|
||
export const STATUS_CLASS = {
|
||
Open: 'b-green', Closed: 'b-gray', 'On Hold': 'b-amber', Draft: 'b-blue',
|
||
Shortlist: 'b-blue', Screening: 'b-purple', Assessment: 'b-amber', Interview: 'b-indigo',
|
||
Offer: 'b-teal', Hired: 'b-green', Rejected: 'b-red',
|
||
Scheduled: 'b-blue', Completed: 'b-green', Cancelled: 'b-red', 'No Show': 'b-amber',
|
||
Sent: 'b-blue', Accepted: 'b-green', Negotiating: 'b-amber', Declined: 'b-red', Expired: 'b-gray',
|
||
'In Progress': 'b-amber', Pending: 'b-gray', Active: 'b-green', Invited: 'b-amber',
|
||
'Strong Hire': 'b-green', Hire: 'b-teal', 'Lean Hire': 'b-amber', 'No Hire': 'b-red',
|
||
}
|
||
|
||
export function Badge({ children, className }) {
|
||
const cls = className || STATUS_CLASS[children] || 'b-gray'
|
||
return <span className={`badge ${cls}`}>{children}</span>
|
||
}
|
||
|
||
export function ScoreChip({ score }) {
|
||
// Theme tokens, not fixed hex — the old greens/blues dropped to ~2.6:1 on dark cards.
|
||
const color =
|
||
score >= 85 ? 'var(--success)'
|
||
: score >= 70 ? 'var(--warning)'
|
||
: score >= 55 ? 'var(--info)' : 'var(--danger)'
|
||
return (
|
||
<span className="score">
|
||
<span className="score-ring" style={{ '--pct': score, '--sc-color': color }}>
|
||
<span style={{ color }}>{score}</span>
|
||
</span>
|
||
</span>
|
||
)
|
||
}
|
||
|
||
export function ProgressBar({ pct, className }) {
|
||
const c = pct >= 80 ? 'green' : pct >= 50 ? '' : pct >= 30 ? 'amber' : 'red'
|
||
return (
|
||
<div className="pbar">
|
||
<div className={`pbar-fill ${className || c}`} style={{ width: `${pct}%` }} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function EmptyState({ icon = 'search', title = 'No results found', children }) {
|
||
return (
|
||
<div className="empty-state">
|
||
<Icon name={icon} />
|
||
<h3>{title}</h3>
|
||
<p>{children || 'Try adjusting your filters or search.'}</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** Trend chip: `dir` is 'up' | 'down' | 'flat', matching js/dashboard.js:21-26. */
|
||
export function Trend({ dir, arrow, children }) {
|
||
if (dir === 'flat') return <span className="trend trend-flat">{children}</span>
|
||
// `dir` is goodness (colour); `arrow` is the data direction when the two
|
||
// differ — a falling time-to-hire is good (green) but the icon must point
|
||
// down, or the chip contradicts its own "-3 days" text.
|
||
const icon = arrow || dir
|
||
return (
|
||
<span className={`trend ${dir === 'up' ? 'trend-up' : 'trend-down'}`}>
|
||
<Icon name={icon === 'up' ? 'trending-up' : 'trending-down'} />
|
||
{children}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* The KPI card repeated across dashboard (8), interviews, jobboard and
|
||
* recruiterhub. Markup is exactly js/dashboard.js:27-35 so the frozen CSS
|
||
* matches: .kpi > .kpi-top > (.kpi-label + .kpi-icn), .kpi-value, .kpi-foot.
|
||
*/
|
||
export function KpiCard({ icon, tone = 'i-indigo', label, value, foot, trend, dir = 'up' }) {
|
||
return (
|
||
<div className="kpi">
|
||
<div className="kpi-top">
|
||
<span className="kpi-label">{label}</span>
|
||
<span className={`kpi-icn ${tone}`}>
|
||
<Icon name={icon} />
|
||
</span>
|
||
</div>
|
||
<div className="kpi-value">{value}</div>
|
||
{(trend || foot) && (
|
||
<div className="kpi-foot">
|
||
{trend && <Trend dir={dir}>{trend}</Trend>}
|
||
{foot && <span className="kpi-foot-text">{foot}</span>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Compact dashboard tile. Sibling of KpiCard, not a variant — Interviews,
|
||
* JobBoard and RecruiterHub keep the 40px icon card and the 4-up `.g-kpi` row.
|
||
* Sparkline args are positional (charts.js sparkline(canvas, data, color)), so
|
||
* `spark` is a number[] and `sparkColor` is a color string. A 1-element array
|
||
* divides by zero in the engine; the length guard is load-bearing.
|
||
*/
|
||
export function KpiTile({ label, value, trend, dir = 'flat', arrow, spark, sparkColor }) {
|
||
return (
|
||
<div className="kpi kpi-tile">
|
||
<span className="kpi-label">{label}</span>
|
||
<div className="kpi-value">{value}</div>
|
||
{trend && <Trend dir={dir} arrow={arrow}>{trend}</Trend>}
|
||
{spark?.length > 1 && (
|
||
<div className="kpi-spark">
|
||
<Chart type="sparkline" data={spark} options={sparkColor} height={36} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function FieldError({ children }) {
|
||
return <span className={`field-error${children ? ' show' : ''}`}>{children || ''}</span>
|
||
}
|
||
|
||
/** 1–5 star input (interview scorecards, profile rating). */
|
||
export function Stars({ value, onChange, disabled }) {
|
||
const set = (n) => { if (!disabled) onChange(n) }
|
||
return (
|
||
<div className="rating-stars">
|
||
{[1, 2, 3, 4, 5].map((n) => (
|
||
<span
|
||
key={n}
|
||
className={`rs${n <= value ? ' on' : ''}`}
|
||
onClick={() => set(n)}
|
||
role="radio"
|
||
aria-checked={n === value}
|
||
tabIndex={disabled ? -1 : 0}
|
||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); set(n) } }}
|
||
>
|
||
<Icon name="star" />
|
||
</span>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export { Icon }
|