Merge branch 'feature'

# Conflicts:
#	frontend/src/components/BottomBar.tsx
This commit is contained in:
2026-08-14 16:21:01 +03:00
3 changed files with 514 additions and 252 deletions
+11 -21
View File
@@ -48,8 +48,8 @@ function App() {
// 📊 Состояния для таймера // 📊 Состояния для таймера
const [showInactiveWarning, setShowInactiveWarning] = useState(false); const [showInactiveWarning, setShowInactiveWarning] = useState(false);
// 📌 Реф для поля ввода логина (исправленный тип) // 📌 Реф для поля ввода логина
const loginInputRef = useRef<HTMLInputElement>(null!); // ✅ Исправлено const loginInputRef = useRef<HTMLInputElement>(null!);
// ============================================ // ============================================
// 🛑 Функция ПОЛНОГО сброса приложения (как при запуске) // 🛑 Функция ПОЛНОГО сброса приложения (как при запуске)
@@ -57,7 +57,6 @@ function App() {
const resetToInitialState = () => { const resetToInitialState = () => {
console.log('🔄 ПОЛНЫЙ СБРОС ПРИЛОЖЕНИЯ — как при запуске'); console.log('🔄 ПОЛНЫЙ СБРОС ПРИЛОЖЕНИЯ — как при запуске');
// ✅ Сбрасываем ВСЕ состояния
setIsLoggedIn(false); setIsLoggedIn(false);
setIsGuestMode(false); setIsGuestMode(false);
setEmployee(null); setEmployee(null);
@@ -67,10 +66,8 @@ function App() {
setShowInactiveWarning(false); setShowInactiveWarning(false);
setLoginError(''); setLoginError('');
// ✅ ПОКАЗЫВАЕМ ВИДЕО-ЗАСТАВКУ СНАЧАЛА
setShowVideo(true); setShowVideo(true);
// ✅ Через 200ms ставим фокус на поле ввода (после видео)
setTimeout(() => { setTimeout(() => {
if (loginInputRef.current) { if (loginInputRef.current) {
loginInputRef.current.focus(); loginInputRef.current.focus();
@@ -80,11 +77,11 @@ function App() {
}; };
// ============================================ // ============================================
// 🕐 Таймер неактивности (работает ВСЕГДА) // 🕐 Таймер неактивности
// ============================================ // ============================================
const { isWarning, timeLeft, resetTimer } = useActivityTimer({ const { isWarning, timeLeft, resetTimer } = useActivityTimer({
timeout: 30000, // 30 секунд до предупреждения timeout: 30000,
warningTimeout: 10000, // 10 секунд до сброса warningTimeout: 10000,
onInactive: () => { onInactive: () => {
console.log('🛑 Пользователь неактивен! ПОЛНЫЙ СБРОС ПРИЛОЖЕНИЯ'); console.log('🛑 Пользователь неактивен! ПОЛНЫЙ СБРОС ПРИЛОЖЕНИЯ');
resetToInitialState(); resetToInitialState();
@@ -95,7 +92,6 @@ function App() {
} }
}); });
// 📌 Функция для сброса таймера при любом действии
const handleUserActivity = () => { const handleUserActivity = () => {
if (showInactiveWarning) { if (showInactiveWarning) {
setShowInactiveWarning(false); setShowInactiveWarning(false);
@@ -128,18 +124,15 @@ function App() {
} }
}; };
// 👤 Режим гостя
const handleGuestMode = () => { const handleGuestMode = () => {
setIsGuestMode(true); setIsGuestMode(true);
handleUserActivity(); handleUserActivity();
}; };
// ↩️ Выход из системы
const handleBack = () => { const handleBack = () => {
resetToInitialState(); resetToInitialState();
}; };
// ↩️ Возврат в главное меню
const handleBackToMenu = () => { const handleBackToMenu = () => {
setCurrentSection(null); setCurrentSection(null);
setShowPayslip(false); setShowPayslip(false);
@@ -147,27 +140,23 @@ function App() {
handleUserActivity(); handleUserActivity();
}; };
// 📂 Выбор раздела
const handleSectionSelect = (section: string) => { const handleSectionSelect = (section: string) => {
setCurrentSection(section); setCurrentSection(section);
handleUserActivity(); handleUserActivity();
}; };
// 📄 Просмотр расчетного листка
const handleViewPayslip = (period: string) => { const handleViewPayslip = (period: string) => {
setSelectedPeriod(period); setSelectedPeriod(period);
setShowPayslip(true); setShowPayslip(true);
handleUserActivity(); handleUserActivity();
}; };
// ↩️ Назад из расчетного листка
const handlePayslipBack = () => { const handlePayslipBack = () => {
setShowPayslip(false); setShowPayslip(false);
setSelectedPeriod(''); setSelectedPeriod('');
handleUserActivity(); handleUserActivity();
}; };
// 🔗 URL для расчетного листка
const getPayslipUrl = (employee: Employee, period: string) => { const getPayslipUrl = (employee: Employee, period: string) => {
const fileName = `${employee.tnom}.pdf`; const fileName = `${employee.tnom}.pdf`;
return `${config.backendUrl}/api/payslip/${period}/${fileName}`; return `${config.backendUrl}/api/payslip/${period}/${fileName}`;
@@ -263,12 +252,12 @@ function App() {
// 📄 Рендер контента // 📄 Рендер контента
// ============================================ // ============================================
const renderContent = () => { const renderContent = () => {
// 🎬 Если showVideo = true — показываем видео // 🎬 Видео-заставка
if (showVideo) { if (showVideo) {
return <VideoBackground onComplete={() => setShowVideo(false)} />; return <VideoBackground onComplete={() => setShowVideo(false)} />;
} }
// ✅ Если не залогинен и не гость — показываем окно входа // 🔐 Экран логина
if (!isLoggedIn && !isGuestMode) { if (!isLoggedIn && !isGuestMode) {
return ( return (
<LoginWindow <LoginWindow
@@ -319,7 +308,6 @@ function App() {
} }
} }
// 📋 Главное меню
return <MainMenu onSectionSelect={handleSectionSelect} employee={employee!} />; return <MainMenu onSectionSelect={handleSectionSelect} employee={employee!} />;
}; };
@@ -342,8 +330,10 @@ function App() {
{/* 📄 Основной контент */} {/* 📄 Основной контент */}
{renderContent()} {renderContent()}
{/* 📱 Нижняя панель (только для авторизованных) */} {/* 📱 Нижняя панель — ПОКАЗЫВАЕМ ВСЕГДА, кроме видео и расчетного листка */}
{isLoggedIn && !showPayslip && <BottomBar />} {!showVideo && !showPayslip && (
<BottomBar onLogout={handleBack} />
)}
</div> </div>
); );
} }
+501 -230
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { motion } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
const NTP_URL = import.meta.env.VITE_NTP_SERVER || 'http://192.168.139.249:3001/api/time';
interface CurrencyRate { interface CurrencyRate {
currency: string; currency: string;
rate: number; rate: number;
@@ -13,7 +13,20 @@ interface MetalRate {
change: number; change: number;
} }
const BottomBar: React.FC = () => { interface WeatherDay {
date: string;
dayOfWeek: string;
tempMin: number;
tempMax: number;
icon: string;
description: string;
}
interface BottomBarProps {
onLogout?: () => void;
}
const BottomBar: React.FC<BottomBarProps> = ({ onLogout }) => {
const [currencyRates, setCurrencyRates] = useState<CurrencyRate[]>([ const [currencyRates, setCurrencyRates] = useState<CurrencyRate[]>([
{ currency: 'USD', rate: 3.22, change: 0.5 }, { currency: 'USD', rate: 3.22, change: 0.5 },
{ currency: 'EUR', rate: 3.45, change: -0.2 }, { currency: 'EUR', rate: 3.45, change: -0.2 },
@@ -30,37 +43,16 @@ const BottomBar: React.FC = () => {
const [isOnline, setIsOnline] = useState(navigator.onLine); const [isOnline, setIsOnline] = useState(navigator.onLine);
const [isPaused, setIsPaused] = useState(false); const [isPaused, setIsPaused] = useState(false);
const [currentTime, setCurrentTime] = useState(new Date());
const [weatherData, setWeatherData] = useState<WeatherDay[]>([]);
const [weatherLoading, setWeatherLoading] = useState(false);
const [weatherError, setWeatherError] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const getAllItems = () => { // ============================================
const currencyItems = currencyRates.map(item => ({ // ⏰ ВРЕМЯ С СЕРВЕРА (объединенная логика)
id: `currency-${item.currency}`, // ============================================
type: 'currency' as const, const NTP_URL = import.meta.env.VITE_NTP_SERVER || 'http://192.168.139.249:3001/api/time';
label: item.currency,
value: item.rate,
change: item.change,
displayValue: item.rate.toFixed(item.rate < 1 ? 3 : 2),
}));
const metalItems = metalRates.map(item => ({
id: `metal-${item.metal}`,
type: 'metal' as const,
label: item.metal,
value: item.price,
change: item.change,
displayValue: `$${item.price.toFixed(item.price < 10 ? 2 : 0)}`,
}));
return [...currencyItems, ...metalItems];
};
const items = getAllItems();
const doubledItems = [...items, ...items, ...items];
const NTP_URL = 'http://localhost:3005/api/time'; // или твой URL
const useServerTime = (interval: number = 1000) => {
const [time, setTime] = useState(() => new Date());
useEffect(() => { useEffect(() => {
let timer: NodeJS.Timeout; let timer: NodeJS.Timeout;
@@ -73,42 +65,101 @@ const useServerTime = (interval: number = 1000) => {
const data = await res.json(); const data = await res.json();
const serverTime = new Date(data.time); const serverTime = new Date(data.time);
// Вычисляем разницу между серверным и локальным временем
diff = serverTime.getTime() - Date.now(); diff = serverTime.getTime() - Date.now();
setCurrentTime(new Date(Date.now() + diff));
// Устанавливаем время
setTime(new Date(Date.now() + diff));
// Запускаем локальный таймер
if (timer) clearInterval(timer); if (timer) clearInterval(timer);
timer = setInterval(() => { timer = setInterval(() => {
setTime(new Date(Date.now() + diff)); setCurrentTime(new Date(Date.now() + diff));
}, interval); }, 1000);
} catch (err) { } catch (err) {
console.warn('Server time unavailable, using local time:', err); console.warn('Server time unavailable, using local time:', err);
// Если сервер недоступен — используем локальное время setCurrentTime(new Date());
setTime(new Date());
if (timer) clearInterval(timer); if (timer) clearInterval(timer);
timer = setInterval(() => { timer = setInterval(() => {
setTime(new Date()); setCurrentTime(new Date());
}, interval); }, 1000);
} }
}; };
// Первый запрос
fetchTime(); fetchTime();
// Очистка при размонтировании
return () => { return () => {
if (timer) clearInterval(timer); if (timer) clearInterval(timer);
}; };
}, [interval]); }, []);
return time; // ============================================
}; // 🌤️ ЗАГРУЗКА ПОГОДЫ
const currentTime = useServerTime(1000); // ============================================
useEffect(() => {
const fetchWeather = async () => {
if (!isOnline) {
setWeatherData([]);
return;
}
try {
setWeatherLoading(true);
setWeatherError(false);
const apiKey = '9edc3b8058098d110db0aa1894a08c97';
const city = 'Grodno';
const country = 'BY';
const url = `https://api.openweathermap.org/data/2.5/forecast?q=${city},${country}&units=metric&lang=ru&appid=${apiKey}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Ошибка: ${response.status}`);
}
const data = await response.json();
const dailyForecast: WeatherDay[] = [];
const daysMap = new Map();
data.list.forEach((item: any) => {
const date = new Date(item.dt * 1000);
const dateStr = date.toISOString().split('T')[0];
const dayOfWeek = date.toLocaleString('ru-RU', { weekday: 'short' });
if (!daysMap.has(dateStr)) {
daysMap.set(dateStr, {
date: dateStr,
dayOfWeek: dayOfWeek.charAt(0).toUpperCase() + dayOfWeek.slice(1),
tempMin: item.main.temp_min,
tempMax: item.main.temp_max,
icon: item.weather[0].icon,
description: item.weather[0].description,
});
} else {
const existing = daysMap.get(dateStr);
existing.tempMin = Math.min(existing.tempMin, item.main.temp_min);
existing.tempMax = Math.max(existing.tempMax, item.main.temp_max);
}
});
const sevenDays = Array.from(daysMap.values()).slice(0, 7);
setWeatherData(sevenDays);
setWeatherLoading(false);
} catch (error) {
console.warn('Ошибка загрузки погоды:', error);
setWeatherError(true);
setWeatherLoading(false);
setWeatherData([]);
}
};
fetchWeather();
const interval = setInterval(fetchWeather, 30 * 60 * 1000);
return () => clearInterval(interval);
}, [isOnline]);
// ============================================
// 🌐 СТАТУС ИНТЕРНЕТА
// ============================================
useEffect(() => { useEffect(() => {
const handleOnline = () => setIsOnline(true); const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false); const handleOffline = () => setIsOnline(false);
@@ -122,6 +173,9 @@ const currentTime = useServerTime(1000);
}; };
}, []); }, []);
// ============================================
// 📊 ЗАГРУЗКА КУРСОВ
// ============================================
useEffect(() => { useEffect(() => {
const loadRates = async () => { const loadRates = async () => {
try { try {
@@ -147,208 +201,425 @@ const currentTime = useServerTime(1000);
loadRates(); loadRates();
const interval = setInterval(loadRates, 60000); const interval = setInterval(loadRates, 60000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [isOnline]); }, [isOnline]);
// ============================================
// 🎮 УПРАВЛЕНИЕ ПАУЗОЙ
// ============================================
const handleMouseEnter = () => setIsPaused(true); const handleMouseEnter = () => setIsPaused(true);
const handleMouseLeave = () => setIsPaused(false); const handleMouseLeave = () => setIsPaused(false);
const handleTouchStart = () => setIsPaused(true); const handleTouchStart = () => setIsPaused(true);
const handleTouchEnd = () => setIsPaused(false); const handleTouchEnd = () => setIsPaused(false);
// ============================================
// 🚪 ВЫХОД
// ============================================
const handleLogout = () => {
if (onLogout) {
onLogout();
}
};
// ============================================
// 🎨 ИКОНКА ПОГОДЫ
// ============================================
const getWeatherIcon = (iconCode: string) => {
const icons: Record<string, string> = {
'01d': '☀️',
'01n': '🌙',
'02d': '⛅',
'02n': '☁️',
'03d': '☁️',
'03n': '☁️',
'04d': '☁️',
'04n': '☁️',
'09d': '🌧️',
'09n': '🌧️',
'10d': '🌦️',
'10n': '🌧️',
'11d': '⛈️',
'11n': '⛈️',
'13d': '❄️',
'13n': '❄️',
'50d': '🌫️',
'50n': '🌫️',
};
return icons[iconCode] || '🌡️';
};
// ============================================
// 📦 ВСЕ ЭЛЕМЕНТЫ ДЛЯ БЕГУЩЕЙ СТРОКИ
// ============================================
const getAllItems = () => {
const currencyItems = currencyRates.map(item => ({
id: `currency-${item.currency}`,
type: 'currency' as const,
label: item.currency,
value: item.rate,
change: item.change,
displayValue: item.rate.toFixed(item.rate < 1 ? 3 : 2),
}));
const metalItems = metalRates.map(item => ({
id: `metal-${item.metal}`,
type: 'metal' as const,
label: item.metal,
value: item.price,
change: item.change,
displayValue: `$${item.price.toFixed(item.price < 10 ? 2 : 0)}`,
}));
return [...currencyItems, ...metalItems];
};
const items = getAllItems();
const doubledItems = [...items, ...items, ...items];
// ============================================
// 🎨 РЕНДЕР
// ============================================
return ( return (
<motion.div <>
className="bottom-bar" {/* 🌤️ ПОЛОСКА ПОГОДЫ */}
initial={{ y: 100 }} <AnimatePresence>
animate={{ y: 0 }} {isOnline && weatherData.length > 0 && !weatherLoading && !weatherError && (
transition={{ type: 'spring', stiffness: 300, damping: 30 }} <motion.div
style={{ initial={{ y: -30, opacity: 0 }}
position: 'fixed', animate={{ y: 0, opacity: 1 }}
bottom: 0, exit={{ y: -30, opacity: 0 }}
left: 0, transition={{ duration: 0.3 }}
right: 0, style={{
background: 'rgba(44, 62, 80, 0.95)', position: 'fixed',
backdropFilter: 'blur(10px)', bottom: '44px',
color: '#fff', left: 0,
padding: '8px 20px', right: 0,
display: 'flex', zIndex: 999,
alignItems: 'center', background: 'rgba(44, 62, 80, 0.85)',
gap: '16px', backdropFilter: 'blur(10px)',
overflow: 'hidden', color: '#fff',
zIndex: 1000, padding: '4px 20px',
height: '44px', display: 'flex',
borderTop: '1px solid rgba(255,255,255,0.1)', alignItems: 'center',
fontSize: '13px', gap: '12px',
fontFamily: 'monospace', overflow: 'hidden',
}} height: '32px',
onMouseEnter={handleMouseEnter} borderTop: '1px solid rgba(255,255,255,0.05)',
onMouseLeave={handleMouseLeave} borderBottom: '1px solid rgba(255,255,255,0.05)',
onTouchStart={handleTouchStart} fontSize: '12px',
onTouchEnd={handleTouchEnd} fontFamily: 'monospace',
> }}
{/* Статус */} >
<div style={{ <div style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '12px', gap: '8px',
flexShrink: 0, flexShrink: 0,
zIndex: 2, background: 'rgba(44, 62, 80, 0.85)',
background: 'rgba(44, 62, 80, 0.95)', paddingRight: '12px',
paddingRight: '12px', }}>
}}> <span style={{ fontSize: '16px' }}>🌤️</span>
<span style={{ <span style={{ fontWeight: 600, opacity: 0.7, fontSize: '11px' }}>
color: isOnline ? '#2ecc71' : '#e74c3c', ГРОДНО
fontSize: '12px', </span>
}}> <div style={{
{isOnline ? '●' : '○'} width: '1px',
</span> height: '20px',
<span style={{ fontWeight: 600, opacity: 0.7, fontSize: '11px' }}>КУРСЫ</span> background: 'rgba(255,255,255,0.1)',
<div style={{ marginLeft: '4px',
width: '1px', }} />
height: '24px', </div>
background: 'rgba(255,255,255,0.15)',
marginLeft: '4px',
}} />
</div>
{/* Бегущая строка */}
<div
ref={scrollRef}
style={{
flex: 1,
overflow: 'hidden',
position: 'relative',
height: '100%',
display: 'flex',
alignItems: 'center',
maskImage: 'linear-gradient(to right, transparent, black 3%, black 97%, transparent)',
WebkitMaskImage: 'linear-gradient(to right, transparent, black 3%, black 97%, transparent)',
}}
>
<motion.div
animate={{
x: isPaused ? 0 : ['0%', '-33.33%'],
}}
transition={{
duration: 35,
repeat: Infinity,
ease: 'linear',
repeatType: 'loop',
}}
style={{
display: 'flex',
gap: '40px',
whiteSpace: 'nowrap',
willChange: 'transform',
transform: 'translateZ(0)',
backfaceVisibility: 'hidden',
WebkitFontSmoothing: 'antialiased',
MozOsxFontSmoothing: 'grayscale',
textRendering: 'geometricPrecision',
paddingRight: '40px',
height: '100%',
alignItems: 'center',
}}
>
{doubledItems.map((item, index) => (
<div <div
key={`${item.id}-${index}`}
style={{ style={{
flex: 1,
overflow: 'hidden',
position: 'relative',
height: '100%',
display: 'flex', display: 'flex',
gap: '8px',
alignItems: 'center', alignItems: 'center',
whiteSpace: 'nowrap',
flexShrink: 0,
fontSmooth: 'always',
WebkitFontSmoothing: 'antialiased',
textRendering: 'geometricPrecision',
}} }}
> >
<span style={{ <motion.div
fontWeight: 600, animate={{
color: 'rgba(255,255,255,0.9)', x: isPaused ? 0 : ['0%', '-50%'],
fontSize: '13px', }}
letterSpacing: '0.3px', transition={{
}}> duration: 20,
{item.label} repeat: Infinity,
</span> ease: 'linear',
<span style={{ repeatType: 'loop',
color: 'rgba(255,255,255,0.8)', }}
fontSize: '13px', style={{
letterSpacing: '0.2px', display: 'flex',
}}> gap: '30px',
{item.displayValue} whiteSpace: 'nowrap',
</span> willChange: 'transform',
<span style={{ transform: 'translateZ(0)',
color: item.change >= 0 ? '#2ecc71' : '#e74c3c', paddingRight: '30px',
fontSize: '11px', height: '100%',
alignItems: 'center',
}}
>
{[...weatherData, ...weatherData].map((day, index) => (
<div
key={`${day.date}-${index}`}
style={{
display: 'flex',
gap: '8px',
alignItems: 'center',
whiteSpace: 'nowrap',
flexShrink: 0,
}}
>
<span style={{ fontSize: '13px', fontWeight: 500 }}>
{day.dayOfWeek}
</span>
<span style={{ fontSize: '16px' }}>
{getWeatherIcon(day.icon)}
</span>
<span style={{ color: 'rgba(255,255,255,0.7)' }}>
{Math.round(day.tempMin)}°…{Math.round(day.tempMax)}°
</span>
</div>
))}
</motion.div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* 📊 ОСНОВНАЯ ПОЛОСКА (курсы валют) */}
<motion.div
className="bottom-bar"
initial={{ y: 100 }}
animate={{ y: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
background: 'rgba(44, 62, 80, 0.95)',
backdropFilter: 'blur(10px)',
color: '#fff',
padding: '8px 20px',
display: 'flex',
alignItems: 'center',
gap: '16px',
overflow: 'hidden',
zIndex: 1000,
height: '44px',
borderTop: '1px solid rgba(255,255,255,0.1)',
fontSize: '13px',
fontFamily: 'monospace',
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* Статус */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
flexShrink: 0,
zIndex: 2,
background: 'rgba(44, 62, 80, 0.95)',
paddingRight: '12px',
}}>
<span style={{
color: isOnline ? '#2ecc71' : '#e74c3c',
fontSize: '12px',
}}>
{isOnline ? '●' : '○'}
</span>
<span style={{ fontWeight: 600, opacity: 0.7, fontSize: '11px' }}>КУРСЫ</span>
<div style={{
width: '1px',
height: '24px',
background: 'rgba(255,255,255,0.15)',
marginLeft: '4px',
}} />
</div>
{/* Бегущая строка */}
<div
ref={scrollRef}
style={{
flex: 1,
overflow: 'hidden',
position: 'relative',
height: '100%',
display: 'flex',
alignItems: 'center',
maskImage: 'linear-gradient(to right, transparent, black 3%, black 97%, transparent)',
WebkitMaskImage: 'linear-gradient(to right, transparent, black 3%, black 97%, transparent)',
}}
>
<motion.div
animate={{
x: isPaused ? 0 : ['0%', '-33.33%'],
}}
transition={{
duration: 35,
repeat: Infinity,
ease: 'linear',
repeatType: 'loop',
}}
style={{
display: 'flex',
gap: '40px',
whiteSpace: 'nowrap',
willChange: 'transform',
transform: 'translateZ(0)',
backfaceVisibility: 'hidden',
WebkitFontSmoothing: 'antialiased',
MozOsxFontSmoothing: 'grayscale',
textRendering: 'geometricPrecision',
paddingRight: '40px',
height: '100%',
alignItems: 'center',
}}
>
{doubledItems.map((item, index) => (
<div
key={`${item.id}-${index}`}
style={{
display: 'flex',
gap: '8px',
alignItems: 'center',
whiteSpace: 'nowrap',
flexShrink: 0,
fontSmooth: 'always',
WebkitFontSmoothing: 'antialiased',
textRendering: 'geometricPrecision',
}}
>
<span style={{
fontWeight: 600,
color: 'rgba(255,255,255,0.9)',
fontSize: '13px',
letterSpacing: '0.3px',
}}>
{item.label}
</span>
<span style={{
color: 'rgba(255,255,255,0.8)',
fontSize: '13px',
letterSpacing: '0.2px',
}}>
{item.displayValue}
</span>
<span style={{
color: item.change >= 0 ? '#2ecc71' : '#e74c3c',
fontSize: '11px',
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: '2px',
letterSpacing: '0.2px',
}}>
{item.change >= 0 ? '▲' : '▼'}
{Math.abs(item.change).toFixed(1)}%
</span>
{item.type === 'metal' && (
<span style={{
fontSize: '9px',
opacity: 0.3,
marginLeft: '-2px',
}}>
$
</span>
)}
</div>
))}
</motion.div>
</div>
{/* Время + Кнопка выхода */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
flexShrink: 0,
paddingLeft: '12px',
background: 'rgba(44, 62, 80, 0.95)',
zIndex: 2,
}}>
<div style={{
fontSize: '11px',
opacity: 0.4,
whiteSpace: 'nowrap',
fontSmooth: 'always',
WebkitFontSmoothing: 'antialiased',
}}>
{currentTime.toLocaleString('ru-RU', {
timeZone: 'Europe/Minsk',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})}
</div>
{onLogout && (
<button
onClick={handleLogout}
style={{
padding: '4px 14px',
background: 'rgba(231, 76, 60, 0.8)',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: 500, fontWeight: 500,
transition: 'all 0.3s ease',
whiteSpace: 'nowrap',
minHeight: '28px',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '2px', gap: '4px',
letterSpacing: '0.2px', }}
}}> onMouseEnter={(e) => {
{item.change >= 0 ? '▲' : '▼'} e.currentTarget.style.background = 'rgba(231, 76, 60, 1)';
{Math.abs(item.change).toFixed(1)}% e.currentTarget.style.transform = 'scale(1.05)';
</span> }}
{item.type === 'metal' && ( onMouseLeave={(e) => {
<span style={{ e.currentTarget.style.background = 'rgba(231, 76, 60, 0.8)';
fontSize: '9px', e.currentTarget.style.transform = 'scale(1)';
opacity: 0.3, }}
marginLeft: '-2px', >
}}> <span style={{ fontSize: '14px' }}>⏻</span>
$ Выйти
</span> </button>
)} )}
</div>
))}
</motion.div>
</div>
{/* ✅ Время с СЕКУНДАМИ и обновлением */}
<div style={{
marginLeft: 'auto',
fontSize: '11px',
opacity: 0.4,
whiteSpace: 'nowrap',
flexShrink: 0,
paddingLeft: '12px',
background: 'rgba(44, 62, 80, 0.95)',
zIndex: 2,
fontSmooth: 'always',
WebkitFontSmoothing: 'antialiased',
}}>
{currentTime.toLocaleString('ru-RU', {
timeZone: 'Europe/Minsk',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})}
</div>
{/* Пауза */}
{isPaused && (
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
fontSize: '11px',
color: 'rgba(255,255,255,0.3)',
pointerEvents: 'none',
zIndex: 3,
background: 'rgba(0,0,0,0.5)',
padding: '2px 12px',
borderRadius: '4px',
backdropFilter: 'blur(5px)',
}}>
⏸ Пауза
</div> </div>
)}
</motion.div> {/* Пауза */}
{isPaused && (
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
fontSize: '11px',
color: 'rgba(255,255,255,0.3)',
pointerEvents: 'none',
zIndex: 3,
background: 'rgba(0,0,0,0.5)',
padding: '2px 12px',
borderRadius: '4px',
backdropFilter: 'blur(5px)',
}}>
⏸ Пауза
</div>
)}
</motion.div>
</>
); );
}; };
export default BottomBar; export default BottomBar;
+2 -1
View File
@@ -53,6 +53,7 @@ const MainMenu: React.FC<MainMenuProps> = ({ onSectionSelect, employee }) => {
gap: '25px', gap: '25px',
maxWidth: '900px', maxWidth: '900px',
width: '100%', width: '100%',
//height: '100%',
justifyContent: 'center', justifyContent: 'center',
}}> }}>
{menuItems.map((item) => ( {menuItems.map((item) => (
@@ -65,7 +66,7 @@ const MainMenu: React.FC<MainMenuProps> = ({ onSectionSelect, employee }) => {
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: '12px', gap: '12px',
padding: '25px 20px', padding: '5px 5px',
background: 'rgba(255,255,255,0.95)', background: 'rgba(255,255,255,0.95)',
border: 'none', border: 'none',
borderRadius: '16px', borderRadius: '16px',