import type { Staff } from '@/types/entities'; import React, { useMemo } from 'react'; import { TrendingUp, TrendingDown, Clock, Activity } from 'lucide-react'; import { useTranslation } from 'react-i18next'; const MS_PER_DAY = 1000 * 60 * 60 * 24; function tenureDays(hireDate, status, now) { if (!hireDate) return 0; const start = new Date(hireDate).getTime(); const end = status === 'inactive' && now ? now : Date.now(); return Math.max(0, Math.floor((end - start) / MS_PER_DAY)); } interface RetentionMetricsProps { staff: Staff[]; } export default function RetentionMetrics({ staff }: RetentionMetricsProps) { const { t } = useTranslation(); const stats = useMemo(() => { const total = staff.length; const active = staff.filter((s) => s.status === 'active').length; const onboarding = staff.filter((s) => s.status === 'onboarding').length; const churned = staff.filter((s) => s.status === 'inactive').length; // Retention rate: of hires whose outcome is decided (active + inactive), // what share are still active. Onboarding hires are excluded from the denominator // since they haven't had time to churn. const decided = active + churned; const retentionRate = decided > 0 ? Math.round((active / decided) * 100) : null; // Cohort retention: % of hires still on board after N days from hire_date. // A hire counts as retained past day N if (status !== 'inactive') OR (tenure >= N before churning). const now = Date.now(); const withHireDate = staff.filter((s) => s.hire_date); const cohort = (days) => { if (withHireDate.length === 0) return null; const retained = withHireDate.filter((s) => { const tenure = tenureDays(s.hire_date, s.status, now); if (s.status === 'inactive') return tenure >= days; return true; // still active/onboarding — retained }).length; return Math.round((retained / withHireDate.length) * 100); }; const avgTenure = withHireDate.length > 0 ? Math.round(withHireDate.reduce((sum, s) => sum + tenureDays(s.hire_date, s.status, now), 0) / withHireDate.length) : 0; return { total, active, onboarding, churned, retentionRate, c30: cohort(30), c60: cohort(60), c90: cohort(90), avgTenure }; }, [staff]); return (
} label={t('retentionMetrics.retentionRate')} value={stats.retentionRate == null ? '—' : `${stats.retentionRate}%`} tone={stats.retentionRate == null ? 'neutral' : stats.retentionRate >= 70 ? 'good' : stats.retentionRate >= 40 ? 'warn' : 'bad'} sub={`${stats.active} active · ${stats.churned} churned`} /> } label={t('retentionMetrics.stillActive')} value={stats.active} tone="good" sub={stats.onboarding > 0 ? `+ ${stats.onboarding} onboarding` : 'On track'} /> } label={t('retentionMetrics.churned')} value={stats.churned} tone={stats.churned === 0 ? 'good' : 'bad'} sub={`${stats.total} total hires`} /> } label={t('retentionMetrics.avgTenure')} value={stats.avgTenure > 0 ? `${stats.avgTenure}d` : '—'} tone="neutral" sub={`30d: ${stats.c30 ?? '—'} · 90d: ${stats.c90 ?? '—'}`} />
); } const TONES = { good: { bg: 'bg-[#ECFDF5]', text: 'text-[#047857]', icon: 'bg-[#047857]' }, warn: { bg: 'bg-[#FFF7ED]', text: 'text-[#B45309]', icon: 'bg-[#B45309]' }, bad: { bg: 'bg-[#FEF2F2]', text: 'text-[#B91C1C]', icon: 'bg-[#B91C1C]' }, neutral: { bg: 'bg-[#F0F4F8]', text: 'text-[#1E56C1]', icon: 'bg-[#1E56C1]' }, }; /** Props for the local `Metric` helper in this file. Optional throughout: the JavaScript validated none of them and callers omit freely. */ interface MetricProps { icon?: any; label?: any; value?: any; sub?: any; tone?: any; } function Metric({ icon, label, value, sub, tone = 'neutral' }: MetricProps) { const t = TONES[tone] || TONES.neutral; return (
{icon}

{label}

{value}

{sub}

); }