diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a9e90f3..e9fa578 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -48,8 +48,8 @@ function App() { // 📊 Состояния для таймера const [showInactiveWarning, setShowInactiveWarning] = useState(false); - // 📌 Реф для поля ввода логина (исправленный тип) - const loginInputRef = useRef(null!); // ✅ Исправлено + // 📌 Реф для поля ввода логина + const loginInputRef = useRef(null!); // ============================================ // 🛑 Функция ПОЛНОГО сброса приложения (как при запуске) @@ -57,7 +57,6 @@ function App() { const resetToInitialState = () => { console.log('🔄 ПОЛНЫЙ СБРОС ПРИЛОЖЕНИЯ — как при запуске'); - // ✅ Сбрасываем ВСЕ состояния setIsLoggedIn(false); setIsGuestMode(false); setEmployee(null); @@ -67,10 +66,8 @@ function App() { setShowInactiveWarning(false); setLoginError(''); - // ✅ ПОКАЗЫВАЕМ ВИДЕО-ЗАСТАВКУ СНАЧАЛА setShowVideo(true); - // ✅ Через 200ms ставим фокус на поле ввода (после видео) setTimeout(() => { if (loginInputRef.current) { loginInputRef.current.focus(); @@ -80,11 +77,11 @@ function App() { }; // ============================================ - // 🕐 Таймер неактивности (работает ВСЕГДА) + // 🕐 Таймер неактивности // ============================================ const { isWarning, timeLeft, resetTimer } = useActivityTimer({ - timeout: 30000, // 30 секунд до предупреждения - warningTimeout: 10000, // 10 секунд до сброса + timeout: 30000, + warningTimeout: 10000, onInactive: () => { console.log('🛑 Пользователь неактивен! ПОЛНЫЙ СБРОС ПРИЛОЖЕНИЯ'); resetToInitialState(); @@ -95,7 +92,6 @@ function App() { } }); - // 📌 Функция для сброса таймера при любом действии const handleUserActivity = () => { if (showInactiveWarning) { setShowInactiveWarning(false); @@ -128,18 +124,15 @@ function App() { } }; - // 👤 Режим гостя const handleGuestMode = () => { setIsGuestMode(true); handleUserActivity(); }; - // ↩️ Выход из системы const handleBack = () => { resetToInitialState(); }; - // ↩️ Возврат в главное меню const handleBackToMenu = () => { setCurrentSection(null); setShowPayslip(false); @@ -147,27 +140,23 @@ function App() { handleUserActivity(); }; - // 📂 Выбор раздела const handleSectionSelect = (section: string) => { setCurrentSection(section); handleUserActivity(); }; - // 📄 Просмотр расчетного листка const handleViewPayslip = (period: string) => { setSelectedPeriod(period); setShowPayslip(true); handleUserActivity(); }; - // ↩️ Назад из расчетного листка const handlePayslipBack = () => { setShowPayslip(false); setSelectedPeriod(''); handleUserActivity(); }; - // 🔗 URL для расчетного листка const getPayslipUrl = (employee: Employee, period: string) => { const fileName = `${employee.tnom}.pdf`; return `${config.backendUrl}/api/payslip/${period}/${fileName}`; @@ -263,12 +252,12 @@ function App() { // 📄 Рендер контента // ============================================ const renderContent = () => { - // 🎬 Если showVideo = true — показываем видео + // 🎬 Видео-заставка if (showVideo) { return setShowVideo(false)} />; } - // ✅ Если не залогинен и не гость — показываем окно входа + // 🔐 Экран логина if (!isLoggedIn && !isGuestMode) { return ( ; }; @@ -342,8 +330,10 @@ function App() { {/* 📄 Основной контент */} {renderContent()} - {/* 📱 Нижняя панель (только для авторизованных) */} - {isLoggedIn && !showPayslip && } + {/* 📱 Нижняя панель — ПОКАЗЫВАЕМ ВСЕГДА, кроме видео и расчетного листка */} + {!showVideo && !showPayslip && ( + + )} ); } diff --git a/frontend/src/components/BottomBar.tsx b/frontend/src/components/BottomBar.tsx index 9e35c0d..d1c57c2 100644 --- a/frontend/src/components/BottomBar.tsx +++ b/frontend/src/components/BottomBar.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef } from 'react'; -import { motion } from 'framer-motion'; -const NTP_URL = import.meta.env.VITE_NTP_SERVER || 'http://192.168.139.249:3001/api/time'; +import { motion, AnimatePresence } from 'framer-motion'; + interface CurrencyRate { currency: string; rate: number; @@ -13,7 +13,20 @@ interface MetalRate { 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 = ({ onLogout }) => { const [currencyRates, setCurrencyRates] = useState([ { currency: 'USD', rate: 3.22, change: 0.5 }, { currency: 'EUR', rate: 3.45, change: -0.2 }, @@ -30,37 +43,16 @@ const BottomBar: React.FC = () => { const [isOnline, setIsOnline] = useState(navigator.onLine); const [isPaused, setIsPaused] = useState(false); + const [currentTime, setCurrentTime] = useState(new Date()); + const [weatherData, setWeatherData] = useState([]); + const [weatherLoading, setWeatherLoading] = useState(false); + const [weatherError, setWeatherError] = useState(false); const scrollRef = useRef(null); - 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]; - -const NTP_URL = 'http://localhost:3005/api/time'; // или твой URL - -const useServerTime = (interval: number = 1000) => { - const [time, setTime] = useState(() => new Date()); + // ============================================ + // ⏰ ВРЕМЯ С СЕРВЕРА (объединенная логика) + // ============================================ + const NTP_URL = import.meta.env.VITE_NTP_SERVER || 'http://192.168.139.249:3001/api/time'; useEffect(() => { let timer: NodeJS.Timeout; @@ -73,42 +65,101 @@ const useServerTime = (interval: number = 1000) => { const data = await res.json(); const serverTime = new Date(data.time); - // Вычисляем разницу между серверным и локальным временем diff = serverTime.getTime() - Date.now(); + setCurrentTime(new Date(Date.now() + diff)); - // Устанавливаем время - setTime(new Date(Date.now() + diff)); - - // Запускаем локальный таймер if (timer) clearInterval(timer); timer = setInterval(() => { - setTime(new Date(Date.now() + diff)); - }, interval); + setCurrentTime(new Date(Date.now() + diff)); + }, 1000); } catch (err) { console.warn('Server time unavailable, using local time:', err); - // Если сервер недоступен — используем локальное время - setTime(new Date()); + setCurrentTime(new Date()); if (timer) clearInterval(timer); timer = setInterval(() => { - setTime(new Date()); - }, interval); + setCurrentTime(new Date()); + }, 1000); } }; - // Первый запрос fetchTime(); - // Очистка при размонтировании return () => { 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(() => { const handleOnline = () => setIsOnline(true); const handleOffline = () => setIsOnline(false); @@ -122,6 +173,9 @@ const currentTime = useServerTime(1000); }; }, []); + // ============================================ + // 📊 ЗАГРУЗКА КУРСОВ + // ============================================ useEffect(() => { const loadRates = async () => { try { @@ -147,208 +201,425 @@ const currentTime = useServerTime(1000); loadRates(); const interval = setInterval(loadRates, 60000); - return () => clearInterval(interval); }, [isOnline]); + // ============================================ + // 🎮 УПРАВЛЕНИЕ ПАУЗОЙ + // ============================================ const handleMouseEnter = () => setIsPaused(true); const handleMouseLeave = () => setIsPaused(false); const handleTouchStart = () => setIsPaused(true); const handleTouchEnd = () => setIsPaused(false); + // ============================================ + // 🚪 ВЫХОД + // ============================================ + const handleLogout = () => { + if (onLogout) { + onLogout(); + } + }; + + // ============================================ + // 🎨 ИКОНКА ПОГОДЫ + // ============================================ + const getWeatherIcon = (iconCode: string) => { + const icons: Record = { + '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 ( - - {/* Статус */} -
- - {isOnline ? '●' : '○'} - - КУРСЫ -
-
+ <> + {/* 🌤️ ПОЛОСКА ПОГОДЫ */} + + {isOnline && weatherData.length > 0 && !weatherLoading && !weatherError && ( + +
+ 🌤️ + + ГРОДНО + +
+
- {/* Бегущая строка */} -
- - {doubledItems.map((item, index) => (
- - {item.label} - - - {item.displayValue} - - = 0 ? '#2ecc71' : '#e74c3c', - fontSize: '11px', + + {[...weatherData, ...weatherData].map((day, index) => ( +
+ + {day.dayOfWeek} + + + {getWeatherIcon(day.icon)} + + + {Math.round(day.tempMin)}°…{Math.round(day.tempMax)}° + +
+ ))} +
+
+
+ )} + + + {/* 📊 ОСНОВНАЯ ПОЛОСКА (курсы валют) */} + + {/* Статус */} +
+ + {isOnline ? '●' : '○'} + + КУРСЫ +
+
+ + {/* Бегущая строка */} +
+ + {doubledItems.map((item, index) => ( +
+ + {item.label} + + + {item.displayValue} + + = 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)}% + + {item.type === 'metal' && ( + + $ + + )} +
+ ))} +
+
+ + {/* Время + Кнопка выхода */} +
+
+ {currentTime.toLocaleString('ru-RU', { + timeZone: 'Europe/Minsk', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + })} +
+ + {onLogout && ( +
- ))} - -
- - {/* ✅ Время с СЕКУНДАМИ и обновлением */} -
- {currentTime.toLocaleString('ru-RU', { - timeZone: 'Europe/Minsk', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hour12: false, - })} -
- - {/* Пауза */} - {isPaused && ( -
- ⏸ Пауза + gap: '4px', + }} + onMouseEnter={(e) => { + e.currentTarget.style.background = 'rgba(231, 76, 60, 1)'; + e.currentTarget.style.transform = 'scale(1.05)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.background = 'rgba(231, 76, 60, 0.8)'; + e.currentTarget.style.transform = 'scale(1)'; + }} + > + ⏻ + Выйти + + )}
- )} -
+ + {/* Пауза */} + {isPaused && ( +
+ ⏸ Пауза +
+ )} + + ); }; - export default BottomBar; \ No newline at end of file diff --git a/frontend/src/components/MenuComponent.tsx b/frontend/src/components/MenuComponent.tsx index f4be671..5bb6425 100644 --- a/frontend/src/components/MenuComponent.tsx +++ b/frontend/src/components/MenuComponent.tsx @@ -53,6 +53,7 @@ const MainMenu: React.FC = ({ onSectionSelect, employee }) => { gap: '25px', maxWidth: '900px', width: '100%', + //height: '100%', justifyContent: 'center', }}> {menuItems.map((item) => ( @@ -65,7 +66,7 @@ const MainMenu: React.FC = ({ onSectionSelect, employee }) => { alignItems: 'center', justifyContent: 'center', gap: '12px', - padding: '25px 20px', + padding: '5px 5px', background: 'rgba(255,255,255,0.95)', border: 'none', borderRadius: '16px',