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