Initial commit MKD fixes

This commit is contained in:
Arsen
2026-02-04 00:17:04 +05:00
commit de94ad707b
312 changed files with 138754 additions and 0 deletions

445
components/hr/HRSummary.tsx Executable file
View File

@@ -0,0 +1,445 @@
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>
);
};