chore(ts-migration): migrate components/krow to TypeScript with real prop types

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
This commit is contained in:
2026-09-18 11:39:58 +05:30
parent 543da9d4e7
commit e7e1e9873f
60 changed files with 750 additions and 116 deletions

View File

@@ -0,0 +1,166 @@
import type { JobPosting } from '@/types/entities';
import React, { useState, useEffect } from 'react';
import { useWorkerProfiles, useMatchTalentForJob } from '@/lib/krowHooks';
import { Sparkles, Loader2, Briefcase, Star } from 'lucide-react';
import { Image } from '@/components/ui/image';
import { getScoreBand, toFICO } from '@/lib/talentHome';
import TalentDetailModal from '@/components/krow/TalentDetailModal';
const PORTRAIT_PHOTOS = [
'photo-1500648767731-7d3514ad8b31',
'photo-1531123897727-8f129e1688ce',
'photo-1573496359142-b8d87734a5a2',
'photo-1487412720507-e7ab37603c6f',
'photo-1502685104226-ee3a97a2f2c8',
'photo-1544005313-94ddf0286df2',
'photo-1494790108377-be9c29b29330',
'photo-1438761681033-6461ffad8d80',
'photo-1517841905240-472988babdf9',
'photo-1463453091185-61582044d556',
];
function getPortraitSeed(name) {
let h = 0;
for (let i = 0; i < (name || '').length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
return PORTRAIT_PHOTOS[h % PORTRAIT_PHOTOS.length];
}
const RECO_STYLES = {
Hire: 'bg-[#16A34A] text-white',
Interview: 'bg-[#0838E0] text-white',
Maybe: 'bg-[#D97706] text-white',
Pass: 'bg-[#9CA3AF] text-white',
};
interface SuggestedTalentProps {
job: JobPosting;
onContact?: (...args: any[]) => void;
onSchedule?: (...args: any[]) => void;
onHire?: (...args: any[]) => void;
}
export default function SuggestedTalent({ job, onContact, onSchedule, onHire }: SuggestedTalentProps) {
const { data: profiles = [] } = useWorkerProfiles();
const matchTalent = useMatchTalentForJob();
const [matches, setMatches] = useState([]);
const [selected, setSelected] = useState(null);
// Auto-run matching when the job is published and there are profiles
useEffect(() => {
if (!job || profiles.length === 0) return;
if (job.status !== 'active') return;
let cancelled = false;
(async () => {
try {
const res = await matchTalent.mutateAsync({ job, profiles });
if (!cancelled && res?.matches) setMatches(res.matches);
} catch {
if (!cancelled) setMatches([]);
}
})();
return () => { cancelled = true; };
}, [job?.id, job?.status, profiles.length]);
const profileById = Object.fromEntries(profiles.map((p) => [p.id, p]));
const enriched = matches
.map((m) => ({ ...m, profile: profileById[m.profile_id] }))
.filter((m) => m.profile)
.slice(0, 6);
const runMatch = () => {
if (profiles.length === 0) return;
/* KNOWN DEFECT, preserved deliberately — see the commit message: React
Query's `mutate` returns `void`, so `.then` throws at run time.
`mutateAsync` is what this means, but swapping it turns a crash into a
working feature, which is a product change rather than a migration. */
(matchTalent.mutate({ job, profiles }) as any).then((res) => setMatches(res?.matches || [])).catch(() => setMatches([]));
};
return (
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-6">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-[#EEF3FE] flex items-center justify-center">
<Sparkles className="w-4 h-4 text-[#0838E0]" />
</div>
<h3 className="text-[15px] font-semibold text-[#111827]">Suggested Talent</h3>
</div>
{matches.length > 0 && (
<button onClick={runMatch} disabled={matchTalent.isPending} className="text-[12px] font-medium text-[#0838E0] hover:underline disabled:opacity-50">
{matchTalent.isPending ? 'Re-matching…' : 'Re-run match'}
</button>
)}
</div>
<p className="text-[12px] text-[#6B7280] mb-4">Don't wait for applications — KROW matched these talents from the pool against your job. Tap a profile to see the full KROW Identity.</p>
{matchTalent.isPending && matches.length === 0 ? (
<div className="flex items-center justify-center py-10 gap-2 text-[#6B7280]">
<Loader2 className="w-5 h-5 animate-spin" />
<span className="text-[13px]">Matching talent from the pool…</span>
</div>
) : enriched.length === 0 ? (
<div className="text-center py-10">
<p className="text-[13px] text-[#6B7280]">No strong matches yet. Try widening your requirements, or re-run the match.</p>
</div>
) : (
<div className="space-y-3">
{enriched.map((m) => {
const p = m.profile;
const band = getScoreBand(p.krow_score || 0);
return (
<div key={m.profile_id} className="flex items-start gap-3 p-3 rounded-xl border border-[#E5E7EB] hover:border-[#0838E0] hover:bg-[#FAFBFF] transition-colors cursor-pointer" onClick={() => setSelected(p)}>
<div className="w-11 h-11 rounded-full bg-[#E5E7EB] overflow-hidden shrink-0">
<Image
src={p.selfie_url || `https://images.unsplash.com/${getPortraitSeed(p.full_name)}?w=120&h=120&fit=crop&crop=faces`}
alt={p.full_name}
className="w-full h-full object-cover"
fittingType="fill"
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-[#111827] text-[14px] truncate">{p.full_name}</span>
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold ${RECO_STYLES[m.recommendation] || RECO_STYLES.Maybe}`}>{m.recommendation}</span>
<span className="px-2 py-0.5 rounded-full text-[10px] font-medium text-white" style={{ backgroundColor: band.color }}>{band.label}</span>
</div>
<div className="flex items-center gap-3 mt-1 text-[11px] text-[#6B7280] flex-wrap">
{(p.desired_position || p.current_position) && (
<span className="inline-flex items-center gap-1 font-medium text-[#0838E0]">
<Briefcase className="w-3 h-3" />{p.desired_position || p.current_position}
</span>
)}
{p.experience_years > 0 && <span>{p.experience_years}yr exp</span>}
<span className="inline-flex items-center gap-1 text-[#F59E0B] font-medium">
<Star className="w-3 h-3 fill-[#F59E0B]" />Career {toFICO(p.krow_score || 0)}
</span>
</div>
{m.reasons?.length > 0 && (
<ul className="mt-1.5 text-[11px] text-[#6B7280] list-disc list-inside space-y-0.5">
{m.reasons.slice(0, 2).map((r, i) => <li key={i}>{r}</li>)}
</ul>
)}
</div>
<div className="flex flex-col items-center justify-center shrink-0 px-2">
<span className="text-[20px] font-bold text-[#0838E0] leading-none">{Math.round(m.match_score)}</span>
<span className="text-[9px] text-[#9CA3AF] tracking-wide">MATCH</span>
</div>
</div>
);
})}
</div>
)}
{selected && (
<TalentDetailModal
open={true}
onClose={() => setSelected(null)}
profile={selected}
onContact={(profile) => { setSelected(null); onContact?.(profile); }}
onSchedule={(profile) => { setSelected(null); onSchedule?.(profile); }}
onHire={(profile) => { setSelected(null); onHire?.(profile); }}
/>
)}
</div>
);
}