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

@@ -39,7 +39,7 @@ const withDefaults = (options: Partial<ToastOptions> = {}): Partial<ToastOptions
});
export const toast = {
success(message, options) {
success(message: any, options?: Partial<ToastOptions>) {
return hotToast.success(message, {
duration: DURATIONS.success,
iconTheme: { primary: 'hsl(142 71% 36%)', secondary: '#FFFFFF' },
@@ -47,7 +47,7 @@ export const toast = {
});
},
error(message, options) {
error(message: any, options?: Partial<ToastOptions>) {
return hotToast.error(message, {
duration: DURATIONS.error,
iconTheme: { primary: 'hsl(0 84% 60%)', secondary: '#FFFFFF' },
@@ -55,11 +55,11 @@ export const toast = {
});
},
info(message, options) {
info(message: any, options?: Partial<ToastOptions>) {
return hotToast(message, { duration: DURATIONS.info, ...withDefaults(options) });
},
loading(message, options) {
loading(message: any, options?: Partial<ToastOptions>) {
return hotToast.loading(message, { duration: DURATIONS.loading, ...withDefaults(options) });
},
@@ -68,7 +68,7 @@ export const toast = {
* error. Preferred over a manual loading/dismiss pair, which leaks a spinner
* whenever the promise rejects.
*/
promise(promise, messages, options) {
promise(promise: any, messages: any, options?: Partial<ToastOptions>) {
return hotToast.promise(
promise,
{

View File

@@ -1,3 +1,4 @@
import type { JobApplication, JobPosting } from '@/types/entities';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
@@ -10,7 +11,15 @@ import { cn } from '@/lib/utils';
const TOTAL_SECONDS = 180;
const QUESTION_COUNT = 5;
export default function AIInterviewModal({ open, onClose, application, job, conductor = 'KROW' }) {
interface AIInterviewModalProps {
open: boolean;
onClose?: () => void;
application: JobApplication;
job: JobPosting;
conductor?: string;
}
export default function AIInterviewModal({ open, onClose, application, job, conductor = 'KROW' }: AIInterviewModalProps) {
const [messages, setMessages] = useState([]);
const [phase, setPhase] = useState('intro'); // intro | active | evaluating | done
const [isListening, setIsListening] = useState(false);
@@ -40,7 +49,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
}, [messages]);
const speak = useCallback((text) => {
return new Promise((resolve) => {
return new Promise<void>((resolve) => {
if (!ttsEnabled || !window.speechSynthesis) {
resolve();
return;
@@ -409,7 +418,7 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
{evaluation.category_scores && Object.entries(evaluation.category_scores).map(([key, val]) => (
<div key={key} className="bg-[#F9FAFB] rounded-lg p-3">
<div className="text-[11px] text-[#6B7280] capitalize">{key.replace(/_/g, ' ')}</div>
<div className="text-lg font-bold text-[#111827]">{val}/100</div>
<div className="text-lg font-bold text-[#111827]">{val as React.ReactNode}/100</div>
</div>
))}
</div>

View File

@@ -1,3 +1,4 @@
import type { UserActivity } from '@/types/entities';
import React, { useMemo } from 'react';
import {
ResponsiveContainer,
@@ -27,7 +28,11 @@ function last7Days() {
return days;
}
export default function ActivityCharts({ activities }) {
interface ActivityChartsProps {
activities: UserActivity[];
}
export default function ActivityCharts({ activities }: ActivityChartsProps) {
const timeSeries = useMemo(() => {
const days = last7Days();
return days.map((day) => {

View File

@@ -1,3 +1,4 @@
import type { UserActivity } from '@/types/entities';
import React, { useState } from 'react';
import { Search } from 'lucide-react';
@@ -36,7 +37,11 @@ function formatTime(dateStr) {
});
}
export default function ActivityLogTable({ activities }) {
interface ActivityLogTableProps {
activities: UserActivity[];
}
export default function ActivityLogTable({ activities }: ActivityLogTableProps) {
const [filter, setFilter] = useState('all');
const [search, setSearch] = useState('');

View File

@@ -1,7 +1,12 @@
import type { UserActivity } from '@/types/entities';
import React from 'react';
import { Users, LogIn, Activity, UserCheck } from 'lucide-react';
export default function ActivityMetrics({ activities }) {
interface ActivityMetricsProps {
activities: UserActivity[];
}
export default function ActivityMetrics({ activities }: ActivityMetricsProps) {
const now = Date.now();
const dayAgo = now - 24 * 60 * 60 * 1000;

View File

@@ -1,3 +1,5 @@
import type { ProfileView } from './types';
import type { JobApplication, Staff } from '@/types/entities';
import React, { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Badge, Button, EmptyState, Modal, StatusBadge, toast } from '@/components/ds';
@@ -22,7 +24,15 @@ import { remainingFor } from '@/lib/hiringRecords';
* database (`UNIQUE (job_posting_id, email)`), so positions they are already up
* for are shown as such rather than offered and then rejected.
*/
export default function AddToPositionModal({ open, onOpenChange, profile, existing = [], staff = [] }) {
interface AddToPositionModalProps {
open: boolean;
onOpenChange?: (open: boolean) => void;
profile: ProfileView;
existing?: JobApplication[];
staff?: Staff[];
}
export default function AddToPositionModal({ open, onOpenChange, profile, existing = [], staff = [] }: AddToPositionModalProps) {
const navigate = useNavigate();
const { data: postings = [], isLoading } = useJobPostings();
const createApplication = useCreateApplication();

View File

@@ -1,7 +1,14 @@
import type { AIInterview, JobApplication, Staff } from '@/types/entities';
import React from 'react';
import { Users, TrendingUp, Brain, Mic, UserCheck, Target } from 'lucide-react';
export default function AnalyticsMetrics({ applications, interviews: _interviews, staff }) {
interface AnalyticsMetricsProps {
applications: JobApplication[];
interviews: AIInterview[];
staff: Staff[];
}
export default function AnalyticsMetrics({ applications, interviews: _interviews, staff }: AnalyticsMetricsProps) {
const totalApplicants = applications.length;
const scored = applications.filter(a => a.ai_score > 0);
const avgScore = scored.length ? Math.round(scored.reduce((s, a) => s + a.ai_score, 0) / scored.length) : 0;

View File

@@ -1,3 +1,5 @@
import type { ProfileView } from './types';
import type { JobPosting } from '@/types/entities';
import React from 'react';
import { Mic, Clock, Gauge, Sparkles, Fingerprint, BadgeCheck, Briefcase, ChevronRight, AlertCircle } from 'lucide-react';
import { Image } from '@/components/ui/image';
@@ -15,7 +17,15 @@ function krowCode(profile) {
return 'KROWID';
}
export default function ApplyIdentityCard({ profile, job, onStart, submitting, onBuildIdentity }) {
interface ApplyIdentityCardProps {
profile: ProfileView;
job: JobPosting;
onStart?: () => void;
submitting: boolean;
onBuildIdentity?: () => void;
}
export default function ApplyIdentityCard({ profile, job, onStart, submitting, onBuildIdentity }: ApplyIdentityCardProps) {
const ready = profile && profile.full_name;
const completeness = Math.min(100, Math.round(profile?.profile_completion || 0));
const skills = profile?.skills || [];
@@ -131,7 +141,15 @@ export default function ApplyIdentityCard({ profile, job, onStart, submitting, o
);
}
function Stat({ label, value, icon }) {
/** Props for the local `Stat` helper in this file. Optional throughout:
the JavaScript validated none of them and callers omit freely. */
interface StatProps {
label?: any;
value?: any;
icon?: any;
}
function Stat({ label, value, icon }: StatProps) {
return (
<div className="rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] px-3 py-2.5 text-center">
<div className="inline-flex items-center justify-center text-[#1E56C1] mb-1">{icon}</div>
@@ -141,7 +159,14 @@ function Stat({ label, value, icon }) {
);
}
function Row({ label, children }) {
/** Props for the local `Row` helper in this file. Optional throughout:
the JavaScript validated none of them and callers omit freely. */
interface RowProps {
label?: any;
children?: any;
}
function Row({ label, children }: RowProps) {
return (
<div>
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-1.5">{label.toUpperCase()}</p>

View File

@@ -1,3 +1,4 @@
import type { JobApplication } from '@/types/entities';
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { cn } from '@/lib/utils';
@@ -48,7 +49,13 @@ function getScoreColor(score) {
return '#EF4444';
}
function ScoreRing({ score }) {
/** Props for the local `ScoreRing` helper in this file. Optional throughout:
the JavaScript validated none of them and callers omit freely. */
interface ScoreRingProps {
score?: any;
}
function ScoreRing({ score }: ScoreRingProps) {
const color = getScoreColor(score);
const radius = 24;
const circumference = 2 * Math.PI * radius;
@@ -67,8 +74,20 @@ function ScoreRing({ score }) {
);
}
/** @param {any} props */
export default function CandidateCard({ application, jobTitle, rank, onAction, onMessage, onCall, onSchedule, onDecline, onDelete, onHire }) {
interface CandidateCardProps {
application: JobApplication;
jobTitle?: string;
rank: number;
onAction?: (...args: any[]) => void;
onMessage?: (...args: any[]) => void;
onCall?: (...args: any[]) => void;
onSchedule?: (...args: any[]) => void;
onDecline?: (...args: any[]) => void;
onDelete?: (...args: any[]) => void;
onHire?: (...args: any[]) => void;
}
export default function CandidateCard({ application, jobTitle, rank, onAction, onMessage, onCall, onSchedule, onDecline, onDelete, onHire }: CandidateCardProps) {
const navigate = useNavigate();
const position = jobTitle || application.job_title || '';
const status = application.status || 'applied';

View File

@@ -1,3 +1,4 @@
import type { JobApplication } from '@/types/entities';
import React from 'react';
import { Award, BarChart3, Clock, ScrollText, Calendar, User, Users, MessageSquare, CalendarCheck, Dumbbell, Crown, Briefcase, Target, Shield, Check, AlertTriangle } from 'lucide-react';
import InterviewResults from '@/components/krow/InterviewResults';
@@ -21,7 +22,6 @@ const METRIC_CONFIG = [
{ key: 'employer_requirements', label: 'Req Match', Icon: Check },
];
/** @param {any} props */
function _MetricPill({ label, value, Icon }) {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-white border border-[#E5E7EB]">
@@ -32,7 +32,11 @@ function _MetricPill({ label, value, Icon }) {
);
}
export default function CandidateExpandedDetails({ application }) {
interface CandidateExpandedDetailsProps {
application: JobApplication;
}
export default function CandidateExpandedDetails({ application }: CandidateExpandedDetailsProps) {
const breakdown = application.score_breakdown || {};
const strengths = application.ai_strengths || [];
const gaps = application.ai_gaps || [];

View File

@@ -1,9 +1,15 @@
import type { JobApplication, Staff } from '@/types/entities';
import React, { useMemo } from 'react';
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Cell } from 'recharts';
const TIER_COLORS = { Skilled: '#0838E0', 'Cross-Trained': '#333F48', Beginner: '#D1E0D7' };
export default function HiringAnalytics({ applications, staff }) {
interface HiringAnalyticsProps {
applications: JobApplication[];
staff: Staff[];
}
export default function HiringAnalytics({ applications, staff }: HiringAnalyticsProps) {
const byTier = useMemo(() => {
const tiers = ['Skilled', 'Cross-Trained', 'Beginner'];
return tiers.map(tier => ({
@@ -14,7 +20,7 @@ export default function HiringAnalytics({ applications, staff }) {
}, [staff]);
const byRole = useMemo(() => {
const counts = {};
const counts: Record<string, number> = {};
staff.forEach(s => {
const role = s.role || 'Unassigned';
counts[role] = (counts[role] || 0) + 1;

View File

@@ -1,8 +1,15 @@
import type { AIInterview, JobApplication, Staff } from '@/types/entities';
import React from 'react';
import { motion } from 'framer-motion';
import { Clock, DollarSign, Award, Zap, Scale, Heart } from 'lucide-react';
export default function ImpactMetrics({ applications, interviews, staff: _staff }) {
interface ImpactMetricsProps {
applications: JobApplication[];
interviews: AIInterview[];
staff: Staff[];
}
export default function ImpactMetrics({ applications, interviews, staff: _staff }: ImpactMetricsProps) {
const hired = applications.filter(a => a.status === 'hired');
/* Named for what it is: the applications the AI actually scored. Elsewhere
in the product "screened" means the stage (status <> 'applied'), which is a

View File

@@ -1,10 +1,16 @@
import type { AIInterview, JobApplication } from '@/types/entities';
import React, { useMemo } from 'react';
import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip, Legend, BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts';
import { Mic, CheckCircle2, Clock, AlertTriangle } from 'lucide-react';
const VERDICT_COLORS = { hire: '#0838E0', maybe: '#F9E547', no: '#333F48' };
export default function InterviewAnalytics({ applications, interviews }) {
interface InterviewAnalyticsProps {
applications: JobApplication[];
interviews: AIInterview[];
}
export default function InterviewAnalytics({ applications, interviews }: InterviewAnalyticsProps) {
const stats = useMemo(() => {
const started = applications.filter(a => a.status === 'interview' || a.interview_id).length;
const completed = interviews.length;

View File

@@ -1,3 +1,4 @@
import type { JobApplication } from '@/types/entities';
import React from 'react';
import { useInterviews } from '@/lib/krowHooks';
import { MessageSquare, CheckCircle2, AlertTriangle, Star, FileText } from 'lucide-react';
@@ -32,11 +33,15 @@ function getCategoryColor(val) {
return '#EF4444';
}
export default function InterviewResults({ application }) {
interface InterviewResultsProps {
application: JobApplication;
}
export default function InterviewResults({ application }: InterviewResultsProps) {
const { data: interviews = [], isLoading } = useInterviews();
const interview = interviews
.filter((i) => i.application_id === application.id)
.sort((a, b) => new Date(b.created_date) - new Date(a.created_date))[0];
.sort((a, b) => (new Date(b.created_date) as any) - (new Date(a.created_date) as any))[0];
if (isLoading) {
return (
@@ -79,7 +84,7 @@ export default function InterviewResults({ application }) {
const color = getCategoryColor(val);
return (
<div key={key} className="bg-[#F9FAFB] rounded-xl p-2.5 flex flex-col items-center gap-1 border border-[#F3F4F6]">
<div className="text-[15px] font-bold text-[#111827]">{val}</div>
<div className="text-[15px] font-bold text-[#111827]">{val as React.ReactNode}</div>
<div className="w-full h-1.5 rounded-full bg-[#E5E7EB] overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${val}%`, background: color }} />
</div>

View File

@@ -1,10 +1,17 @@
import type { JobPosting } from '@/types/entities';
import React, { useState, useMemo } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Copy, Check, Share2, Mail, Link2, Smartphone, Users } from 'lucide-react';
import { toast } from 'react-hot-toast';
export default function InviteApplicantsModal({ open, onClose, job }) {
interface InviteApplicantsModalProps {
open: boolean;
onClose?: () => void;
job: JobPosting;
}
export default function InviteApplicantsModal({ open, onClose, job }: InviteApplicantsModalProps) {
const [copied, setCopied] = useState(false);
const [emailRecipients, setEmailRecipients] = useState('');
const [sending, setSending] = useState(false);

View File

@@ -40,7 +40,12 @@ const READING_LINE = 0.35;
* more code and less exact at the boundaries. Reads are throttled to one per
* frame and every step is a single `getBoundingClientRect`.
*/
export function Journey({ children, className }) {
interface JourneyProps {
children: React.ReactNode;
className?: string;
}
export function Journey({ children, className }: JourneyProps) {
const [active, setActive] = useState(0);
const steps = useRef(new Map());

View File

@@ -1,3 +1,4 @@
import type { JobApplication } from '@/types/entities';
import React from 'react';
import { cn } from '@/lib/utils';
@@ -15,7 +16,11 @@ function getTier(score) {
return 3;
}
export default function KROWCandidateLeaderboard({ applications }) {
interface KROWCandidateLeaderboardProps {
applications: JobApplication[];
}
export default function KROWCandidateLeaderboard({ applications }: KROWCandidateLeaderboardProps) {
const screened = applications.filter(a => a.ai_score > 0 || a.status !== 'applied');
const counts = TIERS.map((_, i) => screened.filter(a => getTier(a.ai_score || 0) === i).length);
const total = counts.reduce((a, b) => a + b, 0) || 1;

View File

@@ -1,3 +1,4 @@
import type { JobPosting } from '@/types/entities';
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { cn } from '@/lib/utils';
@@ -17,7 +18,12 @@ function getCategoryAbbr(category) {
return words.map(w => w[0]).join('').toUpperCase().slice(0, 2);
}
export default function KROWJobPostingCard({ posting, stats }) {
interface KROWJobPostingCardProps {
posting: JobPosting;
stats: any;
}
export default function KROWJobPostingCard({ posting, stats }: KROWJobPostingCardProps) {
const navigate = useNavigate();
const applied = stats?.applied ?? 0;
const screened = stats?.screened ?? 0;

View File

@@ -2,7 +2,11 @@ import { Camera, Fingerprint, Sparkles } from 'lucide-react';
// A living identity card that reflects — rather than describes — the resume-less
// KROW Identity. It fills in as the worker adds a face, skills, languages, etc.
export default function KrowIdentityCard({ form }) {
interface KrowIdentityCardProps {
form: any;
}
export default function KrowIdentityCard({ form }: KrowIdentityCardProps) {
const parts = [
{ key: 'face', filled: !!form.selfie_url },
{ key: 'name', filled: !!form.full_name.trim() },
@@ -62,7 +66,14 @@ export default function KrowIdentityCard({ form }) {
);
}
function SlotRow({ label, items }) {
/** Props for the local `SlotRow` helper in this file. Optional throughout:
the JavaScript validated none of them and callers omit freely. */
interface SlotRowProps {
label?: any;
items?: any;
}
function SlotRow({ label, items }: SlotRowProps) {
const filled = (items || []).length > 0;
return (
<div className="flex items-center gap-2 text-[11px]">

View File

@@ -1,3 +1,5 @@
import type { ProfileView, CourseView } from './types';
import type { JobApplication, JobPosting } from '@/types/entities';
import React, { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowRight, Check, GraduationCap, Minus, Sparkles, X } from 'lucide-react';
@@ -33,7 +35,13 @@ const TONE = {
};
/** One requirement, met or not, with the level on each side of the comparison. */
function RequirementRow({ line }) {
/** Props for the local `RequirementRow` helper in this file. Optional throughout:
the JavaScript validated none of them and callers omit freely. */
interface RequirementRowProps {
line?: any;
}
function RequirementRow({ line }: RequirementRowProps) {
const Icon = line.met ? Check : line.close ? Minus : X;
const tone = line.met ? 'text-[#059669]' : line.close ? 'text-[#D97706]' : 'text-[#DC2626]';
@@ -51,7 +59,18 @@ function RequirementRow({ line }) {
);
}
function CandidateRow({ row, job, onView, onInterview, busy, attaches }) {
/** Props for the local `CandidateRow` helper in this file. Optional throughout:
the JavaScript validated none of them and callers omit freely. */
interface CandidateRowProps {
row?: any;
job?: any;
onView?: any;
onInterview?: any;
busy?: any;
attaches?: any;
}
function CandidateRow({ row, job, onView, onInterview, busy, attaches }: CandidateRowProps) {
const [open, setOpen] = useState(false);
/* Identity is the candidate record; the worker profile is enrichment that
most candidates do not have. Nothing here reads a profile field without
@@ -192,7 +211,16 @@ function CandidateRow({ row, job, onView, onInterview, busy, attaches }) {
);
}
export function MatchedCandidates({ job, profiles = [], courses = [], applications = [], onInterview, busy }) {
interface MatchedCandidatesProps {
job: JobPosting;
profiles?: ProfileView[];
courses?: CourseView[];
applications?: JobApplication[];
onInterview?: (...args: any[]) => void;
busy: boolean;
}
export function MatchedCandidates({ job, profiles = [], courses = [], applications = [], onInterview, busy }: MatchedCandidatesProps) {
const navigate = useNavigate();
const [showAll, setShowAll] = useState(false);
const positionSkills = usePageSkills('positions');
@@ -241,7 +269,7 @@ export function MatchedCandidates({ job, profiles = [], courses = [], applicatio
/* A position with no skill requirements can still be matched — on the
requirements it does state — so this no longer replaces the whole section.
It is said once, above the list, and the list still renders. */
const hasSkillRequirements = Boolean(job?.skill_requirements?.length);
const hasSkillRequirements = Boolean((job?.skill_requirements as any[] | undefined)?.length);
const shown = showAll ? ranked : ranked.slice(0, 5);
@@ -286,7 +314,7 @@ export function MatchedCandidates({ job, profiles = [], courses = [], applicatio
<div className="mt-4 flex flex-wrap items-center gap-2 rounded-lg bg-[#F9FAFB] px-3 py-2">
<Sparkles className="h-3.5 w-3.5 shrink-0 text-[#0838E0]" aria-hidden="true" />
<span className="text-[12px] text-[#6B7280]">Requires</span>
{job.skill_requirements.map((req) => (
{(job.skill_requirements as any[]).map((req) => (
<span key={req.skill_id} className="rounded-full border border-[#E5E7EB] bg-white px-2 py-0.5 text-[12px] text-[#374151]">
{skillName(req.skill_id)} — {LEVEL_LABEL[req.level]}
<span className="ml-1 text-[#9CA3AF]">{req.weight}%</span>
@@ -332,7 +360,6 @@ export function MatchedCandidates({ job, profiles = [], courses = [], applicatio
* `MatchedCandidates` rather than inside it: two audiences, two components, one
* scoring function that cannot drift between them.
*/
/** @param {any} props */
export function YourSkillFit({ job, held, className = '' }) {
const match = matchPosition(job, held);
if (!match) return null;

View File

@@ -1,9 +1,18 @@
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';
export default function MessageCandidateModal({ open, onClose, application }) {
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);

View File

@@ -2,7 +2,12 @@ import React from 'react';
const OWLIVER_URL = '/owliver-avatar.png';
export default function OwliverAvatar({ className = 'w-16 h-16', rounded = 'rounded-full' }) {
interface OwliverAvatarProps {
className?: string;
rounded?: string;
}
export default function OwliverAvatar({ className = 'w-16 h-16', rounded = 'rounded-full' }: OwliverAvatarProps) {
return (
<img
src={OWLIVER_URL}

View File

@@ -6,7 +6,13 @@ import Owliver from '@/pages/Owliver';
const OWLIVER_URL = '/owliver-avatar.png';
export default function OwliverChatBubble({ open: externalOpen, onOpenChange, showTrigger = true }) {
interface OwliverChatBubbleProps {
open: boolean;
onOpenChange?: (open: boolean) => void;
showTrigger?: boolean;
}
export default function OwliverChatBubble({ open: externalOpen, onOpenChange, showTrigger = true }: OwliverChatBubbleProps) {
const [internalOpen, setInternalOpen] = useState(false);
const open = externalOpen !== undefined ? externalOpen : internalOpen;
const setOpen = (v) => {

View File

@@ -1,3 +1,4 @@
import type { JobApplication } from '@/types/entities';
import React from 'react';
import { cn } from '@/lib/utils';
@@ -9,7 +10,11 @@ const STAGES = [
{ key: 'hired', label: 'Hired', color: 'bg-[#0838E0]', text: 'text-white' },
];
export default function PipelineFunnel({ applications }) {
interface PipelineFunnelProps {
applications: JobApplication[];
}
export default function PipelineFunnel({ applications }: PipelineFunnelProps) {
const counts = STAGES.map((s) => {
if (s.key === 'ai_screened') {
return applications.filter(a => ['ai_screened', 'shortlisted', 'interview', 'hired'].includes(a.status)).length;

View File

@@ -1,3 +1,4 @@
import type { JobPosting } from '@/types/entities';
import React from 'react';
import { cn } from '@/lib/utils';
import {
@@ -36,7 +37,14 @@ import {
const NOT_SPECIFIED = 'Not specified';
/** One label/value pair. A `value` of `null` means the record states nothing. */
function InfoCell({ label, value }) {
/** Props for the local `InfoCell` helper in this file. Optional throughout:
the JavaScript validated none of them and callers omit freely. */
interface InfoCellProps {
label?: any;
value?: any;
}
function InfoCell({ label, value }: InfoCellProps) {
const specified = Boolean(value);
return (
<div className="min-w-0">
@@ -60,7 +68,12 @@ function InfoCell({ label, value }) {
* was widened for: eight short values in one column is a column of whitespace
* beside a very long scroll.
*/
export function PositionOverview({ position, className = '' }) {
interface PositionOverviewProps {
position: JobPosting;
className?: string;
}
export function PositionOverview({ position, className = '' }: PositionOverviewProps) {
/* Ordered by what each field answers, three to a row on desktop so the tiers
read as rows: who and what the role is, then where and for how much, then
what is asked of the person. */

View File

@@ -1,3 +1,4 @@
import type { Staff } from '@/types/entities';
import React, { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Star, BadgeCheck, X, Award } from 'lucide-react';
@@ -10,7 +11,14 @@ const SKILL_SUGGESTIONS = [
'Customer Service', 'Work Ethic',
];
export default function RateEndorseModal({ open, onClose, staff, reviewerName }) {
interface RateEndorseModalProps {
open: boolean;
onClose?: () => void;
staff: Staff;
reviewerName: string;
}
export default function RateEndorseModal({ open, onClose, staff, reviewerName }: RateEndorseModalProps) {
const update = useUpdateStaff();
const [rating, setRating] = useState(staff?.client_rating || 0);
const [hover, setHover] = useState(0);

View File

@@ -1,3 +1,4 @@
import type { JobApplication } from '@/types/entities';
import React, { useState } from 'react';
import { Quote, BadgeCheck, Star, ChevronDown } from 'lucide-react';
@@ -17,7 +18,8 @@ const ROLES = ['Supervisor', 'Chef', 'Manager', 'Lead', 'Owner'];
// always sees verbatim-style feedback.
function buildVoices(application) {
const companies = (application?.companies_worked || []).filter(Boolean);
const strengths = (application?.ai_strengths || []).filter(Boolean);
/* `ai_strengths` is a `jsonb` column, typed `unknown` by the registry. */
const strengths = ((application?.ai_strengths as any[]) || []).filter(Boolean);
const years = application?.years_experience || 0;
const source = companies.length > 0
? companies.slice(0, 3)
@@ -34,7 +36,13 @@ function buildVoices(application) {
});
}
function Stars({ count = 5 }) {
/** Props for the local `Stars` helper in this file. Optional throughout:
the JavaScript validated none of them and callers omit freely. */
interface StarsProps {
count?: any;
}
function Stars({ count = 5 }: StarsProps) {
return (
<div className="flex items-center gap-0.5">
{Array.from({ length: 5 }).map((_, i) => (
@@ -44,11 +52,19 @@ function Stars({ count = 5 }) {
);
}
export default function ReferencesEndorsements({ application }) {
interface ReferencesEndorsementsProps {
application: JobApplication;
}
export default function ReferencesEndorsements({ application }: ReferencesEndorsementsProps) {
const [open, setOpen] = useState(true);
const voices = buildVoices(application);
/* `ai_strengths` is `jsonb` (typed `unknown`) while `skills` is a real
`text[]`. Merged without the cast the array infers as `string[]`, which
makes the `typeof s === 'string'` guard below narrow its else branch to
`never` — and that guard exists because the jsonb half may hold objects. */
const strengths = [
...(application?.ai_strengths || []),
...((application?.ai_strengths as any[]) || []),
...(application?.skills || []),
].filter(Boolean).slice(0, 6);

View File

@@ -1,3 +1,4 @@
import type { Staff } from '@/types/entities';
import React, { useMemo } from 'react';
import { TrendingUp, TrendingDown, Clock, Activity } from 'lucide-react';
@@ -10,7 +11,11 @@ function tenureDays(hireDate, status, now) {
return Math.max(0, Math.floor((end - start) / MS_PER_DAY));
}
export default function RetentionMetrics({ staff }) {
interface RetentionMetricsProps {
staff: Staff[];
}
export default function RetentionMetrics({ staff }: RetentionMetricsProps) {
const stats = useMemo(() => {
const total = staff.length;
const active = staff.filter((s) => s.status === 'active').length;
@@ -85,7 +90,17 @@ const TONES = {
neutral: { bg: 'bg-[#F0F4F8]', text: 'text-[#1E56C1]', icon: 'bg-[#1E56C1]' },
};
function Metric({ icon, label, value, sub, tone = 'neutral' }) {
/** Props for the local `Metric` helper in this file. Optional throughout:
the JavaScript validated none of them and callers omit freely. */
interface MetricProps {
icon?: any;
label?: any;
value?: any;
sub?: any;
tone?: any;
}
function Metric({ icon, label, value, sub, tone = 'neutral' }: MetricProps) {
const t = TONES[tone] || TONES.neutral;
return (
<div className="bg-white border border-[#E5E7EB] rounded-xl p-4 flex items-start gap-3">

View File

@@ -1,3 +1,4 @@
import type { JobApplication, JobPosting } from '@/types/entities';
import React, { useMemo, useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
@@ -61,7 +62,14 @@ const ASSESSMENTS = [
const field = 'w-full mt-1.5 min-h-[60px] p-2.5 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0838E0] focus:outline-none resize-none';
export default function ScheduleInterviewModal({ open, onClose, application, position }) {
interface ScheduleInterviewModalProps {
open: boolean;
onClose?: () => void;
application: JobApplication;
position?: JobPosting;
}
export default function ScheduleInterviewModal({ open, onClose, application, position }: ScheduleInterviewModalProps) {
const { data: activity = [] } = useUserActivity();
const { data: interviews = [] } = useInterviews();

View File

@@ -1,3 +1,4 @@
import type { JobApplication } from '@/types/entities';
import React, { useMemo } from 'react';
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Cell } from 'recharts';
@@ -8,7 +9,11 @@ const TIERS = [
{ label: '80-100', range: [80, 100], color: '#0838E0' },
];
export default function ScoreDistribution({ applications }) {
interface ScoreDistributionProps {
applications: JobApplication[];
}
export default function ScoreDistribution({ applications }: ScoreDistributionProps) {
const data = useMemo(() => {
const scored = applications.filter(a => a.ai_score > 0);
return TIERS.map((tier) => ({

View File

@@ -19,7 +19,12 @@ import { LEVEL_LABEL, SKILLS, skillById } from '@/lib/skillGraph';
* Styled to match the surrounding Create Position form rather than the Admin
* design system, because that is the form this lives in.
*/
export function SkillRequirementsField({ value = [], onChange }) {
interface SkillRequirementsFieldProps {
value?: any[];
onChange?: (...args: any[]) => void;
}
export function SkillRequirementsField({ value = [], onChange }: SkillRequirementsFieldProps) {
const rows = Array.isArray(value) ? value : [];
const used = new Set(rows.map((r) => r.skill_id));
const available = SKILLS.filter((s) => !used.has(s.id));

View File

@@ -1,3 +1,4 @@
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';
@@ -31,7 +32,14 @@ const RECO_STYLES = {
Pass: 'bg-[#9CA3AF] text-white',
};
export default function SuggestedTalent({ job, onContact, onSchedule, onHire }) {
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([]);
@@ -62,7 +70,11 @@ export default function SuggestedTalent({ job, onContact, onSchedule, onHire })
const runMatch = () => {
if (profiles.length === 0) return;
matchTalent.mutate({ job, profiles }).then((res) => setMatches(res?.matches || [])).catch(() => setMatches([]));
/* 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 (

View File

@@ -1,3 +1,4 @@
import type { ProfileView } from './types';
import React from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Mail, Phone, Briefcase, Star, Award, BadgeCheck, Fingerprint, X, Calendar, UserCheck, Sparkles, Brain, MessageSquare } from 'lucide-react';
@@ -34,7 +35,14 @@ const BADGE_LEVEL_STYLES = {
platinum: 'bg-[#EEF3FE] text-[#1E40AF] border-[#BFDBFE]',
};
function Section({ label, children }) {
/** Props for the local `Section` helper in this file. Optional throughout:
the JavaScript validated none of them and callers omit freely. */
interface SectionProps {
label?: any;
children?: any;
}
function Section({ label, children }: SectionProps) {
if (!children) return null;
return (
<div>
@@ -44,7 +52,14 @@ function Section({ label, children }) {
);
}
function PillList({ items, className = 'bg-[#F0F4F8] text-[#0047AB]' }) {
/** Props for the local `PillList` helper in this file. Optional throughout:
the JavaScript validated none of them and callers omit freely. */
interface PillListProps {
items?: any;
className?: any;
}
function PillList({ items, className = 'bg-[#F0F4F8] text-[#0047AB]' }: PillListProps) {
if (!items || items.length === 0) return null;
return (
<div className="flex flex-wrap gap-1.5">
@@ -55,8 +70,19 @@ function PillList({ items, className = 'bg-[#F0F4F8] text-[#0047AB]' }) {
);
}
/** @param {any} props */
export default function TalentDetailModal({ open, onClose, profile, onContact, onSchedule, onHire, matchScore, matchReasons }) {
interface TalentDetailModalProps {
open: boolean;
onClose?: () => void;
profile: ProfileView;
onContact?: (...args: any[]) => void;
onSchedule?: (...args: any[]) => void;
onHire?: (...args: any[]) => void;
/* Optional: `SuggestedTalent` opens this modal without either. */
matchScore?: number;
matchReasons?: string[];
}
export default function TalentDetailModal({ open, onClose, profile, onContact, onSchedule, onHire, matchScore, matchReasons }: TalentDetailModalProps) {
if (!profile) return null;
const badges = profile.earned_badges || [];
const capabilities = (profile.capabilities || []).filter((c) => c.status === 'verified' || c.status === 'pending');
@@ -274,7 +300,16 @@ export default function TalentDetailModal({ open, onClose, profile, onContact, o
);
}
function Stat({ label, value, icon, valueClass = 'text-[#5D759B]' }) {
/** Props for the local `Stat` helper in this file. Optional throughout:
the JavaScript validated none of them and callers omit freely. */
interface StatProps {
label?: any;
value?: any;
icon?: any;
valueClass?: any;
}
function Stat({ label, value, icon, valueClass = 'text-[#5D759B]' }: StatProps) {
return (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-[#F0F4F8] text-[#1E56C1] shrink-0">
{icon}

View File

@@ -1,3 +1,4 @@
import type { ProfileView } from './types';
import React, { useState } from 'react';
import { cn } from '@/lib/utils';
import { Mail, Briefcase, MapPin, Star, BadgeCheck, Award, ChevronDown } from 'lucide-react';
@@ -24,7 +25,13 @@ function getPortraitSeed(name) {
return PORTRAIT_PHOTOS[h % PORTRAIT_PHOTOS.length];
}
function CareerGauge({ fico }) {
/** Props for the local `CareerGauge` helper in this file. Optional throughout:
the JavaScript validated none of them and callers omit freely. */
interface CareerGaugeProps {
fico?: any;
}
function CareerGauge({ fico }: CareerGaugeProps) {
const MIN = 300;
const MAX = 850;
const value = Math.max(MIN, Math.min(MAX, fico || MIN));
@@ -61,7 +68,13 @@ const BADGE_LEVEL_STYLES = {
platinum: 'bg-[#EEF3FE] text-[#1E40AF]',
};
export default function TalentPoolCard({ profile, onContact, onView }) {
interface TalentPoolCardProps {
profile: ProfileView;
onContact?: (...args: any[]) => void;
onView?: (...args: any[]) => void;
}
export default function TalentPoolCard({ profile, onContact, onView }: TalentPoolCardProps) {
const [expanded, setExpanded] = useState(false);
const score = profile.krow_score || 0;

View File

@@ -1,3 +1,4 @@
import type { ProfileView, CourseView } from './types';
import React from 'react';
import { Link } from 'react-router-dom';
import { Lock, ShieldCheck, Zap, Clock, ArrowRight } from 'lucide-react';
@@ -9,7 +10,15 @@ const DIFF_STYLES = {
advanced: { label: 'Advanced', bg: 'bg-[#EEF3FE]', text: 'text-[#0838E0]', border: 'border-[#DBEAFE]' },
};
export default function UniversityCourseCard({ course, completed = false, profile }) {
interface UniversityCourseCardProps {
/* `Partial`: `CourseDetail` passes a recommended next course assembled by
`recommendNextCourse`, not a full record read from the API. */
course: Partial<CourseView>;
completed?: boolean;
profile: ProfileView;
}
export default function UniversityCourseCard({ course, completed = false, profile }: UniversityCourseCardProps) {
const lock = isUnlocked(course, profile);
const diff = DIFF_STYLES[course.difficulty] || DIFF_STYLES.beginner;
const proofSkill = course.proof_skill || course.category || 'Workforce Skill';

View File

@@ -1,3 +1,5 @@
import type { ProfileView, CourseView } from './types';
import type { JobPosting } from '@/types/entities';
import React, { useMemo } from 'react';
import { motion, useReducedMotion } from 'framer-motion';
import { ArrowRight, GraduationCap, ShieldCheck } from 'lucide-react';
@@ -50,7 +52,13 @@ const STATUS_LABEL = {
*
* A skill at its top level has no path to draw and says so.
*/
function SkillPath({ state }) {
/** Props for the local `SkillPath` helper in this file. Optional throughout:
the JavaScript validated none of them and callers omit freely. */
interface SkillPathProps {
state?: any;
}
function SkillPath({ state }: SkillPathProps) {
const reduced = useReducedMotion();
const held = state.verifiedLabel;
@@ -92,7 +100,18 @@ function SkillPath({ state }) {
);
}
/** @param {any} props */
interface VerifiedSkillsProps {
pageId?: string;
profile?: ProfileView;
profiles?: ProfileView[];
courses?: CourseView[];
positions?: JobPosting[];
email?: string;
customSources?: any[];
onOpenTraining?: (...args: any[]) => void;
className?: string;
}
export function VerifiedSkills({
pageId = '',
profile = null,
@@ -103,7 +122,7 @@ export function VerifiedSkills({
customSources = [],
onOpenTraining = null,
className = '',
}) {
}: VerifiedSkillsProps) {
/* Accepts a profile or the email of one, so a candidate record — which is an
application, not a worker profile — can be matched to the person behind it. */
const worker = profile || profileForEmail(profiles, email);

View File

@@ -1,3 +1,4 @@
import type { JobApplication } from '@/types/entities';
import React from 'react';
import { Shield, Star, TrendingUp } from 'lucide-react';
import { Gauge, gaugeClasses } from '@mui/x-charts/Gauge';
@@ -42,7 +43,13 @@ function nextTierFromApp(app) {
*/
const GAUGE_W = 184;
function CareerScorePrimary({ application }) {
/** Props for the local `CareerScorePrimary` helper in this file. Optional throughout:
the JavaScript validated none of them and callers omit freely. */
interface CareerScorePrimaryProps {
application?: any;
}
function CareerScorePrimary({ application }: CareerScorePrimaryProps) {
const score = careerScoreFromApp(application);
const band = getScoreBand(Math.round(application?.ai_score || 0));
@@ -172,8 +179,16 @@ const initialsFor = (name) => {
* 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 }) {
/** Props for the local `SupportingSignal` helper in this file. Optional throughout:
the JavaScript validated none of them and callers omit freely. */
interface SupportingSignalProps {
label?: any;
value?: any;
suffix?: any;
icon?: any;
}
function SupportingSignal({ label, value, suffix = '', icon: Icon = null }: SupportingSignalProps) {
const shown = value != null && value !== '' ? value : '—';
return (
<div className="flex items-baseline justify-between gap-3">
@@ -189,8 +204,15 @@ function SupportingSignal({ label, value, suffix = '', icon: Icon = null }) {
);
}
export default function WorkforceReputation({ application }) {
const breakdown = application?.score_breakdown || {};
interface WorkforceReputationProps {
application: JobApplication;
}
export default function WorkforceReputation({ application }: WorkforceReputationProps) {
/* `score_breakdown` is a `jsonb` column, typed `unknown` by the registry.
This panel reads four named dimensions out of it; what else it holds is
still unstated. */
const breakdown: Record<string, any> = (application?.score_breakdown as Record<string, any>) || {};
const companies = application?.companies_worked || [];
const clientRating = application?.client_rating || 0;

View File

@@ -1,3 +1,4 @@
import type { ProfileView, CourseView } from '../types';
import React, { useState } from 'react';
import { Lock, Video, Camera, MessageSquare, ScanLine, XCircle, CheckCircle2, ShieldCheck, Award, Eye, ArrowRight } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -22,7 +23,13 @@ const STEPS = ['Learn', 'Demonstrate', 'Verify', 'Proof'];
const TYPE_ICON = { roleplay: MessageSquare, photo: Camera, video: Video, photo_identify: ScanLine };
const TYPE_LABEL = { roleplay: 'Chat with Owliver', photo: 'Photo Proof', video: 'Record Yourself', photo_identify: 'Identify Hazards' };
function Stepper({ active }) {
/** Props for the local `Stepper` helper in this file. Optional throughout:
the JavaScript validated none of them and callers omit freely. */
interface StepperProps {
active?: any;
}
function Stepper({ active }: StepperProps) {
return (
<div className="mb-5 flex items-center gap-1.5">
{STEPS.map((s, i) => {
@@ -55,7 +62,14 @@ function Stepper({ active }) {
}
/** The runner's shell — one surface, one padding, every step. */
function Frame({ children, className }) {
/** Props for the local `Frame` helper in this file. Optional throughout:
the JavaScript validated none of them and callers omit freely. */
interface FrameProps {
children?: any;
className?: any;
}
function Frame({ children, className }: FrameProps) {
return (
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className={cn('px-5 py-4', className)}>
{children}
@@ -63,7 +77,13 @@ function Frame({ children, className }) {
);
}
export default function ChallengeRunner({ course, profile, onPassed }) {
interface ChallengeRunnerProps {
course: CourseView;
profile: ProfileView;
onPassed?: (...args: any[]) => void;
}
export default function ChallengeRunner({ course, profile, onPassed }: ChallengeRunnerProps) {
const [step, setStep] = useState(0);
const [evaluating, setEvaluating] = useState(false);
const [result, setResult] = useState(null);
@@ -215,10 +235,10 @@ export default function ChallengeRunner({ course, profile, onPassed }) {
<div key={k}>
<div className="mb-1 flex justify-between text-caption">
<span className="capitalize text-ink-3">{k.replace(/_/g, ' ')}</span>
<span className="font-semibold tabular-nums text-ink-1">{Math.round(v)}/100</span>
<span className="font-semibold tabular-nums text-ink-1">{Math.round(v as number)}/100</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-surface-sunken">
<div className="h-full rounded-full bg-krow-blue" style={{ width: `${Math.min(100, Math.round(v))}%` }} />
<div className="h-full rounded-full bg-krow-blue" style={{ width: `${Math.min(100, Math.round(v as number))}%` }} />
</div>
</div>
))}

View File

@@ -1,3 +1,4 @@
import type { CourseView } from '../types';
import React, { useState } from 'react';
import { Trash2, Plus } from 'lucide-react';
import { Image } from '@/components/ui/image';
@@ -13,7 +14,13 @@ const PRESET_TAGS = [
];
// Interactive hazard identification — tap a kitchen photo to mark hazards.
export default function HazardIdentifier({ course, onSubmit, submitting }) {
interface HazardIdentifierProps {
course: CourseView;
onSubmit?: (...args: any[]) => void;
submitting: boolean;
}
export default function HazardIdentifier({ course, onSubmit, submitting }: HazardIdentifierProps) {
const imageUrl = course?.challenge?.image_url;
const [markers, setMarkers] = useState([]);
const [pending, setPending] = useState(null);

View File

@@ -1,10 +1,17 @@
import type { CourseView } from '../types';
import React, { useState } from 'react';
import { Loader2, Camera } from 'lucide-react';
import { base44 } from '@/api/base44Client';
import { Image } from '@/components/ui/image';
// Photo or video proof — capture/upload, then submit for AI evaluation.
export default function MediaChallenge({ course, onSubmit, submitting }) {
interface MediaChallengeProps {
course: CourseView;
onSubmit?: (...args: any[]) => void;
submitting: boolean;
}
export default function MediaChallenge({ course, onSubmit, submitting }: MediaChallengeProps) {
const type = course?.challenge?.type || 'photo';
const [mediaUrl, setMediaUrl] = useState('');
const [uploading, setUploading] = useState(false);

View File

@@ -1,3 +1,4 @@
import type { CourseView } from '../types';
import React, { useState, useRef, useEffect } from 'react';
import { Send, Loader2 } from 'lucide-react';
import { challengeFollowUp } from '@/lib/provingGround';
@@ -5,7 +6,13 @@ import OwliverAvatar from '@/components/krow/OwliverAvatar';
// AI roleplay challenge — scenario prompt, worker responds, KROW follows up,
// then the worker submits the transcript for AI evaluation.
export default function RoleplayChallenge({ course, onSubmit, submitting }) {
interface RoleplayChallengeProps {
course: CourseView;
onSubmit?: (...args: any[]) => void;
submitting: boolean;
}
export default function RoleplayChallenge({ course, onSubmit, submitting }: RoleplayChallengeProps) {
const initial = course?.challenge?.prompt || course?.description || 'Demonstrate the skill.';
const [messages, setMessages] = useState([{ role: 'ai', content: initial }]);
const [input, setInput] = useState('');

View File

@@ -3,7 +3,12 @@ import { Loader2, Video, Square, RotateCcw } from 'lucide-react';
import { base44 } from '@/api/base44Client';
// Real in-browser video recording — "record yourself washing your hands."
export default function VideoRecorder({ onSubmit, submitting }) {
interface VideoRecorderProps {
onSubmit?: (...args: any[]) => void;
submitting: boolean;
}
export default function VideoRecorder({ onSubmit, submitting }: VideoRecorderProps) {
const videoRef = useRef(null);
const mediaRecorderRef = useRef(null);
const chunksRef = useRef([]);

View File

@@ -1,8 +1,13 @@
import type { ProfileView } from '../types';
import React from 'react';
const LADDER = ['Beginner', 'Cross-Trained', 'Skilled', 'Lead', 'Manager'];
export default function CareerGrowth({ profile }) {
interface CareerGrowthProps {
profile: ProfileView;
}
export default function CareerGrowth({ profile }: CareerGrowthProps) {
const lp = Number(profile.leadership_potential) || 0;
const currentIdx = lp >= 80 ? 3 : lp >= 60 ? 2 : lp >= 40 ? 1 : 0;
return (

View File

@@ -1,3 +1,4 @@
import type { ProfileView } from '../types';
import React from 'react';
import { ShieldCheck } from 'lucide-react';
@@ -12,7 +13,11 @@ const ITEMS = [
'Background Check',
];
export default function CareerPassport({ profile }) {
interface CareerPassportProps {
profile: ProfileView;
}
export default function CareerPassport({ profile }: CareerPassportProps) {
const certs = (profile.certifications || []).map((c) => c.toLowerCase());
const skills = (profile.skills || []).map((s) => s.toLowerCase());
const badges = (profile.earned_badges || []).map((b) => (b.name || '').toLowerCase());

View File

@@ -1,3 +1,4 @@
import type { ProfileView } from '../types';
import React from 'react';
import { Info, RefreshCw } from 'lucide-react';
import { Gauge, gaugeClasses } from '@mui/x-charts/Gauge';
@@ -7,7 +8,11 @@ const FICO_MIN = 300;
const FICO_MAX = 850;
const BLUE = '#0838E0';
export default function CareerScoreGauge({ profile }) {
interface CareerScoreGaugeProps {
profile: ProfileView;
}
export default function CareerScoreGauge({ profile }: CareerScoreGaugeProps) {
const raw = getTalentScore(profile);
const score = toFICO(raw);
const band = getScoreBand(raw);

View File

@@ -1,3 +1,4 @@
import type { ProfileView } from '../types';
import React from 'react';
import { Star } from 'lucide-react';
import { getGrowth } from '@/lib/talentHome';
@@ -16,7 +17,7 @@ const COLORS = {
};
// Derives the 10 performance metrics from real profile fields, scaled 0–100.
function getMetrics(profile = {}) {
function getMetrics(profile: ProfileView | Record<string, any> = {}) {
const r = (n) => Math.max(0, Math.min(100, Math.round(Number(n) || 0)));
const client = r((Number(profile.client_rating) || 0) * 20);
const perf = r(profile.performance_score);
@@ -33,7 +34,13 @@ function getMetrics(profile = {}) {
];
}
function Card({ metric }) {
/** Props for the local `Card` helper in this file. Optional throughout:
the JavaScript validated none of them and callers omit freely. */
interface CardProps {
metric?: any;
}
function Card({ metric }: CardProps) {
const color = COLORS[metric.label] || '#2458D3';
const hasData = metric.value > 0;
let display;
@@ -54,7 +61,11 @@ function Card({ metric }) {
);
}
export default function EliteTalentScore({ profile }) {
interface EliteTalentScoreProps {
profile: ProfileView;
}
export default function EliteTalentScore({ profile }: EliteTalentScoreProps) {
const metrics = getMetrics(profile);
return (
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2">

View File

@@ -1,8 +1,13 @@
import type { ProfileView } from '../types';
import React from 'react';
import { Briefcase, Sparkles, Star, Quote } from 'lucide-react';
import { esatTag, recentJobs, clientEndorsements } from '@/lib/talentInsights';
export default function EmployerInsights({ profile }) {
interface EmployerInsightsProps {
profile: ProfileView;
}
export default function EmployerInsights({ profile }: EmployerInsightsProps) {
const esat = esatTag(profile);
const jobs = recentJobs(profile);
const endorsements = clientEndorsements(profile);

View File

@@ -1,10 +1,17 @@
import type { ProfileView } from '../types';
import React from 'react';
import { Star, Briefcase, Quote } from 'lucide-react';
import { verifiedExperience } from './VerifiedExperienceList';
export { verifiedExperience };
import { clientEndorsements } from '@/lib/talentInsights';
function Stars({ count }) {
/** Props for the local `Stars` helper in this file. Optional throughout:
the JavaScript validated none of them and callers omit freely. */
interface StarsProps {
count?: any;
}
function Stars({ count }: StarsProps) {
return (
<div className="flex items-center gap-0.5">
{Array.from({ length: 5 }).map((_, i) => (
@@ -21,7 +28,12 @@ function Stars({ count }) {
// verified experience entry (shifts, supervisor, duration) with its client
// endorsement (role/title + rating). Falls back to seeded experience so the
// block always shows 3 rows.
export default function ExperienceEndorsements({ profile, onReference }) {
interface ExperienceEndorsementsProps {
profile: ProfileView;
onReference?: (...args: any[]) => void;
}
export default function ExperienceEndorsements({ profile, onReference }: ExperienceEndorsementsProps) {
const jobs = verifiedExperience(profile);
if (jobs.length === 0) return null;

View File

@@ -1,10 +1,15 @@
import type { ProfileView } from '../types';
import React from 'react';
import { Star } from 'lucide-react';
const SEED_NETWORK = ['Luis', 'Chef Antonio'];
const SEED_ROLES = ['Supervisor', 'Chef'];
export default function ExperienceNetwork({ profile }) {
interface ExperienceNetworkProps {
profile: ProfileView;
}
export default function ExperienceNetwork({ profile }: ExperienceNetworkProps) {
const exp = (profile.experience || []).slice(0, 3);
const list = exp.length
? exp.map((e, i) => ({

View File

@@ -1,3 +1,4 @@
import type { ProfileView } from '../types';
import React, { useRef, useState } from 'react';
import html2canvas from 'html2canvas';
import { Wallet, Smartphone, Image as ImageIcon, ShieldCheck, Briefcase, Sparkles, QrCode, Download, Check, Info, RefreshCw } from 'lucide-react';
@@ -6,10 +7,10 @@ import { Gauge as MuiGauge, gaugeClasses } from '@mui/x-charts/Gauge';
import { Image } from '@/components/ui/image';
import { getTalentScore, getScoreBand, getGrowth } from '@/lib/talentHome';
const clamp = (n, min = 0, max = 100) => Math.max(min, Math.min(max, Math.round(Number(n) || 0)));
const clamp = (n: unknown, min = 0, max = 100) => Math.max(min, Math.min(max, Math.round(Number(n) || 0)));
// Employee Satisfaction — composite of attendance, performance, client & supervisor ratings
export function getESAT(profile = {}) {
export function getESAT(profile: Partial<ProfileView> = {}) {
const att = clamp(profile.attendance_score ?? 100);
const perf = clamp(profile.performance_score);
const client = ((Number(profile.client_rating) || 0) / 5) * 100;
@@ -50,7 +51,13 @@ function download(filename, content, type) {
const BLUE = '#0838E0';
function Gauge({ fico }) {
/** Props for the local `Gauge` helper in this file. Optional throughout:
the JavaScript validated none of them and callers omit freely. */
interface GaugeProps {
fico?: any;
}
function Gauge({ fico }: GaugeProps) {
return (
<div className="relative mx-auto flex items-center justify-center" style={{ width: 140, height: 80 }}>
<MuiGauge
@@ -87,7 +94,14 @@ function Gauge({ fico }) {
);
}
function FactorPill({ label, value }) {
/** Props for the local `FactorPill` helper in this file. Optional throughout:
the JavaScript validated none of them and callers omit freely. */
interface FactorPillProps {
label?: any;
value?: any;
}
function FactorPill({ label, value }: FactorPillProps) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/70 border border-[#E6ECF5] px-3 py-1.5 text-[11px] font-medium text-[#374151] backdrop-blur-sm">
<span className="text-[#9CA3AF]">{label}</span>
@@ -96,8 +110,16 @@ function FactorPill({ label, value }) {
);
}
/** @param {any} props */
function ReportRow({ icon: Icon, label, value, tone = 'neutral' }) {
/** Props for the local `ReportRow` helper in this file. Optional throughout:
the JavaScript validated none of them and callers omit freely. */
interface ReportRowProps {
icon?: any;
label?: any;
value?: any;
tone?: any;
}
function ReportRow({ icon: Icon, label, value, tone = 'neutral' }: ReportRowProps) {
const valColor = tone === 'verified' ? 'text-[#10B981]' : tone === 'amber' ? 'text-[#D97706]' : 'text-[#0F172A]';
return (
<div className="flex items-center justify-between gap-3 py-2 border-b border-white/60 last:border-0">
@@ -112,7 +134,11 @@ function ReportRow({ icon: Icon, label, value, tone = 'neutral' }) {
);
}
export default function IdentityCareerCard({ profile }) {
interface IdentityCareerCardProps {
profile: ProfileView;
}
export default function IdentityCareerCard({ profile }: IdentityCareerCardProps) {
const stackRef = useRef(null);
const [busy, setBusy] = useState(false);

View File

@@ -1,3 +1,4 @@
import type { CourseView } from '../types';
import React from 'react';
import { Link } from 'react-router-dom';
import { Clock, ArrowRight } from 'lucide-react';
@@ -10,7 +11,11 @@ function roi(course, idx) {
return { score, pay };
}
export default function IncreaseMarketValue({ courses = [] }) {
interface IncreaseMarketValueProps {
courses?: CourseView[];
}
export default function IncreaseMarketValue({ courses = [] }: IncreaseMarketValueProps) {
const list = (courses || []).filter((c) => c.status === 'active').slice(0, 3);
return (

View File

@@ -1,10 +1,16 @@
import type { ProfileView } from '../types';
import React from 'react';
import { Eye, FileText, Calendar, Clock } from 'lucide-react';
const SHIFTS = ['Morning Shift', 'Evening Shift', 'Weekend', 'Full Time'];
const STATES = ['Interested', 'Interview', 'Offer', 'Offer'];
export default function MarketOffers({ profile, jobRecs = [] }) {
interface MarketOffersProps {
profile: ProfileView;
jobRecs?: any;
}
export default function MarketOffers({ profile, jobRecs = [] }: MarketOffersProps) {
const companies = (jobRecs || []).slice(0, 3).map(r => (r.title || 'Company').split(' ')[0]);
const availability = (profile.availability || [])[0] || 'Available This Week';
const offers = (jobRecs || []).slice(0, 4);

View File

@@ -17,7 +17,13 @@ import { Briefcase, MapPin, Wallet, Sparkles, Compass, ArrowRight } from 'lucide
* needs — id, title, role_category, location, pay_range_min/max, status, match —
* and never `job.company`.
*/
function OpportunityCard({ rec }) {
/** Props for the local `OpportunityCard` helper in this file. Optional throughout:
the JavaScript validated none of them and callers omit freely. */
interface OpportunityCardProps {
rec?: any;
}
function OpportunityCard({ rec }: OpportunityCardProps) {
const navigate = useNavigate();
const { job, match } = rec;
const pay = job.pay_range_min && job.pay_range_max ? `$${job.pay_range_min}–$${job.pay_range_max}/hr` : null;
@@ -71,7 +77,11 @@ function OpportunityCard({ rec }) {
);
}
export default function Opportunities({ jobRecs = [] }) {
interface OpportunitiesProps {
jobRecs?: any;
}
export default function Opportunities({ jobRecs = [] }: OpportunitiesProps) {
return (
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6 sm:p-8">
<div className="flex items-center justify-between mb-5">

View File

@@ -1,3 +1,4 @@
import type { ProfileView } from '../types';
import React from 'react';
import { Link } from 'react-router-dom';
import { ArrowRight } from 'lucide-react';
@@ -12,7 +13,7 @@ function greeting() {
}
const TIERS = ['Beginner', 'Cross-Trained', 'Skilled'];
function currentTierIndex(profile = {}) {
function currentTierIndex(profile: Partial<ProfileView> = {}) {
const badges = (profile.earned_badges || []).length;
const shifts = profile.shifts_completed || 0;
if (badges >= 5 || shifts >= 50) return 2;
@@ -29,8 +30,16 @@ function marketRange(profile = {}) {
return `$${low}-$${low + 4}/hr`;
}
/** @param {any} props */
function Field({ label, value, sub = '', accent = false }) {
/** 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>
@@ -50,7 +59,15 @@ function statusFor(v) {
return { txt: 'No data', color: 'text-[#9CA3AF]', dot: 'bg-[#D1D5DB]' };
}
function Factor({ label, value, weight }) {
/** 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">
@@ -69,8 +86,23 @@ function Factor({ label, value, weight }) {
);
}
/** @param {any} props */
export default function TalentHero({ profile, _jobRecs = [], nextCourse = null, openOwliver = null }) {
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];

View File

@@ -1,3 +1,4 @@
import type { ProfileView } from '../types';
import React from 'react';
import { Star, Briefcase } from 'lucide-react';
@@ -38,7 +39,13 @@ export function verifiedExperience(profile) {
});
}
function Stars({ count }) {
/** Props for the local `Stars` helper in this file. Optional throughout:
the JavaScript validated none of them and callers omit freely. */
interface StarsProps {
count?: any;
}
function Stars({ count }: StarsProps) {
return (
<div className="flex items-center gap-0.5">
{Array.from({ length: 5 }).map((_, i) => (
@@ -53,7 +60,12 @@ function Stars({ count }) {
// Verified Experience & Network — employer-facing list of the worker's last
// 3 jobs with time worked, a review rating, and reference / endorse actions.
export default function VerifiedExperienceList({ profile, onReference }) {
interface VerifiedExperienceListProps {
profile: ProfileView;
onReference?: (...args: any[]) => void;
}
export default function VerifiedExperienceList({ profile, onReference }: VerifiedExperienceListProps) {
const list = verifiedExperience(profile);
if (list.length === 0) return null;

View File

@@ -0,0 +1,55 @@
import type { Course, WorkerProfile } from '@/types/entities';
/**
* A worker profile as the talent components read one.
*
* Five of `WorkerProfile`'s columns are `jsonb`, which the generated registry
* types `unknown` — correctly, because nothing on the backend declares what is
* inside them. These components iterate and read through all five, so they are
* narrowed here to arrays. What the elements hold is still unstated, because it
* still is; every field backed by a real column keeps the registry's type.
*
* Declared once for the folder rather than per file: it is the same profile
* travelling through a card, a modal and a hero, and three copies of the same
* narrowing would be three things to keep in step.
*/
export type ProfileView = Omit<
WorkerProfile,
'experience' | 'completed_courses' | 'earned_badges' | 'capabilities' | 'score_breakdown'
> & {
/* Required, not optional: the registry declares these columns NOT NULL, so
making them optional here would make a `ProfileView` unassignable back to a
`WorkerProfile` — which `ChallengeRunner` does when it hands one to a
mutation. Narrowing the type is the point; loosening the presence is not. */
experience: any[];
completed_courses: any[];
earned_badges: any[];
capabilities: any[];
score_breakdown: any;
/**
* NOT a column, and deliberately recorded as such.
*
* `TalentPoolCard` renders a MapPin row behind `profile.location &&`, but
* `worker_profiles` has no `location` — the registry has no such field and
* the API cannot send one, so that row has never rendered. Typed optional so
* the dead branch keeps compiling unchanged; deleting it is a product
* decision, not a migration one. Same class as `user.avatar_url`.
*/
location?: string;
};
/**
* A course as the proving-ground components read one.
*
* `challenge`, `unlock_requirements` and `quiz` are `jsonb`; the runner reads
* `challenge.type`, `.prompt` and `.image_url` to decide which challenge UI to
* show. Narrowed to `any` rather than given a shape, because the shape is not
* declared anywhere the registry can see.
*/
export type CourseView = Omit<Course, 'challenge' | 'unlock_requirements' | 'quiz'> & {
/* Required for the same reason as `ProfileView`'s: these columns are NOT NULL
in the registry, so optional here would break assignment back to `Course`. */
challenge: any;
unlock_requirements: any;
quiz: any;
};