254 lines
9.8 KiB
JavaScript
254 lines
9.8 KiB
JavaScript
import React from 'react';
|
||
import { Shield, Star, TrendingUp } from 'lucide-react';
|
||
import { Gauge, gaugeClasses } from '@mui/x-charts/Gauge';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import { getScoreBand } from '@/lib/talentHome';
|
||
import ReferencesEndorsements from '@/components/krow/ReferencesEndorsements';
|
||
|
||
const FICO_MIN = 300;
|
||
const FICO_MAX = 850;
|
||
|
||
function careerScoreFromApp(app) {
|
||
const s = Math.max(0, Math.min(100, Math.round(app?.ai_score || 0)));
|
||
return 300 + Math.round((s / 100) * 550);
|
||
}
|
||
|
||
function marketRangeFromApp(app) {
|
||
const s = Math.max(0, Math.min(100, Math.round(app?.ai_score || 0)));
|
||
const uplift = Math.max(0, Math.round((s - 70) / 5));
|
||
const low = 20 + uplift;
|
||
return `$${low}-$${low + 4}/hr`;
|
||
}
|
||
|
||
function nextTierFromApp(app) {
|
||
const s = Math.round(app?.ai_score || 0);
|
||
const yrs = app?.years_experience || 0;
|
||
if (s >= 85 || yrs >= 8) return 'Skilled';
|
||
if (s >= 60 || yrs >= 3) return 'Cross-Trained';
|
||
return 'Beginner';
|
||
}
|
||
|
||
/**
|
||
* The score, its band, and what both are worth — one block, read top to bottom.
|
||
*
|
||
* The gauge is the anchor rather than an ornament beside the numbers, so it is
|
||
* sized to be the first thing seen and carries the score inside it: label, value
|
||
* and range stacked in the arc's own empty middle, which is the only place where
|
||
* "834" and "300–850" read as one statement instead of two.
|
||
*
|
||
* Everything to the right is interpretation — where this person sits and where
|
||
* they go next — kept at a subordinate weight so the eye lands on the score
|
||
* first and travels outward.
|
||
*/
|
||
const GAUGE_W = 184;
|
||
|
||
function CareerScorePrimary({ application }) {
|
||
const score = careerScoreFromApp(application);
|
||
const band = getScoreBand(Math.round(application?.ai_score || 0));
|
||
|
||
return (
|
||
<div className="grid gap-x-7 gap-y-5 sm:grid-cols-[auto_minmax(0,1fr)] sm:items-center">
|
||
{/* Layer 1 — the primary signal */}
|
||
<div className="flex flex-col items-center gap-2.5">
|
||
<div
|
||
className="relative shrink-0"
|
||
style={{ width: GAUGE_W, height: Math.round(GAUGE_W * 0.577) }}
|
||
>
|
||
<Gauge
|
||
value={score}
|
||
valueMin={FICO_MIN}
|
||
valueMax={FICO_MAX}
|
||
startAngle={-90}
|
||
endAngle={90}
|
||
innerRadius="89%"
|
||
outerRadius="100%"
|
||
cornerRadius="50%"
|
||
text={() => null}
|
||
skipAnimation
|
||
aria-hidden="true"
|
||
sx={{
|
||
width: GAUGE_W,
|
||
height: GAUGE_W,
|
||
marginTop: `-${Math.round(GAUGE_W * 0.215)}px`,
|
||
/* Brand blue for the score it earned, a hairline-weight neutral
|
||
for the rest of the range — the same pairing the rest of the
|
||
product uses for "achieved against available". */
|
||
[`& .${gaugeClasses.valueArc}`]: { fill: '#0838E0' },
|
||
[`& .${gaugeClasses.referenceArc}`]: { fill: 'hsl(var(--border))' },
|
||
}}
|
||
/>
|
||
|
||
{/* The arc's middle is empty by construction; the score belongs in it.
|
||
Stacked with room between each line so the label never crowds the
|
||
number it is labelling. */}
|
||
<div
|
||
className="pointer-events-none absolute inset-x-0 bottom-0 flex flex-col items-center justify-end pb-1"
|
||
role="img"
|
||
aria-label={`Career score ${score} of a ${FICO_MIN} to ${FICO_MAX} range. Level: ${band.label}.`}
|
||
>
|
||
<span className="text-[9px] font-semibold uppercase leading-none tracking-[0.08em] text-ink-4">
|
||
Career score
|
||
</span>
|
||
<span className="mt-1.5 font-heading text-[32px] font-bold leading-none tabular-nums text-krow-blue">
|
||
{score}
|
||
</span>
|
||
<span className="mt-1.5 text-[10px] font-medium leading-none tabular-nums text-ink-4">
|
||
{FICO_MIN}–{FICO_MAX}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Layer 2 — the band this score falls in. A status marker, sized like
|
||
one: the colour is the band's own, carried by a dot rather than by a
|
||
filled pill the width of the column. */}
|
||
<Badge variant="outline" size="lg" className="gap-1.5">
|
||
<span
|
||
className="h-1.5 w-1.5 rounded-full"
|
||
style={{ backgroundColor: band.color }}
|
||
aria-hidden="true"
|
||
/>
|
||
{band.label}
|
||
</Badge>
|
||
</div>
|
||
|
||
{/* Layer 2 — what the score is worth, and what comes next. */}
|
||
<dl className="grid gap-x-6 gap-y-4 min-[420px]:grid-cols-2 sm:grid-cols-1 lg:grid-cols-2">
|
||
<div>
|
||
<dt className="text-[10px] font-semibold uppercase tracking-[0.06em] text-ink-4">
|
||
Potential market value
|
||
</dt>
|
||
<dd className="mt-1 font-heading text-body-lg font-bold text-ink-1">
|
||
{marketRangeFromApp(application)}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="text-[10px] font-semibold uppercase tracking-[0.06em] text-ink-4">
|
||
Next promotion
|
||
</dt>
|
||
<dd className="mt-1 font-heading text-body font-semibold text-ink-1">
|
||
{nextTierFromApp(application)}
|
||
</dd>
|
||
</div>
|
||
</dl>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const COMM_LABELS = [
|
||
{ min: 85, label: 'Excellent' },
|
||
{ min: 70, label: 'Good' },
|
||
{ min: 55, label: 'Average' },
|
||
{ min: 0, label: 'Needs Work' },
|
||
];
|
||
|
||
const commLabel = (score) => {
|
||
const v = score || 0;
|
||
return (COMM_LABELS.find((c) => v >= c.min) || COMM_LABELS[COMM_LABELS.length - 1]).label;
|
||
};
|
||
|
||
const COMPANY_COLORS = [
|
||
'bg-[#0838E0]',
|
||
'bg-[#333F48]',
|
||
'bg-[#062BAF]',
|
||
];
|
||
|
||
const colorFor = (name) => {
|
||
let hash = 0;
|
||
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||
return COMPANY_COLORS[Math.abs(hash) % COMPANY_COLORS.length];
|
||
};
|
||
|
||
const initialsFor = (name) => {
|
||
const parts = name.trim().split(/\s+/);
|
||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||
};
|
||
|
||
/**
|
||
* A supporting signal — evidence for the score, not a headline of its own.
|
||
*
|
||
* Label and value on one line, no border, no tile: five bordered boxes under the
|
||
* gauge read as five more metrics competing with it, which is exactly the
|
||
* hierarchy this card is meant to establish. Values share one ink colour so no
|
||
* single signal shouts louder than the rest.
|
||
*/
|
||
/** @param {any} props */
|
||
function SupportingSignal({ label, value, suffix = '', icon: Icon = null }) {
|
||
const shown = value != null && value !== '' ? value : '—';
|
||
return (
|
||
<div className="flex items-baseline justify-between gap-3">
|
||
<dt className="truncate text-caption text-ink-3">{label}</dt>
|
||
<dd className="flex shrink-0 items-center font-heading text-body-sm font-bold tabular-nums text-ink-1">
|
||
{Icon && <Icon className="mr-1 h-3 w-3 text-krow-yellow" fill="currentColor" aria-hidden="true" />}
|
||
<span>
|
||
{shown}
|
||
{suffix && shown !== '—' && <span className="text-caption font-semibold">{suffix}</span>}
|
||
</span>
|
||
</dd>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function WorkforceReputation({ application }) {
|
||
const breakdown = application?.score_breakdown || {};
|
||
const companies = application?.companies_worked || [];
|
||
const clientRating = application?.client_rating || 0;
|
||
|
||
const reliability = breakdown.reliability;
|
||
const attendance = breakdown.attendance_expectations;
|
||
const leadership = breakdown.leadership_expectations;
|
||
const communication = breakdown.communication_style;
|
||
const commText = communication != null ? commLabel(communication) : null;
|
||
|
||
const hasScores = [reliability, attendance, leadership, communication].some((v) => v != null);
|
||
const hasData = hasScores || companies.length > 0 || clientRating > 0 || (application?.ai_score || 0) > 0;
|
||
if (!hasData) return null;
|
||
|
||
return (
|
||
<div className="rounded-2xl border border-border bg-surface px-4 py-4">
|
||
<div className="mb-4 flex items-center gap-2">
|
||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-krow-blue">
|
||
<Shield className="h-4 w-4 text-white" strokeWidth={2.5} />
|
||
</div>
|
||
<h4 className="flex items-center gap-1.5 text-[12px] font-bold tracking-wide text-ink-1">
|
||
WORKFORCE REPUTATION
|
||
<TrendingUp className="h-3.5 w-3.5 text-krow-blue" />
|
||
</h4>
|
||
</div>
|
||
|
||
<CareerScorePrimary application={application} />
|
||
|
||
{/* Layer 3 — supporting evidence. A hairline is enough to separate it from
|
||
the score; another container would make it a second card. */}
|
||
<dl className="mt-4 grid grid-cols-1 gap-x-8 gap-y-2 border-t border-border pt-3 min-[420px]:grid-cols-2 lg:grid-cols-3">
|
||
<SupportingSignal label="Reliability" value={reliability} suffix="%" />
|
||
<SupportingSignal label="Attendance" value={attendance} suffix="%" />
|
||
<SupportingSignal label="Leadership" value={leadership} suffix="%" />
|
||
<SupportingSignal label="Communication" value={commText} />
|
||
<SupportingSignal
|
||
label="Client rating"
|
||
value={clientRating > 0 ? clientRating.toFixed(1) : null}
|
||
icon={Star}
|
||
/>
|
||
</dl>
|
||
|
||
{companies.length > 0 && (
|
||
<div className="mt-3 border-t border-border pt-3">
|
||
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-2">WORKED</p>
|
||
<div className="flex flex-wrap items-center gap-2.5">
|
||
{companies.map((c, i) => (
|
||
<div key={i} className="flex items-center gap-2">
|
||
<div className={`w-7 h-7 rounded-lg ${colorFor(c)} flex items-center justify-center text-white text-[11px] font-bold shadow-sm`}>
|
||
{initialsFor(c)}
|
||
</div>
|
||
<span className="text-[12px] font-semibold text-[#374151]">{c}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<ReferencesEndorsements application={application} />
|
||
</div>
|
||
);
|
||
} |