446 lines
23 KiB
TypeScript
Executable File
446 lines
23 KiB
TypeScript
Executable File
|
||
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>
|
||
);
|
||
};
|
||
|