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:
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
@@ -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) => {
|
||||
@@ -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('');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
@@ -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 || [];
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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]">
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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}
|
||||
@@ -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) => {
|
||||
@@ -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;
|
||||
@@ -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. */
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
@@ -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();
|
||||
|
||||
@@ -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) => ({
|
||||
@@ -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));
|
||||
@@ -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 (
|
||||
@@ -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}
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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('');
|
||||
@@ -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([]);
|
||||
@@ -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 (
|
||||
@@ -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());
|
||||
@@ -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);
|
||||
@@ -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">
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => ({
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
@@ -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);
|
||||
@@ -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">
|
||||
@@ -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];
|
||||
@@ -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;
|
||||
|
||||
55
src/components/krow/types.ts
Normal file
55
src/components/krow/types.ts
Normal 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;
|
||||
};
|
||||
@@ -39,6 +39,7 @@ type LearningProfile = Omit<
|
||||
/* Narrowed for the same reason and additionally because the merged profile is
|
||||
handed to `recalcProfilePatch`, which reads through it. */
|
||||
experience?: any[];
|
||||
score_breakdown?: any;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -174,7 +175,7 @@ export function useJobPostings() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useJobPosting(id) {
|
||||
export function useJobPosting(id?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['jobPosting', id],
|
||||
queryFn: () => base44.entities.JobPosting.get(id),
|
||||
@@ -182,7 +183,7 @@ export function useJobPosting(id) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useApplications(jobPostingId) {
|
||||
export function useApplications(jobPostingId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['applications', jobPostingId || 'all'],
|
||||
queryFn: () =>
|
||||
@@ -657,7 +658,7 @@ export function useCourses() {
|
||||
return useQuery({ queryKey: ['courses'], queryFn: () => base44.entities.Course.list('-created_date', 200) });
|
||||
}
|
||||
|
||||
export function useCourse(id) {
|
||||
export function useCourse(id?: string) {
|
||||
return useQuery({ queryKey: ['course', id], queryFn: () => base44.entities.Course.get(id), enabled: !!id });
|
||||
}
|
||||
|
||||
@@ -1119,7 +1120,7 @@ export function useCompleteCourse() {
|
||||
|
||||
/* ===== Proving Ground — evidence-based challenges ===== */
|
||||
|
||||
export function useEvidenceList(workerEmail) {
|
||||
export function useEvidenceList(workerEmail?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['evidence', workerEmail || 'all'],
|
||||
queryFn: () => workerEmail
|
||||
|
||||
14
src/types/global.d.ts
vendored
14
src/types/global.d.ts
vendored
@@ -23,4 +23,18 @@ declare global {
|
||||
interface WindowEventMap {
|
||||
'krow:open-owliver': CustomEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Web Speech API, which `lib.dom` does not declare.
|
||||
*
|
||||
* `AIInterviewModal` feature-detects both spellings — the unprefixed name and
|
||||
* WebKit's — and falls back to a typed answer when neither is present. They
|
||||
* are `any` because the interface itself is not in any lib this project
|
||||
* loads, and writing one out would be inventing a browser API's shape rather
|
||||
* than describing this app's use of it.
|
||||
*/
|
||||
interface Window {
|
||||
SpeechRecognition?: any;
|
||||
webkitSpeechRecognition?: any;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user