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:
138
src/components/krow/ActivityLogTable.tsx
Normal file
138
src/components/krow/ActivityLogTable.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import type { UserActivity } from '@/types/entities';
|
||||
import React, { useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
const EVENT_LABELS = {
|
||||
signup: 'Sign Up',
|
||||
login: 'Login',
|
||||
logout: 'Logout',
|
||||
create_position: 'Create Position',
|
||||
screen_candidate: 'Screen Candidate',
|
||||
hire_candidate: 'Hire Candidate',
|
||||
assign_employee: 'Assign Employee',
|
||||
apply_job: 'Apply to Job',
|
||||
start_interview: 'Start Interview',
|
||||
};
|
||||
|
||||
const EVENT_COLORS = {
|
||||
signup: 'bg-[#EEF3FE] text-[#1E40AF]',
|
||||
login: 'bg-[#ECFDF5] text-[#065F46]',
|
||||
logout: 'bg-[#F3F4F6] text-[#374151]',
|
||||
create_position: 'bg-[#F5F3FF] text-[#5B21B6]',
|
||||
screen_candidate: 'bg-[#FFFBEB] text-[#92400E]',
|
||||
hire_candidate: 'bg-[#ECFDF5] text-[#065F46]',
|
||||
assign_employee: 'bg-[#F0F9FF] text-[#075985]',
|
||||
apply_job: 'bg-[#EEF3FE] text-[#1E40AF]',
|
||||
start_interview: 'bg-[#FEF2F2] text-[#991B1B]',
|
||||
};
|
||||
|
||||
function formatTime(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
interface ActivityLogTableProps {
|
||||
activities: UserActivity[];
|
||||
}
|
||||
|
||||
export default function ActivityLogTable({ activities }: ActivityLogTableProps) {
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filtered = activities.filter((a) => {
|
||||
if (filter !== 'all' && a.event_type !== filter) return false;
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
return (
|
||||
(a.user_email || '').toLowerCase().includes(q) ||
|
||||
(a.user_name || '').toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const eventTypes = ['all', ...Object.keys(EVENT_LABELS)];
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-[#E5E7EB] overflow-hidden">
|
||||
<div className="p-4 border-b border-[#E5E7EB] flex flex-col sm:flex-row gap-3 sm:items-center justify-between">
|
||||
<h3 className="text-[14px] font-semibold text-[#111827]">Activity Log</h3>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[#9CA3AF]" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search user..."
|
||||
className="pl-8 pr-3 h-8 text-[12px] rounded-md border border-[#E5E7EB] focus:border-[#2563EB] focus:outline-none w-40"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="h-8 text-[12px] rounded-md border border-[#E5E7EB] bg-white px-2 focus:border-[#2563EB] focus:outline-none"
|
||||
>
|
||||
{eventTypes.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t === 'all' ? 'All Events' : EVENT_LABELS[t]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[40rem] text-[13px]">
|
||||
<thead className="bg-[#F9FAFB] text-[#6B7280]">
|
||||
<tr>
|
||||
<th className="text-left font-medium px-4 py-2.5">Event</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">User</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Role</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Details</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#F3F4F6]">
|
||||
{filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center py-8 text-[#9CA3AF]">
|
||||
No activity found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filtered.slice(0, 50).map((a) => (
|
||||
<tr key={a.id} className="hover:bg-[#F9FAFB]">
|
||||
<td className="px-4 py-2.5">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-full text-[11px] font-medium ${
|
||||
EVENT_COLORS[a.event_type] || 'bg-[#F3F4F6] text-[#374151]'
|
||||
}`}
|
||||
>
|
||||
{EVENT_LABELS[a.event_type] || a.event_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="font-medium text-[#111827]">{a.user_name || '—'}</div>
|
||||
<div className="text-[11px] text-[#9CA3AF]">{a.user_email}</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 capitalize text-[#6B7280]">{a.account_type || '—'}</td>
|
||||
<td className="px-4 py-2.5 text-[#6B7280] max-w-[200px] truncate">
|
||||
{a.details || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-[#6B7280] whitespace-nowrap">
|
||||
{formatTime(a.created_date)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user