Files
krow_talent_app/src/components/krow/RetentionMetrics.tsx

119 lines
4.7 KiB
TypeScript

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 (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<Metric
icon={<TrendingUp className="w-4 h-4" />}
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`}
/>
<Metric
icon={<Activity className="w-4 h-4" />}
label={t('retentionMetrics.stillActive')}
value={stats.active}
tone="good"
sub={stats.onboarding > 0 ? `+ ${stats.onboarding} onboarding` : 'On track'}
/>
<Metric
icon={<TrendingDown className="w-4 h-4" />}
label={t('retentionMetrics.churned')}
value={stats.churned}
tone={stats.churned === 0 ? 'good' : 'bad'}
sub={`${stats.total} total hires`}
/>
<Metric
icon={<Clock className="w-4 h-4" />}
label={t('retentionMetrics.avgTenure')}
value={stats.avgTenure > 0 ? `${stats.avgTenure}d` : '—'}
tone="neutral"
sub={`30d: ${stats.c30 ?? '—'} · 90d: ${stats.c90 ?? '—'}`}
/>
</div>
);
}
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 (
<div className="bg-white border border-[#E5E7EB] rounded-xl p-4 flex items-start gap-3">
<div className={`w-9 h-9 rounded-lg flex items-center justify-center shrink-0 ${t.bg}`}>
<span className={t.text}>{icon}</span>
</div>
<div className="min-w-0">
<p className="text-[11px] font-semibold text-[#6B7280] tracking-wide">{label}</p>
<p className="text-[20px] font-bold text-[#111827] leading-tight mt-0.5">{value}</p>
<p className="text-[10px] text-[#9CA3AF] mt-0.5 truncate">{sub}</p>
</div>
</div>
);
}