Phase 8, first batch: 56 files, plus three supporting edits outside the folder.
All 56 emit byte-identical JavaScript and the production bundle is byte-identical
to 543da9d.
This batch establishes the pattern for the remaining component work: real prop
interfaces built from actual call sites, reusing the generated entity types.
Fifty-four components now declare what they take -
`CandidateCard({ application: JobApplication, jobTitle?: string, rank: number })`,
`MatchedCandidates({ job: JobPosting, profiles: ProfileView[], ... })` - rather
than carrying a compatibility bag. Callbacks are optional because call sites
omit them; entity props are required because call sites always pass them. Where
a call site proved otherwise, the call site won: `ScheduleInterviewModal`'s
`position`, `CandidateCard`'s `jobTitle`, `TalentDetailModal`'s `matchScore` and
`matchReasons` are optional because real callers omit them.
Two view types absorb what the registry cannot describe. `ProfileView` and
`CourseView` narrow the `jsonb` columns these components read through -
`experience`, `completed_courses`, `earned_badges`, `capabilities`,
`score_breakdown`, `challenge`, `quiz` - to arrays and objects, while every
field backed by a real column keeps the registry's type. They are declared once
for the folder, and their narrowed fields stay REQUIRED: the registry has those
columns NOT NULL, and making them optional broke assignment back to the entity
where `ChallengeRunner` hands a course to a mutation.
TWO REAL DEFECTS FOUND, BOTH PRESERVED RATHER THAN FIXED:
1. `SuggestedTalent` calls `matchTalent.mutate({...}).then(...)`. React
Query's `mutate` returns `void`, so that `.then` throws at run time, and
`runMatch` is reachable from a button. `mutateAsync` is what the code
means. Swapping it turns a crash into a working feature, which is a
product change, not a migration one - so it is cast to compile and left
behaving exactly as it did. This one deserves a fix on its own terms.
2. `TalentPoolCard` renders a location row behind `profile.location &&`, but
`worker_profiles` has no `location` column - the registry has none and the
API cannot send one, so the row has never rendered. Recorded as an
optional field on `ProfileView` with a note, the same treatment as
`user.avatar_url` in Phase 7.
Also fixed, all type-only: the Web Speech API declared as the optional `Window`
members `AIInterviewModal` already feature-detects; four `new Promise<void>`
where `resolve()` takes no argument; `toast`'s options bag made optional on
every method, which callers had always omitted; five `krowHooks` query
arguments made optional, which callers had always omitted.
One automated pass was reverted rather than shipped. A local-component
annotator captured words out of the preceding JSDoc as prop names - producing
`interface FrameProps { one?: any; every?: any }` from a sentence about
"one surface, one padding, every step" - the same class of regex error as
Phase 6's. The whole folder was restored from the index and the sound steps
re-run, then local components were annotated with an index-spliced rewrite that
reads only the destructuring.
Verified: tsc 35 -> 22, set-difference showing thirteen removed and none added;
zero errors in any of the 56 files; all 56 emit byte-identical JavaScript;
production bundle byte-identical; npm test 1684/1691 with the same seven
failures; Owliver baseline 59/59; lint 0 errors; baseline artifacts untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
156 lines
6.2 KiB
TypeScript
156 lines
6.2 KiB
TypeScript
import type { ProfileView } from '../types';
|
|
import React from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { ArrowRight } from 'lucide-react';
|
|
import { getTalentScore, getMarketValue, getFactors } from '@/lib/talentHome';
|
|
import CareerScoreGauge from '@/components/krow/talent/CareerScoreGauge';
|
|
|
|
function greeting() {
|
|
const h = new Date().getHours();
|
|
if (h < 12) return 'Good morning';
|
|
if (h < 18) return 'Good afternoon';
|
|
return 'Good evening';
|
|
}
|
|
|
|
const TIERS = ['Beginner', 'Cross-Trained', 'Skilled'];
|
|
function currentTierIndex(profile: Partial<ProfileView> = {}) {
|
|
const badges = (profile.earned_badges || []).length;
|
|
const shifts = profile.shifts_completed || 0;
|
|
if (badges >= 5 || shifts >= 50) return 2;
|
|
if (badges >= 1 || shifts >= 10) return 1;
|
|
return 0;
|
|
}
|
|
function nextPromotion(profile = {}) {
|
|
const idx = currentTierIndex(profile);
|
|
return TIERS[Math.min(idx + 1, TIERS.length - 1)];
|
|
}
|
|
function marketRange(profile = {}) {
|
|
const uplift = getMarketValue(profile);
|
|
const low = 20 + uplift;
|
|
return `$${low}-$${low + 4}/hr`;
|
|
}
|
|
|
|
/** Props for the local `Field` helper in this file. Optional throughout:
|
|
the JavaScript validated none of them and callers omit freely. */
|
|
interface FieldProps {
|
|
label?: any;
|
|
value?: any;
|
|
sub?: any;
|
|
accent?: any;
|
|
}
|
|
|
|
function Field({ label, value, sub = '', accent = false }: FieldProps) {
|
|
return (
|
|
<div>
|
|
<p className="text-[11px] uppercase tracking-wide text-[#6B7280]">{label}</p>
|
|
<div className="flex items-baseline gap-2">
|
|
<p className={`text-[18px] font-semibold ${accent || 'text-[#0F172A]'}`}>{value}</p>
|
|
{sub && <span className="text-[12px] font-medium text-[#0838E0]">{sub}</span>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function statusFor(v) {
|
|
if (v >= 80) return { txt: 'Excellent', color: 'text-[#16A34A]', dot: 'bg-[#16A34A]' };
|
|
if (v >= 60) return { txt: 'Good', color: 'text-[#0838E0]', dot: 'bg-[#0838E0]' };
|
|
if (v >= 40) return { txt: 'Fair', color: 'text-[#D97706]', dot: 'bg-[#D97706]' };
|
|
if (v > 0) return { txt: 'Poor', color: 'text-[#DC2626]', dot: 'bg-[#DC2626]' };
|
|
return { txt: 'No data', color: 'text-[#9CA3AF]', dot: 'bg-[#D1D5DB]' };
|
|
}
|
|
|
|
/** Props for the local `Factor` helper in this file. Optional throughout:
|
|
the JavaScript validated none of them and callers omit freely. */
|
|
interface FactorProps {
|
|
label?: any;
|
|
value?: any;
|
|
weight?: any;
|
|
}
|
|
|
|
function Factor({ label, value, weight }: FactorProps) {
|
|
const s = statusFor(value);
|
|
return (
|
|
<div className="flex items-center gap-3 py-2">
|
|
<span className={`w-2 h-2 rounded-full shrink-0 ${s.dot}`} />
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[13px] font-medium text-[#0F172A] truncate">{label}</span>
|
|
<span className="text-[11px] text-[#9CA3AF]">{weight}% weight</span>
|
|
</div>
|
|
<div className="h-1.5 bg-[#F3F4F6] rounded-full mt-1.5 overflow-hidden">
|
|
<div className="h-full bg-[#93C5FD] rounded-full" style={{ width: `${value}%` }} />
|
|
</div>
|
|
</div>
|
|
<span className={`text-[12px] font-semibold w-20 text-right ${s.color}`}>{s.txt}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface TalentHeroProps {
|
|
profile: ProfileView;
|
|
/**
|
|
* Passed by `EmployeeDashboard`, ignored by this component.
|
|
*
|
|
* The signature destructures `_jobRecs` — the underscore marking it
|
|
* deliberately unused — so the prop the caller sends never binds to anything.
|
|
* Declared here because the caller does send it, and an excess-property error
|
|
* would be TypeScript objecting to a prop JavaScript simply dropped.
|
|
*/
|
|
jobRecs?: any;
|
|
_jobRecs?: any;
|
|
nextCourse?: any;
|
|
openOwliver?: any;
|
|
}
|
|
|
|
export default function TalentHero({ profile, _jobRecs = [], nextCourse = null, openOwliver = null }: TalentHeroProps) {
|
|
const score = getTalentScore(profile);
|
|
const factors = getFactors(profile);
|
|
const first = (profile.full_name || 'there').split(' ')[0];
|
|
const incomplete = score === 0 && !(profile.skills || []).length;
|
|
|
|
const missionGain = nextCourse ? Math.max(1, Math.round((nextCourse.xp || 100) / 20)) : 0;
|
|
const mission = nextCourse
|
|
? { label: nextCourse.title, sub: `+${missionGain} Score`, to: `/university/${nextCourse.id}` }
|
|
: incomplete
|
|
? { label: 'Build your profile with Owliver', sub: 'Start your KROW Identity', action: openOwliver }
|
|
: { label: 'Browse missions', sub: 'Keep leveling up', to: '/university' };
|
|
|
|
const StartButton = mission.to ? (
|
|
<Link to={mission.to} className="inline-flex items-center gap-2 bg-[#0838E0] text-white text-[13px] font-bold px-5 py-2.5 rounded-full hover:bg-[#062BAF] transition-colors">
|
|
START NOW <ArrowRight className="w-4 h-4" />
|
|
</Link>
|
|
) : (
|
|
<button onClick={mission.action} className="inline-flex items-center gap-2 bg-[#0838E0] text-white text-[13px] font-bold px-5 py-2.5 rounded-full hover:bg-[#062BAF] transition-colors">
|
|
START NOW <ArrowRight className="w-4 h-4" />
|
|
</button>
|
|
);
|
|
|
|
return (
|
|
<div className="relative rounded-2xl bg-white border border-[#E5E7EB] p-6 sm:p-8 space-y-6">
|
|
<h1 className="text-[24px] font-bold text-[#0F172A] tracking-tight">{greeting()}, {first}</h1>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5 items-center">
|
|
<CareerScoreGauge profile={profile} />
|
|
<div className="space-y-4 sm:col-span-2">
|
|
<Field label="Potential Market Value" value={marketRange(profile)} />
|
|
<Field label="Next Promotion" value={nextPromotion(profile)} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pt-4 border-t border-[#E5E7EB]">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<h2 className="text-[15px] font-bold text-[#0F172A]">What's Driving Your Score</h2>
|
|
<span className="text-[11px] text-[#9CA3AF]">Updated this month</span>
|
|
</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-8">
|
|
{factors.map((f) => <Factor key={f.key} label={f.label} value={f.value} weight={f.weight} />)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pt-4 border-t border-[#E5E7EB] space-y-3">
|
|
<Field label="Today" value={mission.label} sub={mission.sub} />
|
|
<div>{StartButton}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |