HR-ATS-Portal/frontend/src/ui/primitives.jsx

174 lines
6.0 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.

/* ============================================================
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>
}
/** 15 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 }