refactor(ts-migration): Phase 11 batch 11 — the data pages
The last nine admin pages. `src/` now contains no `.js` or `.jsx` at all. One error, and it was the same kind of thing the whole phase has been finding: `getBandMembers(min, max)` in `TalentPool` is called with one argument for the top band, and its own body reads `max == null || s < max`. The parameter has always been optional everywhere except the signature. The marker erases. 9/9 byte-identical, and the bundle still hashes to 74d17e2d… Three of these nine — `Analytics`, `Candidates`, `HiredHistory` — are the pages the Owliver baselines cover, and they carry six of the seven pre-existing suite failures. Those failures are unchanged and still match `ecf5e75` line for line, which is the point: if a rename had altered what these pages render, it would have altered how they fail, and it did not. The baselines are untouched, as they have been in every batch. Phase 11 is 122/122 files and eleven batches. Across all of them: type erasure 122/122 byte-identical bundle 74d17e2d… unchanged from `ecf5e75` in every batch npm test 1684/1691 with the same 7 failures, in every batch typecheck 20 errors at the start, 6 now The remaining 6 all pre-date the migration. They are no longer in JavaScript, so they are now fixable; that is the next commit, not this one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
This commit is contained in:
208
src/pages/admin/HiredHistory.tsx
Normal file
208
src/pages/admin/HiredHistory.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Mail, Phone, UserCheck } from 'lucide-react';
|
||||
import { Avatar, Badge, Drawer, StatusBadge, Surface } from '@/components/ds';
|
||||
import { useApplications, useJobPostings, useStaff } from '@/lib/krowHooks';
|
||||
import { AdminPage, SectionTitle } from '@/components/admin/PageShell';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
import { buildHires, summarise } from '@/lib/hiringRecords';
|
||||
import { UiTreeRenderer } from '@/components/ui-tree/UiTreeRenderer';
|
||||
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
|
||||
import { UiEditor } from '@/components/ui-editor/UiEditor';
|
||||
import { composePage } from '@/lib/ui/composition';
|
||||
import { DAY, RANGES, formatDate } from '@/pages/admin/hired-history/nodes';
|
||||
import '@/components/ui-tree/nodeTypes';
|
||||
import '@/pages/admin/hired-history/nodes';
|
||||
|
||||
/**
|
||||
* Admin Hired History — the record of who was hired.
|
||||
*
|
||||
* This page and Analytics used to be one component rendered twice, which is why
|
||||
* they read as duplicates. They answer different questions, and this is the one
|
||||
* about *people*: **who did we hire, for which position, when, and what
|
||||
* happened since.**
|
||||
*
|
||||
* So it is built as a record rather than as a dashboard. There is no funnel, no
|
||||
* trend line and no departmental comparison here — those are aggregate readings
|
||||
* and they live on Analytics. What is here instead is what a record needs and a
|
||||
* dashboard cannot give you: a search across every hire, filters for the fields
|
||||
* you would search by, a chronology, and a full detail view for one person.
|
||||
*
|
||||
* The figures at the top are a count of what the filters currently select, not
|
||||
* a performance summary — they answer "how much of the record am I looking at".
|
||||
* Both pages read `lib/hiringRecords.js`, so the totals cannot disagree.
|
||||
*/
|
||||
|
||||
/** One label/value row in the detail panel. */
|
||||
function Fact({ label, children }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-3 border-b border-border px-4 py-2.5 last:border-0">
|
||||
<span className="shrink-0 text-caption text-ink-4">{label}</span>
|
||||
<span className="min-w-0 text-right text-body-sm font-medium text-ink-1">{children ?? '—'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The full record for one hire — the page's own detail view. */
|
||||
function HireDetail({ hire, onClose }) {
|
||||
if (!hire) return null;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={Boolean(hire)}
|
||||
onOpenChange={(next) => { if (!next) onClose(); }}
|
||||
title={hire.name}
|
||||
description={[hire.role, hire.company].filter((v) => v && v !== '—').join(' · ') || 'Hired'}
|
||||
icon={UserCheck}
|
||||
size="default"
|
||||
>
|
||||
<div className="space-y-5 px-5 py-5 sm:px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar name={hire.name} size="lg" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-heading text-body font-semibold text-ink-1">{hire.name}</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
<StatusBadge status={hire.status || 'hired'} size="sm" />
|
||||
{hire.profile_tier && <Badge variant="neutral" size="sm">{hire.profile_tier}</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<SectionTitle title="The hire" />
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="mt-2 border border-border">
|
||||
<Fact label="Position">{hire.role}</Fact>
|
||||
<Fact label="Client">{hire.company}</Fact>
|
||||
<Fact label="Department">{hire.department}</Fact>
|
||||
<Fact label="Hired">{formatDate(hire.hire_date)}</Fact>
|
||||
<Fact label="Time to hire">{hire.timeToHire ? `${hire.timeToHire} days` : null}</Fact>
|
||||
<Fact label="AI score">{hire.score}</Fact>
|
||||
<Fact label="Client review">
|
||||
{hire.client_rating ? `${hire.client_rating} / 5` : 'Not reviewed yet'}
|
||||
</Fact>
|
||||
</Surface>
|
||||
</section>
|
||||
|
||||
{(hire.email || hire.phone) && (
|
||||
<section>
|
||||
<SectionTitle title="Contact" />
|
||||
<Surface variant="solid" radius="lg" padding="lg" elevation="xs" className="mt-2 space-y-2 border border-border">
|
||||
{hire.email && (
|
||||
<a
|
||||
href={`mailto:${hire.email}`}
|
||||
className="flex items-center gap-2 text-body-sm text-ink-2 transition-colors hover:text-krow-blue"
|
||||
>
|
||||
<Mail className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">{hire.email}</span>
|
||||
</a>
|
||||
)}
|
||||
{hire.phone && (
|
||||
<a
|
||||
href={`tel:${hire.phone}`}
|
||||
className="flex items-center gap-2 text-body-sm text-ink-2 transition-colors hover:text-krow-blue"
|
||||
>
|
||||
<Phone className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">{hire.phone}</span>
|
||||
</a>
|
||||
)}
|
||||
</Surface>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Per-record extension point, on the same terms as every other
|
||||
surface: a definition names `hired-history`, this renders it. */}
|
||||
<SkillSurface page="hired-history" placement="before-footer" context={{ candidate: hire }} />
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminHiredHistory() {
|
||||
const { data: staff = [], isLoading } = useStaff();
|
||||
const { data: applications = [] } = useApplications();
|
||||
const { data: postings = [] } = useJobPostings();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [filters, setFilters] = useState({ position: 'all', department: 'all', range: 'all' });
|
||||
const [selected, setSelected] = useState(null);
|
||||
|
||||
const hires = useMemo(
|
||||
() => buildHires({ staff, applications, postings }),
|
||||
[staff, applications, postings]
|
||||
);
|
||||
|
||||
/* Filter options come from the record itself, so a department with no hires
|
||||
is never offered as a filter that returns nothing. */
|
||||
const positionOptions = useMemo(
|
||||
() => [...new Set(hires.map((h) => h.role).filter((r) => r && r !== '—'))].sort(),
|
||||
[hires]
|
||||
);
|
||||
const departmentOptions = useMemo(
|
||||
() => [...new Set(hires.map((h) => h.department).filter((d) => d && d !== '—'))].sort(),
|
||||
[hires]
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
const range = RANGES.find((r) => r.value === filters.range);
|
||||
const cutoff = range?.days ? Date.now() - range.days * DAY : null;
|
||||
|
||||
return hires
|
||||
.filter((h) => {
|
||||
if (q && ![h.name, h.role, h.company, h.department, h.email]
|
||||
.some((v) => String(v || '').toLowerCase().includes(q))) return false;
|
||||
if (filters.position !== 'all' && h.role !== filters.position) return false;
|
||||
if (filters.department !== 'all' && h.department !== filters.department) return false;
|
||||
if (cutoff && new Date(h.hire_date || 0).getTime() < cutoff) return false;
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => new Date(b.hire_date || 0).getTime() - new Date(a.hire_date || 0).getTime());
|
||||
}, [hires, search, filters]);
|
||||
|
||||
const summary = useMemo(() => summarise(filtered), [filtered]);
|
||||
const isFiltered = Boolean(search) || Object.values(filters).some((v) => v !== 'all');
|
||||
|
||||
/* Recent first, and only what a chronology can actually show — a timeline of
|
||||
forty rows is a table with worse density. */
|
||||
const recent = useMemo(() => filtered.filter((h) => h.hire_date).slice(0, 6), [filtered]);
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearch('');
|
||||
setFilters({ position: 'all', department: 'all', range: 'all' });
|
||||
};
|
||||
|
||||
/* What this page's sections read. Published once; the renderer passes it
|
||||
down untouched and never looks inside. */
|
||||
const context = useMemo(() => ({
|
||||
isLoading, hires, filtered, recent, summary, isFiltered,
|
||||
search, setSearch, filters, setFilters,
|
||||
positionOptions, departmentOptions, clearFilters, setSelected,
|
||||
}), [
|
||||
isLoading, hires, filtered, recent, summary, isFiltered,
|
||||
search, filters, positionOptions, departmentOptions,
|
||||
]);
|
||||
|
||||
return (
|
||||
<AdminPage
|
||||
title="Hired History"
|
||||
subtitle="Who was hired, for which position, and what has happened since."
|
||||
meta={`${hires.length} on record`}
|
||||
>
|
||||
<UiEditor />
|
||||
<HiredComposition context={context} />
|
||||
|
||||
{/* One person, in full. An overlay rather than a section of the page, so
|
||||
it stays outside the tree — there is nothing to reorder about a
|
||||
drawer. */}
|
||||
<HireDetail hire={selected} onClose={() => setSelected(null)} />
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
|
||||
/** The composed page. Split out so it can read the tree the provider computed. */
|
||||
function HiredComposition({ context }) {
|
||||
const editing = useUiEditing();
|
||||
/* Outside a layout — a test, a baseline capture — there is no session, and
|
||||
the page still has to draw. It falls back to the composition as shipped. */
|
||||
const tree = editing?.tree || composePage('hired-history').tree;
|
||||
return <UiTreeRenderer nodes={tree} context={context} />;
|
||||
}
|
||||
Reference in New Issue
Block a user