Files
krow_talent_app/src/pages/admin/HiredHistory.tsx

212 lines
9.2 KiB
TypeScript

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 (
<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 }) {
const { t } = useTranslation();
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={t('hiredHistory.theHire')} />
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="mt-2 border border-border">
<Fact label={t('hiredHistory.position')}>{hire.role}</Fact>
<Fact label={t('hiredHistory.client')}>{hire.company}</Fact>
<Fact label={t('hiredHistory.department')}>{hire.department}</Fact>
<Fact label={t('hiredHistory.hired')}>{formatDate(hire.hire_date)}</Fact>
<Fact label={t('hiredHistory.timeToHire')}>{hire.timeToHire ? `${hire.timeToHire} days` : null}</Fact>
<Fact label={t('hiredHistory.aiScore')}>{hire.score}</Fact>
<Fact label={t('hiredHistory.clientReview')}>
{hire.client_rating ? `${hire.client_rating} / 5` : 'Not reviewed yet'}
</Fact>
</Surface>
</section>
{(hire.email || hire.phone) && (
<section>
<SectionTitle title={t('hiredHistory.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 { 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 (
<AdminPage
title={t('hiredHistory.hiredHistory')}
subtitle={t('hiredHistory.whoWasHiredForWhichPosition')}
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} />;
}