Files
mkd/components/hr/HRSummary.tsx
2026-02-04 00:17:04 +05:00

446 lines
23 KiB
TypeScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import {
UsersRound,
UserPlus,
Gift,
TrendingDown,
GraduationCap,
Plane,
FileWarning,
Calendar,
ClipboardList
} from 'lucide-react';
import { authFetch } from '../../services/apiClient';
import { readCache, saveCache } from '../../hooks/useCachedFetch';
import { REFRESH_EVENTS } from '../../constants/refreshEvents';
const CACHE_KEY = 'mkd_hr_summary_cache';
interface HRSummaryData {
totalStaff: number;
vacancyCount: number;
activeCandidates: number;
turnoverRate: number;
onTraining: number;
staffingByDistrict: { districtId: string; districtName: string; current: number; total: number }[];
events: {
birthdays: { employeeId: string; name: string; initials: string; date: string; label: string }[];
vacationReturns: { employeeId: string; name: string; initials: string; date: string; label: string }[];
};
mostFrequentVacation: { employeeId: string; name: string; initials: string; count: number }[];
mostFrequentAbsences: {
employeeId: string;
name: string;
initials: string;
dayOff: number;
absence: number;
late: number;
earlyLeave: number;
sick: number;
total: number;
}[];
}
interface Props {
onNavigate: (tab: any) => void;
}
const defaultSummary: HRSummaryData = {
totalStaff: 0,
vacancyCount: 0,
activeCandidates: 0,
turnoverRate: 0,
onTraining: 0,
staffingByDistrict: [],
events: { birthdays: [], vacationReturns: [] },
mostFrequentVacation: [],
mostFrequentAbsences: []
};
export const HRSummary: React.FC<Props> = ({ onNavigate }) => {
const cached = readCache<HRSummaryData | null>(CACHE_KEY, null);
const [summary, setSummary] = useState<HRSummaryData>(cached || defaultSummary);
const [loading, setLoading] = useState(!cached);
const load = useCallback(async (showSpinner = true) => {
if (showSpinner && !cached) setLoading(true);
try {
const response = await authFetch('/api/hr/summary');
if (response.ok) {
const data = await response.json();
setSummary(data);
saveCache(CACHE_KEY, data);
} else setSummary(defaultSummary);
} catch (error) {
console.error('Error fetching HR summary:', error);
setSummary(defaultSummary);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, []);
useEffect(() => {
const onRefresh = () => load(false);
window.addEventListener(REFRESH_EVENTS.employees, onRefresh);
return () => window.removeEventListener(REFRESH_EVENTS.employees, onRefresh);
}, [load]);
useEffect(() => {
const interval = setInterval(() => load(false), 60 * 1000);
return () => clearInterval(interval);
}, [load]);
const [holidaysRu, setHolidaysRu] = useState<{ date: string; name: string }[]>([]);
const [holidayYear, setHolidayYear] = useState(() => new Date().getFullYear());
useEffect(() => {
authFetch(`/api/holidays/ru?year=${holidayYear}`)
.then((r) => r.json())
.then((data) => setHolidaysRu(data.holidays || []))
.catch(() => setHolidaysRu([]));
}, [holidayYear]);
const eventsList = [
...summary.events.birthdays.map((e) => ({ ...e, type: 'birthday' as const })),
...summary.events.vacationReturns.map((e) => ({ ...e, type: 'vacation' as const }))
].slice(0, 10);
const sickAndDayOff = [...summary.mostFrequentAbsences]
.filter((e) => e.dayOff > 0 || e.sick > 0)
.sort((a, b) => b.dayOff + b.sick - (a.dayOff + a.sick))
.slice(0, 10);
const disciplinary = [...summary.mostFrequentAbsences]
.filter((e) => e.absence > 0 || e.late > 0 || e.earlyLeave > 0)
.sort((a, b) => b.absence + b.late + b.earlyLeave - (a.absence + a.late + a.earlyLeave))
.slice(0, 10);
return (
<div className="space-y-6 animate-fade-in">
{loading && (
<div className="flex justify-center py-8">
<div className="w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" />
</div>
)}
{!loading && (
<>
{/* KPI Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
icon={UsersRound}
label="Всего в штате"
value={summary.totalStaff}
color="text-primary-600"
bg="bg-primary-50"
onClick={() => onNavigate('employees')}
/>
<StatCard
icon={UserPlus}
label="Активный найм"
value={summary.activeCandidates}
subValue={`${summary.vacancyCount} вак.`}
color="text-violet-600"
bg="bg-violet-50"
onClick={() => onNavigate('hiring')}
/>
<StatCard
icon={TrendingDown}
label="Текучесть (год)"
value={`${summary.turnoverRate}%`}
color="text-emerald-600"
bg="bg-emerald-50"
/>
<StatCard
icon={GraduationCap}
label="На обучение"
value={summary.onTraining}
color="text-amber-600"
bg="bg-amber-50"
onClick={() => onNavigate('safety')}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
{/* Events Widget */}
<div className="max-w-md bg-slate-900 rounded-[2.5rem] p-6 text-white shadow-xl relative overflow-hidden flex flex-col">
<Gift className="absolute -top-4 -right-4 w-32 h-32 opacity-10 rotate-12" />
<div className="relative z-10 mb-8">
<h4 className="text-xs font-black text-amber-400 uppercase tracking-widest mb-4">События недели</h4>
<div className="space-y-4">
{eventsList.length === 0 && (
<p className="text-[10px] text-slate-400">Нет предстоящих событий</p>
)}
{eventsList.map((e) => {
const eventSubtitle =
e.type === 'birthday' && (e.label === 'Сегодня' || e.label === 'Завтра')
? `День рождения • ${e.label}`
: e.type === 'birthday'
? `День рождения • ${e.label.replace(/^День рождения •\s*/i, '')}`
: e.label;
return (
<div key={`${e.type}-${e.employeeId}`} className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center font-bold border border-white/10 text-xs">
{e.initials}
</div>
<div>
<p className="text-sm font-bold leading-none">{e.name}</p>
<p className="text-[10px] text-slate-400 mt-1">{eventSubtitle}</p>
</div>
</div>
);
})}
</div>
</div>
<button
onClick={() => onNavigate('calendar')}
className="mt-auto w-full py-3 bg-white/10 hover:bg-white/20 rounded-2xl text-[10px] font-black uppercase tracking-widest transition-colors border border-white/10"
>
Поздравить всех
</button>
</div>
{/* Календарь праздников РФ */}
<HolidayCalendar holidays={holidaysRu} currentYear={holidayYear} onYearChange={setHolidayYear} />
</div>
{/* Самые частые в отпуске / больничные и отгулы / дисциплинарные нарушения */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="bg-white rounded-3xl border border-slate-200 shadow-sm overflow-hidden">
<div className="p-5 border-b border-slate-100 bg-slate-50/50 flex items-center gap-2">
<Plane className="w-4 h-4 text-primary-500" />
<h3 className="font-black text-[10px] text-slate-500 uppercase tracking-widest">
Самые частые в отпуске
</h3>
</div>
<div className="p-6">
{summary.mostFrequentVacation.length === 0 && (
<p className="text-xs text-slate-500">Нет данных</p>
)}
<ul className="space-y-3">
{summary.mostFrequentVacation.map((e) => (
<li key={e.employeeId} className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-primary-50 flex items-center justify-center font-bold text-primary-600 text-xs">
{e.initials}
</div>
<span className="text-sm font-bold text-slate-800">{e.name}</span>
</div>
<span className="text-[10px] font-black text-slate-400 uppercase bg-slate-100 px-2 py-1 rounded-full">
{e.count} отпуск{e.count === 1 ? '' : e.count < 5 ? 'а' : 'ов'}
</span>
</li>
))}
</ul>
</div>
</div>
<div className="bg-white rounded-3xl border border-slate-200 shadow-sm overflow-hidden">
<div className="p-5 border-b border-slate-100 bg-slate-50/50 flex items-center gap-2">
<ClipboardList className="w-4 h-4 text-blue-500" />
<h3 className="font-black text-[10px] text-slate-500 uppercase tracking-widest">
Больничные и отгулы
</h3>
</div>
<div className="p-6">
{sickAndDayOff.length === 0 && (
<p className="text-xs text-slate-500">Нет данных</p>
)}
<ul className="space-y-3">
{sickAndDayOff.map((e) => (
<li key={e.employeeId} className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2">
<div className="w-9 h-9 rounded-full bg-blue-50 flex items-center justify-center font-bold text-blue-600 text-xs">
{e.initials}
</div>
<span className="text-sm font-bold text-slate-800">{e.name}</span>
</div>
<div className="flex flex-wrap gap-1 text-[9px] font-bold">
{e.dayOff > 0 && (
<span className="bg-slate-100 text-slate-600 px-1.5 py-0.5 rounded">отгулы: {e.dayOff}</span>
)}
{e.sick > 0 && (
<span className="bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded">больн.: {e.sick}</span>
)}
</div>
</li>
))}
</ul>
</div>
</div>
<div className="bg-white rounded-3xl border border-slate-200 shadow-sm overflow-hidden">
<div className="p-5 border-b border-slate-100 bg-slate-50/50 flex items-center gap-2">
<FileWarning className="w-4 h-4 text-amber-500" />
<h3 className="font-black text-[10px] text-slate-500 uppercase tracking-widest">
Дисциплинарные нарушения
</h3>
</div>
<div className="p-6">
{disciplinary.length === 0 && (
<p className="text-xs text-slate-500">Нет данных</p>
)}
<ul className="space-y-3">
{disciplinary.map((e) => (
<li key={e.employeeId} className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2">
<div className="w-9 h-9 rounded-full bg-amber-50 flex items-center justify-center font-bold text-amber-600 text-xs">
{e.initials}
</div>
<span className="text-sm font-bold text-slate-800">{e.name}</span>
</div>
<div className="flex flex-wrap gap-1 text-[9px] font-bold">
{e.absence > 0 && (
<span className="bg-red-50 text-red-600 px-1.5 py-0.5 rounded">прогулы: {e.absence}</span>
)}
{e.late > 0 && (
<span className="bg-orange-50 text-orange-600 px-1.5 py-0.5 rounded">опозд.: {e.late}</span>
)}
{e.earlyLeave > 0 && (
<span className="bg-amber-50 text-amber-600 px-1.5 py-0.5 rounded">уходы: {e.earlyLeave}</span>
)}
</div>
</li>
))}
</ul>
</div>
</div>
</div>
</>
)}
</div>
);
};
const StatCard = ({ icon: Icon, label, value, subValue, color, bg, onClick }: any) => (
<div
onClick={onClick}
className="bg-white p-5 rounded-[2rem] border border-slate-200 shadow-sm cursor-pointer hover:border-primary-400 transition-all hover:shadow-md active:scale-95"
>
<div className="flex justify-between items-start mb-3">
<div className={`p-2.5 ${bg} ${color} rounded-2xl`}>
<Icon className="w-5 h-5" />
</div>
{subValue != null && subValue !== '' && (
<span className="text-[9px] font-black text-primary-500 bg-primary-50 px-2 py-1 rounded-full uppercase">
{subValue}
</span>
)}
</div>
<p className="text-2xl font-black text-slate-800 leading-none">{value}</p>
<p className="text-[10px] text-slate-500 font-bold uppercase tracking-wider mt-2">{label}</p>
</div>
);
const MONTH_NAMES = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'];
const WEEKDAYS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
const HolidayCalendar: React.FC<{
holidays: { date: string; name: string }[];
currentYear: number;
onYearChange: (y: number) => void;
}> = ({ holidays, currentYear, onYearChange }) => {
const today = useMemo(() => new Date(), []);
const [viewMonth, setViewMonth] = useState(today.getMonth());
const [viewYear, setViewYear] = useState(currentYear);
const displayYear = viewYear;
const holidaysByDate = useMemo(() => {
const map: Record<string, string> = {};
holidays.forEach((h) => { map[h.date] = h.name; });
return map;
}, [holidays]);
if (viewYear !== currentYear) setViewYear(currentYear);
const firstDay = new Date(displayYear, viewMonth, 1);
const lastDay = new Date(displayYear, viewMonth + 1, 0);
const startWeekday = firstDay.getDay() === 0 ? 6 : firstDay.getDay() - 1;
const daysInMonth = lastDay.getDate();
const cells: (number | null)[] = [];
for (let i = 0; i < startWeekday; i++) cells.push(null);
for (let d = 1; d <= daysInMonth; d++) cells.push(d);
const rows: (number | null)[][] = [];
for (let i = 0; i < cells.length; i += 7) rows.push(cells.slice(i, i + 7));
const toDateStr = (day: number) =>
`${displayYear}-${String(viewMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const isToday = (day: number) =>
today.getDate() === day && today.getMonth() === viewMonth && today.getFullYear() === displayYear;
const goPrev = () => {
if (viewMonth === 0) {
setViewMonth(11);
setViewYear((y) => y - 1);
onYearChange(displayYear - 1);
} else setViewMonth((m) => m - 1);
};
const goNext = () => {
if (viewMonth === 11) {
setViewMonth(0);
setViewYear((y) => y + 1);
onYearChange(displayYear + 1);
} else setViewMonth((m) => m + 1);
};
return (
<div className="bg-white rounded-[2rem] border border-slate-200 shadow-sm overflow-hidden">
<div className="p-4 border-b border-slate-100 bg-slate-50/50 flex items-center justify-between gap-2">
<h4 className="text-[10px] font-black text-slate-500 uppercase tracking-widest flex items-center gap-2">
<Calendar className="w-4 h-4 text-primary-500" /> Праздники РФ
</h4>
<div className="flex items-center gap-1">
<button
type="button"
onClick={goPrev}
className="p-1.5 rounded-lg text-slate-500 hover:bg-slate-200 text-xs font-bold"
>
</button>
<span className="text-xs font-bold text-slate-700 min-w-[100px] text-center">
{MONTH_NAMES[viewMonth]} {displayYear}
</span>
<button
type="button"
onClick={goNext}
className="p-1.5 rounded-lg text-slate-500 hover:bg-slate-200 text-xs font-bold"
>
</button>
</div>
</div>
<div className="p-4">
<div className="grid grid-cols-7 gap-0.5 text-center">
{WEEKDAYS.map((wd) => (
<div key={wd} className="text-[9px] font-black text-slate-400 py-1">
{wd}
</div>
))}
{rows.flatMap((row, ri) =>
row.map((day, di) => {
if (day === null) {
return <div key={`e-${ri}-${di}`} className="aspect-square" />;
}
const dateStr = toDateStr(day);
const holidayName = holidaysByDate[dateStr];
const todayCell = isToday(day);
return (
<div
key={dateStr}
title={holidayName || undefined}
className={`aspect-square flex items-center justify-center text-[11px] font-bold rounded-lg ${todayCell ? 'bg-primary-100 text-primary-700 ring-1 ring-primary-300' : holidayName ? 'bg-amber-100 text-amber-800 cursor-help' : 'text-slate-600 hover:bg-slate-50'}`}
>
{day}
</div>
);
})
)}
</div>
<p className="text-[9px] text-slate-400 mt-3 text-center">
Наведите на отмеченную дату подсказка с названием праздника
</p>
</div>
</div>
);
};