Merge branch 'feature'
# Conflicts: # frontend/src/components/BottomBar.tsx
This commit is contained in:
+11
-21
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user