weather&bottom
This commit is contained in:
+24
-6
@@ -5,10 +5,11 @@ import LoginWindow from './components/LoginWindow';
|
||||
import GuestMode from './components/GuestMode';
|
||||
import MainMenu from './components/MenuComponent';
|
||||
import VideoBackground from './components/VideoBackground';
|
||||
import WeatherWidget from './components/WeatherWidget';
|
||||
import BottomBar from './components/BottomBar';
|
||||
import PayslipViewer from './components/PayslipViewer';
|
||||
import EmployeeInfo from './components/EmployeeInfo';
|
||||
import { useActivityTimer } from './components/useActivityTimer'
|
||||
import { useActivityTimer } from './components/useActivityTimer';
|
||||
import config from './config.json';
|
||||
|
||||
// Импорты разделов
|
||||
@@ -314,6 +315,9 @@ function App() {
|
||||
// ============================================
|
||||
// 🚀 Рендер приложения
|
||||
// ============================================
|
||||
const showTopBar = !showVideo && !showPayslip;
|
||||
const topBarHeight = 70; // высота виджета
|
||||
|
||||
return (
|
||||
<div
|
||||
className="app"
|
||||
@@ -327,12 +331,26 @@ function App() {
|
||||
{/* ⚠️ Предупреждение о неактивности */}
|
||||
{renderWarning()}
|
||||
|
||||
{/* 📄 Основной контент */}
|
||||
{renderContent()}
|
||||
{/* 🌤️ ВЕРХНИЙ ВИДЖЕТ: ПОГОДА + ВРЕМЯ + ВЫХОД */}
|
||||
{showTopBar && (
|
||||
<WeatherWidget onLogout={handleBack} />
|
||||
)}
|
||||
|
||||
{/* 📱 Нижняя панель — ПОКАЗЫВАЕМ ВСЕГДА, кроме видео и расчетного листка */}
|
||||
{!showVideo && !showPayslip && (
|
||||
<BottomBar onLogout={handleBack} />
|
||||
{/* 📄 Основной контент с отступом сверху */}
|
||||
<div style={{
|
||||
paddingTop: showTopBar ? topBarHeight : 0,
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{renderContent()}
|
||||
</div>
|
||||
|
||||
{/* 📊 НИЖНЯЯ ПАНЕЛЬ: КУРСЫ ВАЛЮТ */}
|
||||
{showTopBar && (
|
||||
<BottomBar />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface CurrencyRate {
|
||||
currency: string;
|
||||
@@ -13,20 +13,7 @@ interface MetalRate {
|
||||
change: number;
|
||||
}
|
||||
|
||||
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 BottomBar: React.FC = () => {
|
||||
const [currencyRates, setCurrencyRates] = useState<CurrencyRate[]>([
|
||||
{ currency: 'USD', rate: 3.22, change: 0.5 },
|
||||
{ currency: 'EUR', rate: 3.45, change: -0.2 },
|
||||
@@ -43,135 +30,12 @@ const BottomBar: React.FC<BottomBarProps> = ({ onLogout }) => {
|
||||
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine);
|
||||
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 [loading, setLoading] = useState(false);
|
||||
const [dataSource, setDataSource] = useState<'mysql' | 'excel' | 'default'>('default');
|
||||
const [lastUpdate, setLastUpdate] = useState<string>('');
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// ============================================
|
||||
// ⏰ ВРЕМЯ С СЕРВЕРА (объединенная логика)
|
||||
// ============================================
|
||||
const NTP_URL = import.meta.env.VITE_NTP_SERVER || 'http://192.168.139.249:3001/api/time';
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
let diff = 0;
|
||||
|
||||
const fetchTime = async () => {
|
||||
try {
|
||||
const res = await fetch(NTP_URL);
|
||||
if (!res.ok) throw new Error('Server time unavailable');
|
||||
const data = await res.json();
|
||||
const serverTime = new Date(data.time);
|
||||
|
||||
diff = serverTime.getTime() - Date.now();
|
||||
setCurrentTime(new Date(Date.now() + diff));
|
||||
|
||||
if (timer) clearInterval(timer);
|
||||
timer = setInterval(() => {
|
||||
setCurrentTime(new Date(Date.now() + diff));
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
console.warn('Server time unavailable, using local time:', err);
|
||||
setCurrentTime(new Date());
|
||||
if (timer) clearInterval(timer);
|
||||
timer = setInterval(() => {
|
||||
setCurrentTime(new Date());
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTime();
|
||||
|
||||
return () => {
|
||||
if (timer) clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ============================================
|
||||
// 🌤️ ЗАГРУЗКА ПОГОДЫ
|
||||
// ============================================
|
||||
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);
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, []);
|
||||
const BACKEND_URL = 'http://localhost:3001';
|
||||
|
||||
// ============================================
|
||||
// 📊 ЗАГРУЗКА КУРСОВ
|
||||
@@ -179,30 +43,102 @@ const BottomBar: React.FC<BottomBarProps> = ({ onLogout }) => {
|
||||
useEffect(() => {
|
||||
const loadRates = async () => {
|
||||
try {
|
||||
if (isOnline) {
|
||||
const response = await fetch('http://localhost:3001/api/rates');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCurrencyRates(data.currency || currencyRates);
|
||||
setMetalRates(data.metals || metalRates);
|
||||
setLoading(true);
|
||||
|
||||
// ✅ Сначала пробуем MySQL
|
||||
try {
|
||||
const mysqlResponse = await fetch(`${BACKEND_URL}/api/rates/mysql`);
|
||||
if (mysqlResponse.ok) {
|
||||
const data = await mysqlResponse.json();
|
||||
if (data.currency && data.currency.length > 0) {
|
||||
setCurrencyRates(data.currency);
|
||||
setMetalRates(data.metals || []);
|
||||
setDataSource('mysql');
|
||||
setLastUpdate(new Date().toLocaleString('ru-RU'));
|
||||
// Сохраняем в localStorage
|
||||
localStorage.setItem('ratesData', JSON.stringify({
|
||||
currency: data.currency,
|
||||
metals: data.metals,
|
||||
source: 'mysql',
|
||||
lastUpdate: new Date().toISOString()
|
||||
}));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const stored = localStorage.getItem('currencyRates');
|
||||
if (stored) {
|
||||
} catch (mysqlError) {
|
||||
console.warn('MySQL недоступен, пробуем Excel:', mysqlError);
|
||||
}
|
||||
|
||||
// ✅ Если MySQL не работает — пробуем Excel
|
||||
try {
|
||||
const excelResponse = await fetch(`${BACKEND_URL}/api/rates/excel`);
|
||||
if (excelResponse.ok) {
|
||||
const data = await excelResponse.json();
|
||||
if (data.currency && data.currency.length > 0) {
|
||||
setCurrencyRates(data.currency);
|
||||
setMetalRates(data.metals || []);
|
||||
setDataSource('excel');
|
||||
setLastUpdate(new Date().toLocaleString('ru-RU'));
|
||||
// Сохраняем в localStorage
|
||||
localStorage.setItem('ratesData', JSON.stringify({
|
||||
currency: data.currency,
|
||||
metals: data.metals,
|
||||
source: 'excel',
|
||||
lastUpdate: new Date().toISOString()
|
||||
}));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (excelError) {
|
||||
console.warn('Excel недоступен:', excelError);
|
||||
}
|
||||
|
||||
// ✅ Если ничего не работает — берём из localStorage
|
||||
const stored = localStorage.getItem('ratesData');
|
||||
if (stored) {
|
||||
try {
|
||||
const data = JSON.parse(stored);
|
||||
setCurrencyRates(data.currency || currencyRates);
|
||||
setMetalRates(data.metals || metalRates);
|
||||
if (data.currency) {
|
||||
setCurrencyRates(data.currency);
|
||||
setMetalRates(data.metals || []);
|
||||
setDataSource(data.source || 'default');
|
||||
if (data.lastUpdate) {
|
||||
setLastUpdate(new Date(data.lastUpdate).toLocaleString('ru-RU'));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Ошибка загрузки из localStorage:', e);
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
|
||||
} catch (error) {
|
||||
console.warn('Ошибка загрузки курсов:', error);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadRates();
|
||||
const interval = setInterval(loadRates, 60000);
|
||||
const interval = setInterval(loadRates, 60000); // Обновление каждую минуту
|
||||
return () => clearInterval(interval);
|
||||
}, [isOnline]);
|
||||
}, []);
|
||||
|
||||
// ============================================
|
||||
// 🌐 СТАТУС ИНТЕРНЕТА
|
||||
// ============================================
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setIsOnline(true);
|
||||
const handleOffline = () => setIsOnline(false);
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ============================================
|
||||
// 🎮 УПРАВЛЕНИЕ ПАУЗОЙ
|
||||
@@ -212,42 +148,6 @@ const BottomBar: React.FC<BottomBarProps> = ({ onLogout }) => {
|
||||
const handleTouchStart = () => setIsPaused(true);
|
||||
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] || '🌡️';
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 📦 ВСЕ ЭЛЕМЕНТЫ ДЛЯ БЕГУЩЕЙ СТРОКИ
|
||||
// ============================================
|
||||
@@ -267,7 +167,8 @@ const BottomBar: React.FC<BottomBarProps> = ({ onLogout }) => {
|
||||
label: item.metal,
|
||||
value: item.price,
|
||||
change: item.change,
|
||||
displayValue: `$${item.price.toFixed(item.price < 10 ? 2 : 0)}`,
|
||||
displayValue: item.price.toFixed(item.price < 10 ? 2 : 0),
|
||||
currencySymbol: 'Br',
|
||||
}));
|
||||
|
||||
return [...currencyItems, ...metalItems];
|
||||
@@ -277,348 +178,244 @@ const BottomBar: React.FC<BottomBarProps> = ({ onLogout }) => {
|
||||
const doubledItems = [...items, ...items, ...items];
|
||||
|
||||
// ============================================
|
||||
// 🎨 РЕНДЕР
|
||||
// 🔄 ИНДИКАТОР ЗАГРУЗКИ
|
||||
// ============================================
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: 5,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: 'rgba(44, 62, 80, 0.95)',
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
padding: '10px 20px',
|
||||
textAlign: 'center',
|
||||
fontSize: '12px',
|
||||
zIndex: 1000,
|
||||
borderTop: '1px solid rgba(255,255,255,0.1)',
|
||||
height: '50px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
⏳ Загрузка курсов...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 🎨 ОСНОВНОЙ РЕНДЕР
|
||||
// ============================================
|
||||
return (
|
||||
<>
|
||||
{/* 🌤️ ПОЛОСКА ПОГОДЫ */}
|
||||
<AnimatePresence>
|
||||
{isOnline && weatherData.length > 0 && !weatherLoading && !weatherError && (
|
||||
<motion.div
|
||||
initial={{ y: -30, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: -30, opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '44px',
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 999,
|
||||
background: 'rgba(44, 62, 80, 0.85)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
color: '#fff',
|
||||
padding: '4px 20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
overflow: 'hidden',
|
||||
height: '32px',
|
||||
borderTop: '1px solid rgba(255,255,255,0.05)',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.05)',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
flexShrink: 0,
|
||||
background: 'rgba(44, 62, 80, 0.85)',
|
||||
paddingRight: '12px',
|
||||
}}>
|
||||
<span style={{ fontSize: '16px' }}>🌤️</span>
|
||||
<span style={{ fontWeight: 600, opacity: 0.7, fontSize: '11px' }}>
|
||||
ГРОДНО
|
||||
</span>
|
||||
<div style={{
|
||||
width: '1px',
|
||||
height: '20px',
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
marginLeft: '4px',
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
animate={{
|
||||
x: isPaused ? 0 : ['0%', '-50%'],
|
||||
}}
|
||||
transition={{
|
||||
duration: 20,
|
||||
repeat: Infinity,
|
||||
ease: 'linear',
|
||||
repeatType: 'loop',
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '30px',
|
||||
whiteSpace: 'nowrap',
|
||||
willChange: 'transform',
|
||||
transform: 'translateZ(0)',
|
||||
paddingRight: '30px',
|
||||
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',
|
||||
<motion.div
|
||||
className="bottom-bar"
|
||||
initial={{ y: 100 }}
|
||||
animate={{ y: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 5,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: 'rgba(44, 62, 80, 0.95)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
color: '#fff',
|
||||
padding: '0 20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
overflow: 'hidden',
|
||||
zIndex: 1000,
|
||||
height: '50px',
|
||||
borderTop: '1px solid rgba(255,255,255,0.1)',
|
||||
fontSize: '18px',
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{/* Статус и источник данных */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
flexShrink: 0,
|
||||
zIndex: 2,
|
||||
background: 'rgba(44, 62, 80, 0.95)',
|
||||
paddingRight: '12px',
|
||||
height: '100%',
|
||||
}}>
|
||||
<span style={{
|
||||
color: isOnline ? '#2ecc71' : '#e74c3c',
|
||||
fontSize: '10px',
|
||||
}}>
|
||||
<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>
|
||||
{isOnline ? '●' : '○'}
|
||||
</span>
|
||||
<span style={{ fontWeight: 600, opacity: 0.6, fontSize: '10px' }}>КУРСЫ</span>
|
||||
|
||||
{/* ✅ Индикатор источника данных (без кнопки) */}
|
||||
<span style={{
|
||||
padding: '2px 8px',
|
||||
background: dataSource === 'mysql' ? 'rgba(46, 204, 113, 0.2)' :
|
||||
dataSource === 'excel' ? 'rgba(52, 152, 219, 0.2)' :
|
||||
'rgba(255, 193, 7, 0.2)',
|
||||
color: dataSource === 'mysql' ? '#2ecc71' :
|
||||
dataSource === 'excel' ? '#3498db' :
|
||||
'#f39c12',
|
||||
border: '1px solid ' + (dataSource === 'mysql' ? 'rgba(46,204,113,0.3)' :
|
||||
dataSource === 'excel' ? 'rgba(52,152,219,0.3)' :
|
||||
'rgba(255,193,7,0.3)'),
|
||||
borderRadius: '4px',
|
||||
fontSize: '8px',
|
||||
fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{dataSource === 'mysql' ? 'MySQL' :
|
||||
dataSource === 'excel' ? '📊' :
|
||||
'💾'}
|
||||
</span>
|
||||
|
||||
<div style={{
|
||||
width: '1px',
|
||||
height: '20px',
|
||||
background: 'rgba(255,255,255,0.15)',
|
||||
marginLeft: '4px',
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* Бегущая строка */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
{/* Бегущая строка */}
|
||||
<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={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
gap: '30px',
|
||||
whiteSpace: 'nowrap',
|
||||
willChange: 'transform',
|
||||
transform: 'translateZ(0)',
|
||||
backfaceVisibility: 'hidden',
|
||||
WebkitFontSmoothing: 'antialiased',
|
||||
MozOsxFontSmoothing: 'grayscale',
|
||||
textRendering: 'geometricPrecision',
|
||||
paddingRight: '30px',
|
||||
height: '100%',
|
||||
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}
|
||||
{doubledItems.map((item, index) => (
|
||||
<div
|
||||
key={`${item.id}-${index}`}
|
||||
style={{
|
||||
padding: '4px 14px',
|
||||
background: 'rgba(231, 76, 60, 0.8)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
transition: 'all 0.3s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
minHeight: '28px',
|
||||
display: 'flex',
|
||||
gap: '6px',
|
||||
alignItems: 'center',
|
||||
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)';
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '14px' }}>⏻</span>
|
||||
Выйти
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span style={{
|
||||
fontWeight: 600,
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
fontSize: '12px',
|
||||
letterSpacing: '0.3px',
|
||||
}}>
|
||||
{item.label}
|
||||
</span>
|
||||
<span style={{
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
fontSize: '12px',
|
||||
letterSpacing: '0.2px',
|
||||
}}>
|
||||
{item.displayValue}
|
||||
</span>
|
||||
{item.type === 'metal' && (
|
||||
<span style={{
|
||||
fontSize: '8px',
|
||||
opacity: 0.3,
|
||||
marginLeft: '-2px',
|
||||
}}>
|
||||
{item.currencySymbol || 'Br'}
|
||||
</span>
|
||||
)}
|
||||
<span style={{
|
||||
color: item.change >= 0 ? '#2ecc71' : '#e74c3c',
|
||||
fontSize: '10px',
|
||||
fontWeight: 500,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2px',
|
||||
letterSpacing: '0.2px',
|
||||
}}>
|
||||
{item.change >= 0 ? '▲' : '▼'}
|
||||
{Math.abs(item.change).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
</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 style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
flexShrink: 0,
|
||||
paddingLeft: '8px',
|
||||
}}>
|
||||
{lastUpdate && (
|
||||
<span style={{
|
||||
fontSize: '8px',
|
||||
opacity: 0.3,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
⏸ Пауза
|
||||
</div>
|
||||
{lastUpdate}
|
||||
</span>
|
||||
)}
|
||||
</motion.div>
|
||||
</>
|
||||
</div>
|
||||
|
||||
{/* Пауза */}
|
||||
{isPaused && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
fontSize: '10px',
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,56 +1,370 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { FaArrowLeft } from 'react-icons/fa';
|
||||
import { FaArrowLeft, FaSearch, FaArrowUp, FaArrowDown } from 'react-icons/fa';
|
||||
import MapViewer from './MapViewer';
|
||||
import config from '../config.json';
|
||||
|
||||
interface ContactsProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 🔧 Парсер телефонов из HTML
|
||||
// ============================================
|
||||
function parseHandbookBlocks(html: string) {
|
||||
const blocks: (string | { type: 'phones', rows: { desc: string, internal: string, city: string }[] })[] = [];
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = html;
|
||||
let phoneRows: { desc: string, internal: string, city: string }[] = [];
|
||||
let lastDesc = '';
|
||||
const phoneRegex = /(\d{2,3}-\d{2,3}(?:-\d{2,3})?|\d{4}\s\d{6,7})/g;
|
||||
const children = Array.from(container.childNodes);
|
||||
let htmlBuffer = '';
|
||||
const flushHtmlBuffer = () => {
|
||||
if (htmlBuffer) {
|
||||
blocks.push(htmlBuffer);
|
||||
htmlBuffer = '';
|
||||
}
|
||||
};
|
||||
const flushPhoneRows = () => {
|
||||
if (phoneRows.length > 1) {
|
||||
blocks.push({ type: 'phones', rows: phoneRows });
|
||||
} else if (phoneRows.length === 1) {
|
||||
const row = phoneRows[0];
|
||||
blocks.push(`<p>${row.desc} ${row.internal}${row.city ? ' ' + row.city : ''}</p>`);
|
||||
}
|
||||
phoneRows = [];
|
||||
};
|
||||
children.forEach(node => {
|
||||
if (node.nodeType === 1) {
|
||||
const tag = (node as HTMLElement).tagName.toLowerCase();
|
||||
const text = (node as HTMLElement).textContent?.trim() || '';
|
||||
const phones = Array.from(text.matchAll(phoneRegex), m => m[1]);
|
||||
if ((tag === 'p' || tag === 'li') && phones.length >= 1) {
|
||||
flushHtmlBuffer();
|
||||
if (phones.length === 2) {
|
||||
const desc = text.replace(phoneRegex, '').trim();
|
||||
phoneRows.push({ desc, internal: phones[0], city: phones[1] });
|
||||
lastDesc = desc;
|
||||
} else if (phones.length === 1) {
|
||||
phoneRows.push({ desc: lastDesc, internal: phones[0], city: '' });
|
||||
} else if (text && !phones.length) {
|
||||
lastDesc = text;
|
||||
}
|
||||
} else {
|
||||
if (phoneRows.length) {
|
||||
flushPhoneRows();
|
||||
}
|
||||
if ((node as HTMLElement).outerHTML) {
|
||||
htmlBuffer += (node as HTMLElement).outerHTML;
|
||||
} else {
|
||||
htmlBuffer += node.textContent || '';
|
||||
}
|
||||
}
|
||||
} else if (node.nodeType === 3) {
|
||||
htmlBuffer += node.textContent;
|
||||
}
|
||||
});
|
||||
if (phoneRows.length) {
|
||||
flushPhoneRows();
|
||||
}
|
||||
if (htmlBuffer) {
|
||||
blocks.push(htmlBuffer);
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 📚 Функции рендеринга с подсветкой и поиском
|
||||
// ============================================
|
||||
const ADMIN_SERVER_URL = config.adminServerUrl || 'http://localhost:3005';
|
||||
|
||||
function patchMediaSrc(html: string): string {
|
||||
return html
|
||||
.replace(/<img([^>]+)src=["'](\/uploads\/[^"']+)["']/g, `<img$1src="${ADMIN_SERVER_URL}$2"`)
|
||||
.replace(/<video([^>]+)src=["'](\/uploads\/[^"']+)["']/g, `<video$1src="${ADMIN_SERVER_URL}$2"`);
|
||||
}
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = html;
|
||||
return tmp.textContent || tmp.innerText || '';
|
||||
}
|
||||
|
||||
// ✅ Подсветка текста внутри HTML с сохранением разметки
|
||||
function highlightTextInHtml(html: string, searchTerm: string): { html: string; count: number } {
|
||||
if (!searchTerm.trim()) return { html, count: 0 };
|
||||
|
||||
const term = searchTerm.trim();
|
||||
const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp(`(${escapedTerm})`, 'gi');
|
||||
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = html;
|
||||
let matchCount = 0;
|
||||
|
||||
function walkNodes(node: Node): void {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.textContent || '';
|
||||
if (text.toLowerCase().includes(term.toLowerCase())) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
const tempRegex = new RegExp(escapedTerm, 'gi');
|
||||
|
||||
while ((match = tempRegex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
fragment.appendChild(document.createTextNode(text.substring(lastIndex, match.index)));
|
||||
}
|
||||
const mark = document.createElement('mark');
|
||||
mark.style.cssText = 'background: #ffdd57; color: #1a3c34; padding: 0 4px; border-radius: 3px; font-weight: 600;';
|
||||
mark.dataset.matchIndex = String(matchCount);
|
||||
mark.textContent = match[0];
|
||||
fragment.appendChild(mark);
|
||||
lastIndex = match.index + match[0].length;
|
||||
matchCount++;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
fragment.appendChild(document.createTextNode(text.substring(lastIndex)));
|
||||
}
|
||||
node.parentNode?.replaceChild(fragment, node);
|
||||
}
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const children = Array.from(node.childNodes);
|
||||
for (const child of children) {
|
||||
walkNodes(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walkNodes(temp);
|
||||
return { html: temp.innerHTML, count: matchCount };
|
||||
}
|
||||
|
||||
function blockMatchesSearch(block: any, searchTerm: string): boolean {
|
||||
if (!searchTerm.trim()) return true;
|
||||
const term = searchTerm.trim().toLowerCase();
|
||||
if (typeof block === 'string') {
|
||||
return stripHtml(block).toLowerCase().includes(term);
|
||||
} else if (block.type === 'phones') {
|
||||
return block.rows.some((row: { desc: string; internal: string; city: string }) =>
|
||||
row.desc.toLowerCase().includes(term) ||
|
||||
row.internal.includes(term) ||
|
||||
row.city.includes(term)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 📦 Компонент для рендеринга справочника
|
||||
// ============================================
|
||||
const HandbookRenderer: React.FC<{
|
||||
blocks: any[];
|
||||
searchTerm: string;
|
||||
onMatchCount: (count: number) => void;
|
||||
}> = ({ blocks, searchTerm, onMatchCount }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [renderedHtml, setRenderedHtml] = useState<{ html: string; count: number }[]>([]);
|
||||
const [totalMatches, setTotalMatches] = useState(0);
|
||||
const hasSearch = searchTerm.trim().length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!blocks.length) return;
|
||||
|
||||
const result: { html: string; count: number }[] = [];
|
||||
let total = 0;
|
||||
|
||||
blocks.forEach(block => {
|
||||
if (typeof block === 'string') {
|
||||
const content = patchMediaSrc(block);
|
||||
if (hasSearch) {
|
||||
const highlighted = highlightTextInHtml(content, searchTerm);
|
||||
result.push({ html: highlighted.html, count: highlighted.count });
|
||||
total += highlighted.count;
|
||||
} else {
|
||||
result.push({ html: content, count: 0 });
|
||||
}
|
||||
} else if (block.type === 'phones') {
|
||||
const term = searchTerm.trim().toLowerCase();
|
||||
let rows = block.rows;
|
||||
if (hasSearch && term) {
|
||||
rows = rows.filter((row: { desc: string; internal: string; city: string }) =>
|
||||
row.desc.toLowerCase().includes(term) ||
|
||||
row.internal.includes(term) ||
|
||||
row.city.includes(term)
|
||||
);
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
result.push({ html: '', count: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
let tableHtml = `<table class="handbook-table">`;
|
||||
tableHtml += `
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Описание</th>
|
||||
<th>АТС предприятия</th>
|
||||
<th>Городская АТС</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>`;
|
||||
|
||||
rows.forEach((row: { desc: string; internal: string; city: string }) => {
|
||||
let descHtml = row.desc;
|
||||
let internalHtml = row.internal;
|
||||
let cityHtml = row.city;
|
||||
let rowHasMatch = false;
|
||||
|
||||
if (hasSearch && term) {
|
||||
const descResult = highlightTextInHtml(row.desc, searchTerm);
|
||||
descHtml = descResult.html;
|
||||
if (descResult.count > 0) rowHasMatch = true;
|
||||
const internalResult = highlightTextInHtml(row.internal, searchTerm);
|
||||
internalHtml = internalResult.html;
|
||||
if (internalResult.count > 0) rowHasMatch = true;
|
||||
const cityResult = highlightTextInHtml(row.city, searchTerm);
|
||||
cityHtml = cityResult.html;
|
||||
if (cityResult.count > 0) rowHasMatch = true;
|
||||
total += descResult.count + internalResult.count + cityResult.count;
|
||||
}
|
||||
|
||||
tableHtml += `
|
||||
<tr>
|
||||
<td>${descHtml}</td>
|
||||
<td>${internalHtml}</td>
|
||||
<td>${cityHtml}</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
tableHtml += `</tbody></table>`;
|
||||
result.push({ html: tableHtml, count: 0 });
|
||||
}
|
||||
});
|
||||
|
||||
setRenderedHtml(result);
|
||||
setTotalMatches(total);
|
||||
onMatchCount(total);
|
||||
}, [blocks, searchTerm, onMatchCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const marks = containerRef.current.querySelectorAll('mark');
|
||||
marks.forEach((el, idx) => {
|
||||
el.dataset.matchIndex = String(idx);
|
||||
});
|
||||
}, [renderedHtml]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="handbook-content">
|
||||
{renderedHtml.map((item, idx) => (
|
||||
item.html ? <div key={idx} style={{ marginBottom: 16 }} dangerouslySetInnerHTML={{ __html: item.html }} /> : null
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 🏠 Основной компонент Contacts
|
||||
// ============================================
|
||||
const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<'map' | 'directory'>('directory');
|
||||
const [showMapViewer, setShowMapViewer] = useState(false);
|
||||
const [blocks, setBlocks] = useState<any[]>([]);
|
||||
const [loadingHandbook, setLoadingHandbook] = useState(false);
|
||||
const [errorHandbook, setErrorHandbook] = useState<string | null>(null);
|
||||
const [matchCount, setMatchCount] = useState(0);
|
||||
const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// ✅ Путь к TIFF-файлу (вместо PDF)
|
||||
const MAP_FILE_URL = '/assets/maps/enterprise-map.tiff';
|
||||
|
||||
const directoryData = [
|
||||
{ name: 'Иванов Иван Иванович', position: 'Директор', phone: '123-45-67', department: 'Руководство' },
|
||||
{ name: 'Петров Петр Петрович', position: 'Главный инженер', phone: '123-45-68', department: 'Руководство' },
|
||||
{ name: 'Сидорова Мария Сергеевна', position: 'Бухгалтер', phone: '123-45-69', department: 'Бухгалтерия' },
|
||||
{ name: 'Козлов Андрей Владимирович', position: 'Начальник цеха', phone: '123-45-70', department: 'Производство' },
|
||||
];
|
||||
useEffect(() => {
|
||||
if (activeTab === 'directory' && blocks.length === 0 && !loadingHandbook) {
|
||||
loadHandbook();
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
const emergencyNumbers = [
|
||||
{ name: 'Пожарная служба', phone: '101', icon: '🧯' },
|
||||
{ name: 'Милиция', phone: '102', icon: '🛡️' },
|
||||
{ name: 'Скорая помощь', phone: '103', icon: '🚑' },
|
||||
{ name: 'Газовая служба', phone: '104', icon: '📞' },
|
||||
];
|
||||
|
||||
const filteredData = directoryData.filter(item =>
|
||||
item.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.position.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.department.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const handleOpenMap = () => {
|
||||
setShowMapViewer(true);
|
||||
const loadHandbook = async () => {
|
||||
setLoadingHandbook(true);
|
||||
setErrorHandbook(null);
|
||||
try {
|
||||
const url = `${config.backendUrl}/api/handbook/html`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const html = await response.text();
|
||||
const parsed = parseHandbookBlocks(html);
|
||||
setBlocks(parsed);
|
||||
} catch (error) {
|
||||
console.error('[Contacts] Ошибка загрузки справочника:', error);
|
||||
setErrorHandbook(error instanceof Error ? error.message : 'Неизвестная ошибка');
|
||||
} finally {
|
||||
setLoadingHandbook(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMapBack = () => {
|
||||
setShowMapViewer(false);
|
||||
const handleOpenMap = () => setShowMapViewer(true);
|
||||
const handleMapBack = () => setShowMapViewer(false);
|
||||
|
||||
const goToNextMatch = useCallback(() => {
|
||||
if (matchCount === 0) return;
|
||||
const nextIndex = (currentMatchIndex + 1) % matchCount;
|
||||
setCurrentMatchIndex(nextIndex);
|
||||
scrollToMatch(nextIndex);
|
||||
}, [matchCount, currentMatchIndex]);
|
||||
|
||||
const goToPrevMatch = useCallback(() => {
|
||||
if (matchCount === 0) return;
|
||||
const prevIndex = (currentMatchIndex - 1 + matchCount) % matchCount;
|
||||
setCurrentMatchIndex(prevIndex);
|
||||
scrollToMatch(prevIndex);
|
||||
}, [matchCount, currentMatchIndex]);
|
||||
|
||||
const scrollToMatch = useCallback((index: number) => {
|
||||
const container = contentRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const marks = container.querySelectorAll('mark[data-match-index]');
|
||||
if (marks.length === 0) return;
|
||||
|
||||
const targetMark = Array.from(marks).find((el: Element) => el.getAttribute('data-match-index') === String(index));
|
||||
if (!targetMark) return;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const markRect = targetMark.getBoundingClientRect();
|
||||
const scrollTop = container.scrollTop + markRect.top - containerRect.top - 80;
|
||||
container.scrollTo({ top: scrollTop, behavior: 'smooth' });
|
||||
|
||||
const markElement = targetMark as HTMLElement;
|
||||
markElement.style.outline = '2px solid #ff6b6b';
|
||||
markElement.style.outlineOffset = '2px';
|
||||
setTimeout(() => {
|
||||
markElement.style.outline = 'none';
|
||||
markElement.style.outlineOffset = 'none';
|
||||
}, 2000);
|
||||
}, []);
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (matchCount > 0) {
|
||||
goToNextMatch();
|
||||
}
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentMatchIndex(0);
|
||||
}, [searchTerm]);
|
||||
|
||||
if (showMapViewer) {
|
||||
return (
|
||||
<MapViewer
|
||||
fileUrl={MAP_FILE_URL}
|
||||
onBack={handleMapBack}
|
||||
title="Карта предприятия"
|
||||
/>
|
||||
);
|
||||
return <MapViewer fileUrl={MAP_FILE_URL} onBack={handleMapBack} title="Карта предприятия" />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -65,17 +379,12 @@ const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
|
||||
maxWidth: '1200px',
|
||||
margin: '0 auto',
|
||||
paddingBottom: '80px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: 'calc(100vh - 80px)',
|
||||
}}
|
||||
>
|
||||
{/* Верхняя панель */}
|
||||
<div className="top-bar" style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '25px',
|
||||
flexWrap: 'wrap',
|
||||
gap: '10px'
|
||||
}}>
|
||||
<div className="top-bar" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '25px', flexWrap: 'wrap', gap: '10px', flexShrink: 0 }}>
|
||||
<button
|
||||
className="back-button"
|
||||
onClick={onBack}
|
||||
@@ -114,7 +423,6 @@ const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
|
||||
<div style={{ width: '100px' }} />
|
||||
</div>
|
||||
|
||||
{/* Карточка карты */}
|
||||
<motion.div
|
||||
whileHover={{ y: -5, boxShadow: '0 8px 30px rgba(46, 204, 113, 0.3)' }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
@@ -131,53 +439,30 @@ const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
minHeight: '80px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '20px',
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '36px',
|
||||
color: 'white',
|
||||
}}>
|
||||
🗺️
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '20px' }}>
|
||||
<div style={{ fontSize: '36px', color: 'white' }}>🗺️</div>
|
||||
<div>
|
||||
<h3 style={{
|
||||
margin: '0 0 5px 0',
|
||||
color: 'white',
|
||||
fontSize: '20px',
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 5px 0', color: 'white', fontSize: '20px', fontWeight: 500 }}>
|
||||
Карта предприятия
|
||||
</h3>
|
||||
<p style={{
|
||||
margin: 0,
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
<p style={{ margin: 0, color: 'rgba(255,255,255,0.85)', fontSize: '14px' }}>
|
||||
Схема проезда, план здания, расположение корпусов
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
color: 'white',
|
||||
fontSize: '20px',
|
||||
opacity: 0.8,
|
||||
}}>
|
||||
→
|
||||
</div>
|
||||
<div style={{ color: 'white', fontSize: '20px', opacity: 0.8 }}>→</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Вкладки */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
marginBottom: '25px',
|
||||
borderBottom: '2px solid #f0f0f0',
|
||||
paddingBottom: '10px',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setActiveTab('directory')}
|
||||
@@ -213,176 +498,329 @@ const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Контент вкладок */}
|
||||
{activeTab === 'directory' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{/* Экстренные номера */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
|
||||
gap: '12px',
|
||||
marginBottom: '25px',
|
||||
}}>
|
||||
{emergencyNumbers.map((item, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
style={{
|
||||
background: '#e74c3c',
|
||||
color: 'white',
|
||||
padding: '15px',
|
||||
borderRadius: '12px',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 4px 15px rgba(231, 76, 60, 0.3)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '24px', marginBottom: '5px' }}>{item.icon}</div>
|
||||
<div style={{ fontSize: '13px', fontWeight: 500 }}>{item.name}</div>
|
||||
<div style={{ fontSize: '20px', fontWeight: 700 }}>{item.phone}</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Поиск */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.06)',
|
||||
marginBottom: '20px',
|
||||
}}>
|
||||
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
{activeTab === 'directory' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
style={{ display: 'flex', flexDirection: 'column', height: '100%' }}
|
||||
>
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '16px 20px',
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.06)',
|
||||
marginBottom: '20px',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
background: '#f8f9fa',
|
||||
borderRadius: '10px',
|
||||
padding: '8px 16px',
|
||||
border: '2px solid #dfe6e9',
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
🔍
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск по справочнику..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
style={{
|
||||
flex: 1,
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
padding: '10px 0',
|
||||
fontSize: '15px',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Таблица */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.06)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead style={{ background: '#f8f9fa', borderBottom: '2px solid #f1f2f6' }}>
|
||||
<tr>
|
||||
<th style={{ padding: '15px 20px', textAlign: 'left', fontSize: '13px', color: '#636e72' }}>ФИО</th>
|
||||
<th style={{ padding: '15px 20px', textAlign: 'left', fontSize: '13px', color: '#636e72' }}>Должность</th>
|
||||
<th style={{ padding: '15px 20px', textAlign: 'left', fontSize: '13px', color: '#636e72' }}>Телефон</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredData.map((item, index) => (
|
||||
<motion.tr
|
||||
key={index}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
style={{
|
||||
borderBottom: '1px solid #f1f2f6',
|
||||
transition: 'background 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = '#f8f9fa'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<td style={{ padding: '12px 20px', fontSize: '14px' }}>{item.name}</td>
|
||||
<td style={{ padding: '12px 20px', fontSize: '14px', color: '#636e72' }}>{item.position}</td>
|
||||
<td style={{ padding: '12px 20px', fontSize: '14px', color: '#6c5ce7', fontWeight: 500 }}>
|
||||
📞 {item.phone}
|
||||
</td>
|
||||
</motion.tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{filteredData.length === 0 && (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#636e72' }}>
|
||||
Ничего не найдено
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
background: '#f8f9fa',
|
||||
borderRadius: '10px',
|
||||
padding: '4px 16px',
|
||||
border: '2px solid #dfe6e9',
|
||||
flex: 1,
|
||||
minWidth: '200px',
|
||||
}}>
|
||||
🔍
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
inputMode="search"
|
||||
autoFocus
|
||||
placeholder="Поиск по справочнику..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
}}
|
||||
onKeyPress={handleKeyPress}
|
||||
style={{
|
||||
flex: 1,
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
padding: '10px 0',
|
||||
fontSize: '15px',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeTab === 'map' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
style={{
|
||||
background: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '60px 40px',
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.06)',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
border: '2px dashed #dfe6e9',
|
||||
minHeight: '300px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
onClick={handleOpenMap}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#2ecc71';
|
||||
e.currentTarget.style.boxShadow = '0 8px 30px rgba(46, 204, 113, 0.15)';
|
||||
e.currentTarget.style.transform = 'translateY(-3px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = '#dfe6e9';
|
||||
e.currentTarget.style.boxShadow = '0 4px 20px rgba(0,0,0,0.06)';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '64px', marginBottom: '15px' }}>🗺️</div>
|
||||
<h3 style={{ color: '#2d3436', marginBottom: '10px', fontSize: '22px' }}>
|
||||
Карта предприятия
|
||||
</h3>
|
||||
<p style={{ color: '#636e72', marginBottom: '20px', fontSize: '15px' }}>
|
||||
Нажмите для просмотра карты в полноэкранном режиме
|
||||
</p>
|
||||
<div style={{
|
||||
display: 'inline-block',
|
||||
padding: '12px 30px',
|
||||
background: 'linear-gradient(135deg, #2ecc71, #27ae60)',
|
||||
color: 'white',
|
||||
borderRadius: '10px',
|
||||
fontSize: '15px',
|
||||
fontWeight: 500,
|
||||
transition: 'all 0.3s ease',
|
||||
}}>
|
||||
Открыть карту →
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
{searchTerm.trim() && matchCount > 0 && (
|
||||
<span style={{
|
||||
fontSize: '13px',
|
||||
color: '#636e72',
|
||||
whiteSpace: 'nowrap',
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
{currentMatchIndex + 1} / {matchCount}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '6px',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<button
|
||||
onClick={goToPrevMatch}
|
||||
disabled={matchCount === 0}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: matchCount > 0 ? 'linear-gradient(135deg, #2ecc71, #27ae60)' : '#dfe6e9',
|
||||
color: matchCount > 0 ? 'white' : '#b2bec3',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: matchCount > 0 ? 'pointer' : 'default',
|
||||
fontSize: '14px',
|
||||
transition: 'all 0.3s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: '40px',
|
||||
minHeight: '40px',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (matchCount > 0) {
|
||||
e.currentTarget.style.transform = 'scale(1.05)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
<FaArrowUp size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={goToNextMatch}
|
||||
disabled={matchCount === 0}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: matchCount > 0 ? 'linear-gradient(135deg, #2ecc71, #27ae60)' : '#dfe6e9',
|
||||
color: matchCount > 0 ? 'white' : '#b2bec3',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: matchCount > 0 ? 'pointer' : 'default',
|
||||
fontSize: '14px',
|
||||
transition: 'all 0.3s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: '40px',
|
||||
minHeight: '40px',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (matchCount > 0) {
|
||||
e.currentTarget.style.transform = 'scale(1.05)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
<FaArrowDown size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ✅ БЕЛЫЙ ФОН, ТЁМНЫЙ ТЕКСТ */}
|
||||
<div
|
||||
ref={contentRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
background: '#ffffff',
|
||||
borderRadius: '16px',
|
||||
padding: '24px 28px',
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.06)',
|
||||
border: '1px solid #f1f2f6',
|
||||
}}
|
||||
>
|
||||
{loadingHandbook ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<div style={{
|
||||
display: 'inline-block',
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
border: '3px solid #f3f3f3',
|
||||
borderTop: '3px solid #6c5ce7',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
}} />
|
||||
<p style={{ color: '#636e72' }}>Загрузка справочника...</p>
|
||||
</div>
|
||||
) : errorHandbook ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<p style={{ color: '#e74c3c' }}>Ошибка загрузки: {errorHandbook}</p>
|
||||
<button
|
||||
onClick={loadHandbook}
|
||||
style={{
|
||||
padding: '8px 20px',
|
||||
background: '#6c5ce7',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
marginTop: '10px',
|
||||
}}
|
||||
>
|
||||
Повторить
|
||||
</button>
|
||||
</div>
|
||||
) : blocks.length > 0 ? (
|
||||
<HandbookRenderer
|
||||
blocks={blocks}
|
||||
searchTerm={searchTerm}
|
||||
onMatchCount={(count) => {
|
||||
setMatchCount(count);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<p style={{ textAlign: 'center', color: '#b2bec3' }}>Нет данных для отображения</p>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeTab === 'map' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
style={{
|
||||
background: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '60px 40px',
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.06)',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
border: '2px dashed #dfe6e9',
|
||||
minHeight: '300px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flex: 1,
|
||||
}}
|
||||
onClick={handleOpenMap}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#2ecc71';
|
||||
e.currentTarget.style.boxShadow = '0 8px 30px rgba(46, 204, 113, 0.15)';
|
||||
e.currentTarget.style.transform = 'translateY(-3px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = '#dfe6e9';
|
||||
e.currentTarget.style.boxShadow = '0 4px 20px rgba(0,0,0,0.06)';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '64px', marginBottom: '15px' }}>🗺️</div>
|
||||
<h3 style={{ color: '#2d3436', marginBottom: '10px', fontSize: '22px' }}>
|
||||
Карта предприятия
|
||||
</h3>
|
||||
<p style={{ color: '#636e72', marginBottom: '20px', fontSize: '15px' }}>
|
||||
Нажмите для просмотра карты в полноэкранном режиме
|
||||
</p>
|
||||
<div style={{
|
||||
display: 'inline-block',
|
||||
padding: '12px 30px',
|
||||
background: 'linear-gradient(135deg, #2ecc71, #27ae60)',
|
||||
color: 'white',
|
||||
borderRadius: '10px',
|
||||
fontSize: '15px',
|
||||
fontWeight: 500,
|
||||
transition: 'all 0.3s ease',
|
||||
}}>
|
||||
Открыть карту →
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ✅ ТЁМНЫЙ ТЕКСТ ДЛЯ СПРАВОЧНИКА */}
|
||||
<style>
|
||||
{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
.handbook-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #2d3436;
|
||||
}
|
||||
.handbook-content h1, .handbook-content h2, .handbook-content h3, .handbook-content h4 {
|
||||
margin: 16px 0 8px 0;
|
||||
color: #2d3436;
|
||||
}
|
||||
.handbook-content p {
|
||||
margin: 8px 0;
|
||||
color: #2d3436;
|
||||
}
|
||||
.handbook-content ul, .handbook-content ol {
|
||||
padding-left: 24px;
|
||||
}
|
||||
.handbook-content li {
|
||||
margin: 4px 0;
|
||||
color: #2d3436;
|
||||
}
|
||||
.handbook-content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 16px 0;
|
||||
color: #2d3436;
|
||||
}
|
||||
.handbook-content th, .handbook-content td {
|
||||
border: 1px solid #dfe6e9;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
color: #2d3436;
|
||||
}
|
||||
.handbook-content th {
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
}
|
||||
.handbook-content a {
|
||||
color: #2d3436;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.handbook-content strong, .handbook-content b {
|
||||
color: #2d3436;
|
||||
}
|
||||
.handbook-content mark {
|
||||
background: #ffdd57;
|
||||
color: #1a3c34;
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
font-weight: 600;
|
||||
transition: outline 0.3s ease;
|
||||
}
|
||||
mark[data-match-index] {
|
||||
cursor: pointer;
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f2f6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #b2bec3;
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #6c5ce7;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,7 +9,10 @@ const MainMenu: React.FC<MainMenuProps> = ({ onSectionSelect, employee }) => {
|
||||
console.log('🔵 MainMenu компонент рендерится!');
|
||||
|
||||
const menuItems = [
|
||||
{ id: 'employee-info', title: 'Моя информация', emoji: '📋' },
|
||||
{ id: 'employee-info', emoji: '📋' }
|
||||
];
|
||||
|
||||
const menuItems_2 = [
|
||||
{ id: 'finance', title: 'Финансы', emoji: '💰' },
|
||||
{ id: 'medicine', title: 'Медицина', emoji: '🏥' },
|
||||
{ id: 'trade-union', title: 'Профсоюз', emoji: '🤝' },
|
||||
@@ -26,16 +29,17 @@ const MainMenu: React.FC<MainMenuProps> = ({ onSectionSelect, employee }) => {
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '40px',
|
||||
padding: '10px',
|
||||
marginTop: '-5vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
}}>
|
||||
{employee && (
|
||||
<div style={{
|
||||
color: '#fff',
|
||||
fontSize: '1.2rem',
|
||||
fontSize: '1.5rem',
|
||||
marginBottom: '10px',
|
||||
}}>
|
||||
Здравствуйте, {employee.ima} {employee.famaly}
|
||||
Приветствуем Вас, {employee.ima} {employee.famaly}
|
||||
</div>
|
||||
)}
|
||||
<h1 style={{
|
||||
@@ -49,14 +53,74 @@ const MainMenu: React.FC<MainMenuProps> = ({ onSectionSelect, employee }) => {
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 200px))',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 870px))',
|
||||
gap: '25px',
|
||||
maxWidth: '900px',
|
||||
width: '100%',
|
||||
//height: '100%',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
|
||||
{menuItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onSectionSelect(item.id)}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #1b718b 0%, #7ec87b 100%)',
|
||||
borderRadius: '16px',
|
||||
padding: '25px 30px',
|
||||
marginBottom: '25px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
boxShadow: '0 4px 20px rgba(198, 231, 212, 0.25)',
|
||||
display: 'inline-flex',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'center',
|
||||
minHeight: '80px',
|
||||
fontSize: '6.0rem',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1.05)';
|
||||
e.currentTarget.style.boxShadow = '0 12px 40px rgba(0,0,0,0.2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
e.currentTarget.style.boxShadow = '0 8px 30px rgba(0,0,0,0.12)';
|
||||
}}
|
||||
>
|
||||
<div>{item.emoji}</div>
|
||||
<span style={{
|
||||
fontSize: '1rem',
|
||||
fontWeight: 500,
|
||||
color: '#2d3436',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<div>
|
||||
<h3 style={{
|
||||
color: 'white',
|
||||
fontSize: '25px',
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
Расчетный лист
|
||||
</h3>
|
||||
</div>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 200px))',
|
||||
gap: '15px',
|
||||
maxWidth: '900px',
|
||||
width: '100%',
|
||||
//height: '100%',
|
||||
justifyContent: 'center',
|
||||
marginTop: '15px'
|
||||
}}>
|
||||
|
||||
{menuItems_2.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onSectionSelect(item.id)}
|
||||
@@ -73,7 +137,7 @@ const MainMenu: React.FC<MainMenuProps> = ({ onSectionSelect, employee }) => {
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 8px 30px rgba(0,0,0,0.12)',
|
||||
minHeight: '140px',
|
||||
fontSize: '2.5rem',
|
||||
fontSize: '3.5rem',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
|
||||
@@ -25,15 +25,26 @@ const renderIcon = (Icon: any, style?: React.CSSProperties) => {
|
||||
const Profile: React.FC<ProfileProps> = ({ onBack, employee }) => {
|
||||
const [activeTab, setActiveTab] = useState('about');
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
// ✅ ФИО из базы
|
||||
const fullName = employee ? `${employee.famaly} ${employee.ima} ${employee.otch}` : 'Сотрудник не найден';
|
||||
|
||||
// ✅ Табельный номер из базы
|
||||
const tnom = employee?.tnom || '—';
|
||||
|
||||
// ✅ Телефон из базы (если есть, иначе заглушка)
|
||||
const phone = employee?.phone || '+7 (999) 123-45-67';
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
fullName: employee?.fullName || 'Иванов Иван Иванович',
|
||||
fullName: fullName,
|
||||
position: employee?.position || 'Инженер-технолог',
|
||||
department: employee?.department || 'Цех №5',
|
||||
phone: employee?.phone || '+7 (999) 123-45-67',
|
||||
phone: phone,
|
||||
email: employee?.email || 'ivanov@company.ru',
|
||||
address: employee?.address || 'г. Москва, ул. Ленина, д. 1, кв. 10',
|
||||
birthDate: employee?.birthDate || '1985-06-15',
|
||||
education: employee?.education || 'Высшее, МГУ им. Ломоносова, химический факультет',
|
||||
tnom: tnom,
|
||||
});
|
||||
|
||||
const tabs: Tab[] = [
|
||||
@@ -107,6 +118,15 @@ const Profile: React.FC<ProfileProps> = ({ onBack, employee }) => {
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '15px' }}>
|
||||
{/* ✅ Табельный номер из базы */}
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', color: '#b2bec3', marginBottom: '4px' }}>Табельный номер</div>
|
||||
<div style={{ fontSize: '15px', color: '#2d3436', fontWeight: 500 }}>
|
||||
{formData.tnom}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ✅ Телефон из базы */}
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', color: '#b2bec3', marginBottom: '4px' }}>Телефон</div>
|
||||
{isEditing ? (
|
||||
@@ -126,6 +146,7 @@ const Profile: React.FC<ProfileProps> = ({ onBack, employee }) => {
|
||||
<div style={{ fontSize: '15px', color: '#2d3436' }}>{formData.phone}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', color: '#b2bec3', marginBottom: '4px' }}>Email</div>
|
||||
{isEditing ? (
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
interface WeatherDay {
|
||||
date: string;
|
||||
dayOfWeek: string;
|
||||
tempMin: number;
|
||||
tempMax: number;
|
||||
icon: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface WeatherWidgetProps {
|
||||
onLogout?: () => void;
|
||||
}
|
||||
|
||||
const WeatherWidget: React.FC<WeatherWidgetProps> = ({ onLogout }) => {
|
||||
const [weatherData, setWeatherData] = useState<WeatherDay[]>([]);
|
||||
const [currentWeather, setCurrentWeather] = useState<WeatherDay | null>(null);
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
|
||||
// ⏰ ВРЕМЯ
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentTime(new Date());
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// 🌐 СТАТУС ИНТЕРНЕТА
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setIsOnline(true);
|
||||
const handleOffline = () => setIsOnline(false);
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 🌤️ ЗАГРУЗКА ПОГОДЫ
|
||||
useEffect(() => {
|
||||
const fetchWeather = async () => {
|
||||
if (!isOnline) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const apiKey = '9edc3b8058098d110db0aa1894a08c97';
|
||||
const url = `https://api.openweathermap.org/data/2.5/forecast?q=Grodno&units=metric&lang=ru&appid=${apiKey}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
// fallback
|
||||
setCurrentWeather({
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
dayOfWeek: 'Сегодня',
|
||||
tempMin: 18,
|
||||
tempMax: 24,
|
||||
icon: '01d',
|
||||
description: 'Ясно',
|
||||
});
|
||||
setWeatherData([
|
||||
{ date: '2026-08-17', dayOfWeek: 'Пн', tempMin: 18, tempMax: 24, icon: '01d', description: 'Ясно' },
|
||||
{ date: '2026-08-18', dayOfWeek: 'Вт', tempMin: 19, tempMax: 25, icon: '02d', description: 'Облачно' },
|
||||
{ date: '2026-08-19', dayOfWeek: 'Ср', tempMin: 17, tempMax: 23, icon: '10d', description: 'Дождь' },
|
||||
{ date: '2026-08-20', dayOfWeek: 'Чт', tempMin: 16, tempMax: 22, icon: '03d', description: 'Облачно' },
|
||||
{ date: '2026-08-21', dayOfWeek: 'Пт', tempMin: 18, tempMax: 24, icon: '01d', description: 'Ясно' },
|
||||
{ date: '2026-08-22', dayOfWeek: 'Сб', tempMin: 20, tempMax: 26, icon: '01d', description: 'Ясно' },
|
||||
{ date: '2026-08-23', dayOfWeek: 'Вс', tempMin: 21, tempMax: 27, icon: '02d', description: 'Облачно' },
|
||||
]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const current = data.list[0];
|
||||
setCurrentWeather({
|
||||
date: new Date(current.dt * 1000).toISOString().split('T')[0],
|
||||
dayOfWeek: 'Сегодня',
|
||||
tempMin: current.main.temp_min,
|
||||
tempMax: current.main.temp_max,
|
||||
icon: current.weather[0].icon,
|
||||
description: current.weather[0].description,
|
||||
});
|
||||
|
||||
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);
|
||||
setLoading(false);
|
||||
|
||||
} catch (error) {
|
||||
console.warn('Ошибка загрузки погоды:', error);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchWeather();
|
||||
const interval = setInterval(fetchWeather, 30 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isOnline]);
|
||||
|
||||
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 handleLogout = () => {
|
||||
if (onLogout) {
|
||||
onLogout();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ y: -100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 9999,
|
||||
background: 'rgba(44, 62, 80, 0.75)', // ✅ полупрозрачный
|
||||
backdropFilter: 'blur(12px)',
|
||||
padding: '5px 15px',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.08)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
minHeight: '70px',
|
||||
boxShadow: '0 2px 20px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
>
|
||||
{/* Левая часть: Время */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
flexShrink: 0,
|
||||
minWidth: '140px',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: '30px',
|
||||
fontWeight: 700,
|
||||
color: '#fff',
|
||||
letterSpacing: '0.5px',
|
||||
lineHeight: 1,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}>
|
||||
{currentTime.toLocaleString('ru-RU', {
|
||||
timeZone: 'Europe/Minsk',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
letterSpacing: '0.3px',
|
||||
fontWeight: 400,
|
||||
}}>
|
||||
{currentTime.toLocaleString('ru-RU', {
|
||||
timeZone: 'Europe/Minsk',
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Центральная часть: Погода */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0 16px',
|
||||
minHeight: '56px',
|
||||
}}>
|
||||
<AnimatePresence mode="wait">
|
||||
{isOnline && !loading && currentWeather ? (
|
||||
<motion.div
|
||||
key="weather"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
padding: '4px 16px 4px 10px',
|
||||
borderRadius: '30px',
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
}}>
|
||||
<span style={{ fontSize: '32px', lineHeight: 1 }}>
|
||||
{getWeatherIcon(currentWeather.icon)}
|
||||
</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<span style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
}}>
|
||||
{Math.round(currentWeather.tempMax)}°
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
}}>
|
||||
{currentWeather.description}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{weatherData.slice(1, 7).map((day, index) => (
|
||||
<motion.div
|
||||
key={day.date}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '2px',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '6px',
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
minWidth: '40px',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
fontSize: '9px',
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
{day.dayOfWeek}
|
||||
</span>
|
||||
<span style={{ fontSize: '16px', lineHeight: 1 }}>
|
||||
{getWeatherIcon(day.icon)}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '10px',
|
||||
fontWeight: 600,
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
}}>
|
||||
{Math.round(day.tempMax)}°
|
||||
</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="placeholder"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
style={{
|
||||
color: 'rgba(255,255,255,0.3)',
|
||||
fontSize: '13px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Правая часть: Кнопка выхода */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
flexShrink: 0,
|
||||
minWidth: '80px',
|
||||
justifyContent: 'flex-end',
|
||||
}}>
|
||||
{onLogout && (
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={handleLogout}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
background: 'rgba(231, 76, 60, 0.7)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
transition: 'all 0.2s',
|
||||
whiteSpace: 'nowrap',
|
||||
minHeight: '34px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '6px',
|
||||
backdropFilter: 'blur(4px)',
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
borderColor: 'rgba(255,255,255,0.1)',
|
||||
boxShadow: '0 2px 10px rgba(231, 76, 60, 0.2)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(231, 76, 60, 1)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 20px rgba(231, 76, 60, 0.4)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(231, 76, 60, 0.7)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 10px rgba(231, 76, 60, 0.2)';
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '16px' }}>⏻</span>
|
||||
Выйти
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WeatherWidget;
|
||||
Reference in New Issue
Block a user