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'; import { useTranslation } from 'react-i18next'; /** * 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 (
{label} {children ?? '—'}
); } /** The full record for one hire — the page's own detail view. */ function HireDetail({ hire, onClose }) { const { t } = useTranslation(); if (!hire) return null; return ( { if (!next) onClose(); }} title={hire.name} description={[hire.role, hire.company].filter((v) => v && v !== '—').join(' · ') || 'Hired'} icon={UserCheck} size="default" >

{hire.name}

{hire.profile_tier && {hire.profile_tier}}
{hire.role} {hire.company} {hire.department} {formatDate(hire.hire_date)} {hire.timeToHire ? `${hire.timeToHire} days` : null} {hire.score} {hire.client_rating ? `${hire.client_rating} / 5` : 'Not reviewed yet'}
{(hire.email || hire.phone) && (
{hire.email && ( )} {hire.phone && ( )}
)} {/* Per-record extension point, on the same terms as every other surface: a definition names `hired-history`, this renders it. */}
); } export default function AdminHiredHistory() { const { t } = useTranslation(); 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 ( {/* 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. */} setSelected(null)} /> ); } /** 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 ; }