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,89 @@
import type { JobApplication } from '@/types/entities';
import React, { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Mail, Phone, Send, CheckCircle2 } from 'lucide-react';
interface MessageCandidateModalProps {
open: boolean;
onClose?: () => void;
/* `Partial`: `TalentPool` opens this with a target built from a profile —
name, email and phone — rather than a stored application. */
application: Partial<JobApplication>;
}
export default function MessageCandidateModal({ open, onClose, application }: MessageCandidateModalProps) {
const [channel, setChannel] = useState('message');
const [message, setMessage] = useState('');
const [sent, setSent] = useState(false);
const handleSend = () => {
setSent(true);
setTimeout(() => {
setSent(false);
setMessage('');
onClose();
}, 1500);
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Message {application?.applicant_name}</DialogTitle>
</DialogHeader>
{sent ? (
<div className="py-8 text-center">
<div className="w-12 h-12 rounded-full bg-[#D1FAE5] flex items-center justify-center mx-auto mb-3">
<CheckCircle2 className="w-6 h-6 text-[#059669]" />
</div>
<p className="text-[14px] font-medium text-[#111827]">Message sent!</p>
</div>
) : (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => setChannel('message')}
className={`flex items-center justify-center gap-2 py-2.5 rounded-lg border text-[13px] font-medium transition-all ${
channel === 'message' ? 'border-[#0838E0] bg-[#EEF3FE] text-[#0838E0]' : 'border-[#E5E7EB] text-[#6B7280]'
}`}
>
<Mail className="w-4 h-4" /> Message
</button>
<button
onClick={() => setChannel('call')}
className={`flex items-center justify-center gap-2 py-2.5 rounded-lg border text-[13px] font-medium transition-all ${
channel === 'call' ? 'border-[#0838E0] bg-[#EEF3FE] text-[#0838E0]' : 'border-[#E5E7EB] text-[#6B7280]'
}`}
>
<Phone className="w-4 h-4" /> Call
</button>
</div>
{channel === 'call' ? (
<div className="py-6 text-center text-[13px] text-[#6B7280]">
<Phone className="w-8 h-8 text-[#0838E0] mx-auto mb-2" />
Calling {application?.phone || 'candidate'}...
</div>
) : (
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder={`Hi ${application?.applicant_name?.split(' ')[0]}, ...`}
className="w-full min-h-[100px] p-3 text-[14px] rounded-lg border border-[#E5E7EB] focus:border-[#0838E0] focus:outline-none resize-none"
/>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button onClick={handleSend} disabled={channel === 'message' && !message.trim()} className="bg-[#0838E0] hover:bg-[#062BAF]">
<Send className="w-4 h-4 mr-2" /> Send
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
);
}