212 lines
9.2 KiB
TypeScript
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} />;
|
|
}
|