add compomemts&front

This commit is contained in:
2026-08-10 16:16:37 +03:00
parent b0ee07823e
commit b0754e8cdb
27 changed files with 5256 additions and 962 deletions
+20
View File
@@ -1638,3 +1638,23 @@
[2026-08-09T19:34:39.791Z] [INFO] Card logs initialized successfully [2026-08-09T19:34:39.791Z] [INFO] Card logs initialized successfully
[2026-08-09T19:36:51.312Z] [INFO] Server running on http://localhost:3005 [2026-08-09T19:36:51.312Z] [INFO] Server running on http://localhost:3005
[2026-08-09T19:36:51.318Z] [INFO] Card logs initialized successfully [2026-08-09T19:36:51.318Z] [INFO] Card logs initialized successfully
[2026-08-10T07:16:29.299Z] [INFO] Server running on http://localhost:3005
[2026-08-10T07:16:29.302Z] [INFO] Card log file created at: D:\rasch_git\electron\backend\logs\cards.log
[2026-08-10T07:16:29.303Z] [INFO] Card log file created at: D:\rasch_git\electron\backend\logs\cards.log
[2026-08-10T07:16:29.303Z] [INFO] Card logs initialized successfully
[2026-08-10T07:16:37.113Z] [ERROR] Error: ENOENT: no such file or directory, stat 'D:\rasch_git\electron\admin-frontend\build\index.html'
[2026-08-10T07:17:35.578Z] [INFO] Server running on http://localhost:3005
[2026-08-10T07:17:35.581Z] [INFO] Card logs initialized successfully
[2026-08-10T07:17:43.099Z] [INFO] Login successful
[2026-08-10T07:17:43.218Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
[2026-08-10T07:17:43.250Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
[2026-08-10T07:17:44.412Z] [WARN] Log file not found: D:\rasch_git\electron\backend\logs\app.log
[2026-08-10T07:18:02.846Z] [INFO] [LOGIN] Попытка входа: grivova
[2026-08-10T07:18:02.846Z] [INFO] [LOGIN] Проверка подключения к базе...
[2026-08-10T07:18:02.852Z] [INFO] [LOGIN] Результат поиска пользователя: найден
[2026-08-10T07:18:02.853Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
[2026-08-10T07:18:02.943Z] [INFO] [LOGIN] Результат сравнения пароля: true
[2026-08-10T07:18:02.944Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":3,"username":"grivova"}
[2026-08-10T07:18:02.945Z] [INFO] [LOGIN] Ответ отправлен
[2026-08-10T07:22:25.057Z] [INFO] Server running on http://localhost:3005
[2026-08-10T07:22:25.060Z] [INFO] Card logs initialized successfully
-1
View File
@@ -7,7 +7,6 @@
"": { "": {
"name": "admin-server", "name": "admin-server",
"version": "1.0.0", "version": "1.0.0",
"hasInstallScript": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"axios": "^1.10.0", "axios": "^1.10.0",
-1
View File
@@ -6,7 +6,6 @@
"start": "node server.js", "start": "node server.js",
"electron": "electron .", "electron": "electron .",
"electron-dev": "set NODE_ENV=development&& electron .", "electron-dev": "set NODE_ENV=development&& electron .",
"postinstall": "electron-builder install-app-deps",
"test": "echo \"Error: no test specified\" && exit 1" "test": "echo \"Error: no test specified\" && exit 1"
}, },
"keywords": [], "keywords": [],
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"payslipsPath": "\\\\PC-20010\\payslips", "payslipsPath": "\\\\PC-20010\\payslips2",
"server": { "server": {
"host": "localhost", "host": "localhost",
"port": "3001" "port": "3001"
+582 -694
View File
File diff suppressed because it is too large Load Diff
+8 -4
View File
@@ -9,13 +9,17 @@
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0", "@types/react-dom": "^19.2.0",
"axios": "^1.6.0",
"date-fns": "^2.30.0",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"electron-is-dev": "^2.0.0", "electron-is-dev": "^2.0.0",
"pdfjs-dist": "^4.10.38", "framer-motion": "^13.0.0",
"pdfjs-dist": "^3.11.174",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-pdf": "^10.0.0", "react-icons": "^4.11.0",
"serialport": "^13.0.0", "react-pdf": "^8.0.0",
"react-zoom-pan-pinch": "^3.3.0",
"typescript": "^5.8.0", "typescript": "^5.8.0",
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4"
}, },
@@ -40,7 +44,7 @@
}, },
"extraResources": [ "extraResources": [
{ {
"from": "node_modules/pdfjs-dist/build/pdf.worker.min.mjs", "from": "node_modules/pdfjs-dist/build/pdf.worker.min.js",
"to": "pdf.worker.mjs", "to": "pdf.worker.mjs",
"filter": [ "filter": [
"**/*" "**/*"
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.
-52
View File
@@ -5,7 +5,6 @@ const isDev = require('electron-is-dev');
const fs = require('fs'); const fs = require('fs');
const COM_PORT = process.env.COM_PORT; const COM_PORT = process.env.COM_PORT;
const COM_SPEED = process.env.COM_SPEED; const COM_SPEED = process.env.COM_SPEED;
const { SerialPort } = require('serialport');
const axios = require('axios'); const axios = require('axios');
const { logger } = require('../../backend/src/config/logger'); const { logger } = require('../../backend/src/config/logger');
const config = require('../src/config.json') const config = require('../src/config.json')
@@ -140,57 +139,6 @@ app.on('will-quit', () => {
}); });
//считыватель //считыватель
SerialPort.list().then(ports => {
logger.info('Доступные порты:', ports.map(p => p.path));
}).catch(err => {
logger.error('Ошибка при получении списка портов:', err);
});
try {
const port = new SerialPort({ path: COM_PORT, baudRate: parseInt(COM_SPEED, 10)});
let buffer = '';
port.on('open', () => {
logger.info(' Serial port открыт');
});
port.on('data', (data) => {
logger.info('RAW DATA:', data);
buffer += data.toString('utf8');
if (buffer.includes('\n')) {
const lines = buffer.split('\n');
lines.forEach((line, idx) => {
if (idx < lines.length - 1) {
const uid = line.trim().replace(/\D/g, '');
if (uid) {
console.log(`UID карты: ${uid}`);
console.log('[DEBUG] Отправляю событие на admin-server:', {
event: 'CARD_PLACED',
uid: uid,
timestamp: Date.now()
});
axios.post(`${config.adminUrl}}/api/card-event`, {
event: 'CARD_PLACED',
uid: uid,
timestamp: Date.now()
}).catch(err => {
console.error('[ADMIN][CARD_LOG] Ошибка отправки:', err.message);
});
if (mainWindow && mainWindow.webContents) {
mainWindow.webContents.send('card-detected', uid);
console.log(` Отправка события 'card-detected' в рендерер с UID: ${uid}`);
} else {
console.warn('mainWindow или webContents не определены, событие не отправлено');
}
}
}
});
buffer = lines[lines.length - 1];
}
});
port.on('error', (err) => {
console.error('Ошибка serialport:', err);
});
} catch (err) {
console.error('!!! FATAL: FAILED TO INITIALIZE SERIALPORT !!!', err);
}
ipcMain.handle('get-app-config', async (event) => { ipcMain.handle('get-app-config', async (event) => {
console.log('[IPC] Renderer requested app config. Sending:', appConfig); console.log('[IPC] Renderer requested app config. Sending:', appConfig);
+274 -22
View File
@@ -1,30 +1,111 @@
import React, { useState } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import './styles/main.css'; import './styles/main.css';
import './styles/animations.css';
import LoginWindow from './components/LoginWindow'; import LoginWindow from './components/LoginWindow';
import EmployeeInfo from './components/EmployeeInfo';
import GuestMode from './components/GuestMode'; import GuestMode from './components/GuestMode';
import MainMenu from './components/MenuComponent';
import VideoBackground from './components/VideoBackground';
import BottomBar from './components/BottomBar';
import PayslipViewer from './components/PayslipViewer'; import PayslipViewer from './components/PayslipViewer';
import EmployeeInfo from './components/EmployeeInfo';
import { useActivityTimer } from './components/useActivityTimer'
import config from './config.json'; import config from './config.json';
// Импорты разделов
import Finance from './components/Finance';
import Medicine from './components/Medicine';
import TradeUnion from './components/TradeUnion';
import LaborSafety from './components/LaborSafety';
import Contacts from './components/Contacts';
import News from './components/News';
import Profile from './components/Profile';
interface Employee { interface Employee {
id: string; id: string;
tnom: string; tnom: string;
famaly: string; famaly: string;
ima: string; ima: string;
otch: string; otch: string;
category: string; category?: string;
organization: string; organization: string;
identNumber: string; identNumber?: string;
departmentName?: string;
profession?: string;
cardNumber?: string;
} }
function App() { function App() {
// 📊 Состояния приложения
const [isLoggedIn, setIsLoggedIn] = useState(false); const [isLoggedIn, setIsLoggedIn] = useState(false);
const [isGuestMode, setIsGuestMode] = useState(false); const [isGuestMode, setIsGuestMode] = useState(false);
const [employee, setEmployee] = useState<Employee | null>(null); const [employee, setEmployee] = useState<Employee | null>(null);
const [loginError, setLoginError] = useState('');
const [showVideo, setShowVideo] = useState(true);
const [currentSection, setCurrentSection] = useState<string | null>(null);
const [showPayslip, setShowPayslip] = useState(false); const [showPayslip, setShowPayslip] = useState(false);
const [selectedPeriod, setSelectedPeriod] = useState(''); const [selectedPeriod, setSelectedPeriod] = useState('');
const [loginError, setLoginError] = useState('');
// 📊 Состояния для таймера
const [showInactiveWarning, setShowInactiveWarning] = useState(false);
// 📌 Реф для поля ввода логина (исправленный тип)
const loginInputRef = useRef<HTMLInputElement>(null!); // ✅ Исправлено
// ============================================
// 🛑 Функция ПОЛНОГО сброса приложения (как при запуске)
// ============================================
const resetToInitialState = () => {
console.log('🔄 ПОЛНЫЙ СБРОС ПРИЛОЖЕНИЯ — как при запуске');
// ✅ Сбрасываем ВСЕ состояния
setIsLoggedIn(false);
setIsGuestMode(false);
setEmployee(null);
setCurrentSection(null);
setShowPayslip(false);
setSelectedPeriod('');
setShowInactiveWarning(false);
setLoginError('');
// ✅ ПОКАЗЫВАЕМ ВИДЕО-ЗАСТАВКУ СНАЧАЛА
setShowVideo(true);
// ✅ Через 200ms ставим фокус на поле ввода (после видео)
setTimeout(() => {
if (loginInputRef.current) {
loginInputRef.current.focus();
console.log('✅ Фокус на поле ввода логина');
}
}, 200);
};
// ============================================
// 🕐 Таймер неактивности (работает ВСЕГДА)
// ============================================
const { isWarning, timeLeft, resetTimer } = useActivityTimer({
timeout: 30000, // 30 секунд до предупреждения
warningTimeout: 10000, // 10 секунд до сброса
onInactive: () => {
console.log('🛑 Пользователь неактивен! ПОЛНЫЙ СБРОС ПРИЛОЖЕНИЯ');
resetToInitialState();
},
onWarning: () => {
console.log('⚠️ Предупреждение о неактивности!');
setShowInactiveWarning(true);
}
});
// 📌 Функция для сброса таймера при любом действии
const handleUserActivity = () => {
if (showInactiveWarning) {
setShowInactiveWarning(false);
}
resetTimer();
};
// ============================================
// 🔐 Вход в систему
// ============================================
const handleLogin = async (id: string) => { const handleLogin = async (id: string) => {
try { try {
const isCardUid = /^\d{7}$/.test(id); const isCardUid = /^\d{7}$/.test(id);
@@ -37,6 +118,7 @@ function App() {
setEmployee(data); setEmployee(data);
setIsLoggedIn(true); setIsLoggedIn(true);
setLoginError(''); setLoginError('');
handleUserActivity();
} else { } else {
setLoginError('Сотрудник не найден'); setLoginError('Сотрудник не найден');
} }
@@ -46,52 +128,222 @@ function App() {
} }
}; };
// 👤 Режим гостя
const handleGuestMode = () => { const handleGuestMode = () => {
setIsGuestMode(true); setIsGuestMode(true);
handleUserActivity();
}; };
// ↩️ Выход из системы
const handleBack = () => { const handleBack = () => {
setIsLoggedIn(false); resetToInitialState();
setEmployee(null); };
setIsGuestMode(false);
// ↩️ Возврат в главное меню
const handleBackToMenu = () => {
setCurrentSection(null);
setShowPayslip(false); setShowPayslip(false);
setSelectedPeriod(''); setSelectedPeriod('');
handleUserActivity();
}; };
// 📂 Выбор раздела
const handleSectionSelect = (section: string) => {
setCurrentSection(section);
handleUserActivity();
};
// 📄 Просмотр расчетного листка
const handleViewPayslip = (period: string) => { const handleViewPayslip = (period: string) => {
setSelectedPeriod(period); setSelectedPeriod(period);
setShowPayslip(true); setShowPayslip(true);
handleUserActivity();
}; };
// ↩️ Назад из расчетного листка
const handlePayslipBack = () => { const handlePayslipBack = () => {
setShowPayslip(false); setShowPayslip(false);
setSelectedPeriod(''); setSelectedPeriod('');
handleUserActivity();
}; };
// 🔗 URL для расчетного листка
const getPayslipUrl = (employee: Employee, period: string) => { const getPayslipUrl = (employee: Employee, period: string) => {
const fileName = `${employee.tnom}.pdf`; const fileName = `${employee.tnom}.pdf`;
return `${config.backendUrl}/api/payslip/${period}/${fileName}`; return `${config.backendUrl}/api/payslip/${period}/${fileName}`;
}; };
// ============================================
// ⚠️ Предупреждение о неактивности
// ============================================
const renderWarning = () => {
if (!showInactiveWarning) return null;
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.6)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
backdropFilter: 'blur(5px)',
}}>
<div style={{
background: 'rgba(255, 193, 7, 0.98)',
padding: '35px 45px',
borderRadius: '20px',
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
textAlign: 'center',
maxWidth: '400px',
animation: 'pulseWarning 1s ease-in-out infinite',
border: '3px solid #ff6b6b',
}}>
<div style={{ fontSize: '48px', marginBottom: '10px' }}>⚠️</div>
<h3 style={{ margin: '0 0 10px 0', color: '#2d3436', fontSize: '22px' }}>
Сессия истекает!
</h3>
<p style={{ margin: '0 0 5px 0', color: '#636e72', fontSize: '14px' }}>
Вы были неактивны. Приложение будет сброшено через:
</p>
<p style={{
margin: '10px 0 20px 0',
fontSize: '48px',
fontWeight: 'bold',
color: '#d32f2f',
animation: 'blinkText 0.5s ease-in-out infinite'
}}>
{Math.ceil(timeLeft / 1000)}с
</p>
<p style={{
color: '#636e72',
fontSize: '13px',
margin: '0 0 15px 0',
opacity: 0.7
}}>
👆 Нажмите или начните двигать мышью для отмены
</p>
<button
onClick={() => {
setShowInactiveWarning(false);
handleUserActivity();
}}
style={{
padding: '14px 45px',
background: '#28a745',
border: 'none',
borderRadius: '12px',
color: 'white',
fontSize: '18px',
cursor: 'pointer',
transition: 'all 0.3s ease',
boxShadow: '0 4px 15px rgba(40,167,69,0.3)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#218838';
e.currentTarget.style.transform = 'scale(1.05)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#28a745';
e.currentTarget.style.transform = 'scale(1)';
}}
>
Продолжить
</button>
</div>
</div>
);
};
// ============================================
// 📄 Рендер контента
// ============================================
const renderContent = () => {
// 🎬 Если showVideo = true — показываем видео
if (showVideo) {
return <VideoBackground onComplete={() => setShowVideo(false)} />;
}
// ✅ Если не залогинен и не гость — показываем окно входа
if (!isLoggedIn && !isGuestMode) {
return (
<LoginWindow
onLogin={handleLogin}
onGuestMode={handleGuestMode}
loginError={loginError}
inputRef={loginInputRef}
/>
);
}
// 👤 Режим гостя
if (isGuestMode) {
return <GuestMode onBack={handleBack} />;
}
// 📄 Просмотр расчетного листка
if (showPayslip) {
return ( return (
<div className="app">
{!isLoggedIn && !isGuestMode ? (
<LoginWindow onLogin={handleLogin} onGuestMode={handleGuestMode} loginError={loginError} />
) : isGuestMode ? (
<GuestMode onBack={handleBack} />
) : showPayslip ? (
<PayslipViewer <PayslipViewer
fileUrl={employee && selectedPeriod ? getPayslipUrl(employee, selectedPeriod) : "/payslips/test.pdf"} fileUrl={employee && selectedPeriod ? getPayslipUrl(employee, selectedPeriod) : "/payslips/test.pdf"}
onBack={handlePayslipBack} onBack={handlePayslipBack}
/> />
) : employee ? ( );
<EmployeeInfo }
employee={employee}
onViewPayslip={handleViewPayslip} // 📂 Разделы приложения
onBack={handleBack} if (currentSection) {
/> switch (currentSection) {
) : null} case 'employee-info':
return <EmployeeInfo employee={employee!} onViewPayslip={handleViewPayslip} onBack={handleBackToMenu} />;
case 'finance':
return <Finance onBack={handleBackToMenu} onViewPayslip={handleViewPayslip} employee={employee!} />;
case 'medicine':
return <Medicine onBack={handleBackToMenu} employee={employee!} />;
case 'trade-union':
return <TradeUnion onBack={handleBackToMenu} employee={employee!} />;
case 'labor-safety':
return <LaborSafety onBack={handleBackToMenu} employee={employee!} />;
case 'contacts':
return <Contacts onBack={handleBackToMenu} />;
case 'news':
return <News onBack={handleBackToMenu} />;
case 'profile':
return <Profile onBack={handleBackToMenu} employee={employee!} />;
default:
return <MainMenu onSectionSelect={handleSectionSelect} employee={employee!} />;
}
}
// 📋 Главное меню
return <MainMenu onSectionSelect={handleSectionSelect} employee={employee!} />;
};
// ============================================
// 🚀 Рендер приложения
// ============================================
return (
<div
className="app"
onClick={handleUserActivity}
onKeyDown={handleUserActivity}
onMouseMove={handleUserActivity}
onTouchStart={handleUserActivity}
onScroll={handleUserActivity}
onWheel={handleUserActivity}
>
{/* ⚠️ Предупреждение о неактивности */}
{renderWarning()}
{/* 📄 Основной контент */}
{renderContent()}
{/* 📱 Нижняя панель (только для авторизованных) */}
{isLoggedIn && !showPayslip && <BottomBar />}
</div> </div>
); );
} }
+311
View File
@@ -0,0 +1,311 @@
import React, { useState, useEffect, useRef } from 'react';
import { motion } from 'framer-motion';
interface CurrencyRate {
currency: string;
rate: number;
change: number;
}
interface MetalRate {
metal: string;
price: number;
change: number;
}
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 },
{ currency: 'CNY', rate: 0.44, change: 0.1 },
{ currency: 'RUB', rate: 0.035, change: -0.3 },
]);
const [metalRates, setMetalRates] = useState<MetalRate[]>([
{ metal: 'Золото', price: 1950, change: 0.8 },
{ metal: 'Серебро', price: 23.5, change: -0.5 },
{ metal: 'Платина', price: 920, change: 0.3 },
{ metal: 'Палладий', price: 1450, change: -0.7 },
]);
const [isOnline, setIsOnline] = useState(navigator.onLine);
const [isPaused, setIsPaused] = useState(false);
const [currentTime, setCurrentTime] = useState(new Date()); // ✅ Добавлено
const scrollRef = useRef<HTMLDivElement>(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];
// ✅ Обновление времени каждую секунду
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 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);
}
} else {
const stored = localStorage.getItem('currencyRates');
if (stored) {
const data = JSON.parse(stored);
setCurrencyRates(data.currency || currencyRates);
setMetalRates(data.metals || metalRates);
}
}
} catch (error) {
console.warn('Ошибка загрузки курсов:', error);
}
};
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);
return (
<motion.div
className="bottom-bar"
initial={{ y: 100 }}
animate={{ y: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
background: 'rgba(44, 62, 80, 0.95)',
backdropFilter: 'blur(10px)',
color: '#fff',
padding: '8px 20px',
display: 'flex',
alignItems: 'center',
gap: '16px',
overflow: 'hidden',
zIndex: 1000,
height: '44px',
borderTop: '1px solid rgba(255,255,255,0.1)',
fontSize: '13px',
fontFamily: 'monospace',
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* Статус */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
flexShrink: 0,
zIndex: 2,
background: 'rgba(44, 62, 80, 0.95)',
paddingRight: '12px',
}}>
<span style={{
color: isOnline ? '#2ecc71' : '#e74c3c',
fontSize: '12px',
}}>
{isOnline ? '●' : '○'}
</span>
<span style={{ fontWeight: 600, opacity: 0.7, fontSize: '11px' }}>КУРСЫ</span>
<div style={{
width: '1px',
height: '24px',
background: 'rgba(255,255,255,0.15)',
marginLeft: '4px',
}} />
</div>
{/* Бегущая строка */}
<div
ref={scrollRef}
style={{
flex: 1,
overflow: 'hidden',
position: 'relative',
height: '100%',
display: 'flex',
alignItems: 'center',
maskImage: 'linear-gradient(to right, transparent, black 3%, black 97%, transparent)',
WebkitMaskImage: 'linear-gradient(to right, transparent, black 3%, black 97%, transparent)',
}}
>
<motion.div
animate={{
x: isPaused ? 0 : ['0%', '-33.33%'],
}}
transition={{
duration: 35,
repeat: Infinity,
ease: 'linear',
repeatType: 'loop',
}}
style={{
display: 'flex',
gap: '40px',
whiteSpace: 'nowrap',
willChange: 'transform',
transform: 'translateZ(0)',
backfaceVisibility: 'hidden',
WebkitFontSmoothing: 'antialiased',
MozOsxFontSmoothing: 'grayscale',
textRendering: 'geometricPrecision',
paddingRight: '40px',
height: '100%',
alignItems: 'center',
}}
>
{doubledItems.map((item, index) => (
<div
key={`${item.id}-${index}`}
style={{
display: 'flex',
gap: '8px',
alignItems: 'center',
whiteSpace: 'nowrap',
flexShrink: 0,
fontSmooth: 'always',
WebkitFontSmoothing: 'antialiased',
textRendering: 'geometricPrecision',
}}
>
<span style={{
fontWeight: 600,
color: 'rgba(255,255,255,0.9)',
fontSize: '13px',
letterSpacing: '0.3px',
}}>
{item.label}
</span>
<span style={{
color: 'rgba(255,255,255,0.8)',
fontSize: '13px',
letterSpacing: '0.2px',
}}>
{item.displayValue}
</span>
<span style={{
color: item.change >= 0 ? '#2ecc71' : '#e74c3c',
fontSize: '11px',
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: '2px',
letterSpacing: '0.2px',
}}>
{item.change >= 0 ? '▲' : '▼'}
{Math.abs(item.change).toFixed(1)}%
</span>
{item.type === 'metal' && (
<span style={{
fontSize: '9px',
opacity: 0.3,
marginLeft: '-2px',
}}>
$
</span>
)}
</div>
))}
</motion.div>
</div>
{/* ✅ Время с СЕКУНДАМИ и обновлением */}
<div style={{
marginLeft: 'auto',
fontSize: '11px',
opacity: 0.4,
whiteSpace: 'nowrap',
flexShrink: 0,
paddingLeft: '12px',
background: 'rgba(44, 62, 80, 0.95)',
zIndex: 2,
fontSmooth: 'always',
WebkitFontSmoothing: 'antialiased',
}}>
{currentTime.toLocaleString('ru-RU', {
timeZone: 'Europe/Minsk',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})}
</div>
{/* Пауза */}
{isPaused && (
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
fontSize: '11px',
color: 'rgba(255,255,255,0.3)',
pointerEvents: 'none',
zIndex: 3,
background: 'rgba(0,0,0,0.5)',
padding: '2px 12px',
borderRadius: '4px',
backdropFilter: 'blur(5px)',
}}>
⏸ Пауза
</div>
)}
</motion.div>
);
};
export default BottomBar;
+390
View File
@@ -0,0 +1,390 @@
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import { FaArrowLeft } from 'react-icons/fa';
import MapViewer from './MapViewer';
interface ContactsProps {
onBack: () => void;
}
const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
const [searchTerm, setSearchTerm] = useState('');
const [activeTab, setActiveTab] = useState<'map' | 'directory'>('directory');
const [showMapViewer, setShowMapViewer] = useState(false);
// ✅ Путь к 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: 'Производство' },
];
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 handleMapBack = () => {
setShowMapViewer(false);
};
if (showMapViewer) {
return (
<MapViewer
fileUrl={MAP_FILE_URL}
onBack={handleMapBack}
title="Карта предприятия"
/>
);
}
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
className="contacts-section"
style={{
padding: '20px',
maxWidth: '1200px',
margin: '0 auto',
paddingBottom: '80px',
}}
>
{/* Верхняя панель */}
<div className="top-bar" style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '25px',
flexWrap: 'wrap',
gap: '10px'
}}>
<button
className="back-button"
onClick={onBack}
style={{
padding: '10px 20px',
background: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '14px',
transition: 'all 0.3s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#5a6268';
e.currentTarget.style.transform = 'translateX(-2px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#6c757d';
e.currentTarget.style.transform = 'translateX(0)';
}}
>
← Назад
</button>
<h2 style={{
margin: 0,
color: '#2d3436',
fontSize: '24px',
fontWeight: 300,
display: 'flex',
alignItems: 'center',
gap: '12px',
}}>
📘 Контакты
</h2>
<div style={{ width: '100px' }} />
</div>
{/* Карточка карты */}
<motion.div
whileHover={{ y: -5, boxShadow: '0 8px 30px rgba(46, 204, 113, 0.3)' }}
whileTap={{ scale: 0.98 }}
onClick={handleOpenMap}
style={{
background: 'linear-gradient(135deg, #2ecc71, #27ae60)',
borderRadius: '16px',
padding: '25px 30px',
marginBottom: '25px',
cursor: 'pointer',
transition: 'all 0.3s ease',
boxShadow: '0 4px 20px rgba(46, 204, 113, 0.25)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
minHeight: '80px',
}}
>
<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>
<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>
</motion.div>
{/* Вкладки */}
<div style={{
display: 'flex',
gap: '10px',
marginBottom: '25px',
borderBottom: '2px solid #f0f0f0',
paddingBottom: '10px',
}}>
<button
onClick={() => setActiveTab('directory')}
style={{
padding: '10px 24px',
border: 'none',
background: 'none',
cursor: 'pointer',
fontSize: '15px',
fontWeight: activeTab === 'directory' ? 600 : 400,
color: activeTab === 'directory' ? '#6c5ce7' : '#636e72',
borderBottom: activeTab === 'directory' ? '3px solid #6c5ce7' : '3px solid transparent',
transition: 'all 0.3s ease',
}}
>
📘 Справочник
</button>
<button
onClick={() => setActiveTab('map')}
style={{
padding: '10px 24px',
border: 'none',
background: 'none',
cursor: 'pointer',
fontSize: '15px',
fontWeight: activeTab === 'map' ? 600 : 400,
color: activeTab === 'map' ? '#6c5ce7' : '#636e72',
borderBottom: activeTab === 'map' ? '3px solid #6c5ce7' : '3px solid transparent',
transition: 'all 0.3s ease',
}}
>
🗺️ Карта
</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={{
display: 'flex',
alignItems: 'center',
gap: '12px',
background: '#f8f9fa',
borderRadius: '10px',
padding: '8px 16px',
border: '2px solid #dfe6e9',
}}>
🔍
<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>
)}
</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>
)}
</motion.div>
);
};
export default Contacts;
+162
View File
@@ -0,0 +1,162 @@
import React, { useState } from 'react';
import { motion } from 'framer-motion';
interface FinanceProps {
onBack: () => void;
onViewPayslip: (period: string) => void;
employee: any;
}
interface FinancialIndicator {
title: string;
value: string;
change?: number;
unit?: string;
}
const Finance: React.FC<FinanceProps> = ({ onBack, onViewPayslip, employee }) => {
const [selectedPeriod, setSelectedPeriod] = useState(() => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
return `${month}${year}`;
});
const [indicators] = useState<FinancialIndicator[]>([
{ title: 'Средняя ЗП по предприятию', value: '1 850', unit: 'руб.' },
{ title: 'Премия по итогам месяца', value: '15', unit: '%' },
{ title: 'Материальная помощь к отпуску', value: '500', unit: 'руб.' },
{ title: 'Выплаты к Новому году', value: '300', unit: 'руб.' },
]);
const generatePeriods = () => {
const periods = [];
const now = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(now.getFullYear(), now.getMonth() - i, 1);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
periods.push(`${month}${year}`);
}
return periods;
};
const periods = generatePeriods();
const monthNames = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'];
const formatPeriod = (period: string) => {
const month = parseInt(period.substring(0, 2)) - 1;
const year = period.substring(2);
return `${monthNames[month]} ${year}`;
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
className="finance-section"
style={{ padding: '20px', maxWidth: '1200px', margin: '0 auto', paddingBottom: '80px' }}
>
<div className="top-bar" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '25px', flexWrap: 'wrap', gap: '10px' }}>
<button
className="back-button"
onClick={onBack}
style={{ padding: '10px 20px', background: '#6c757d', color: 'white', border: 'none', borderRadius: '8px', cursor: 'pointer', fontSize: '14px', transition: 'all 0.3s ease' }}
onMouseEnter={(e) => { e.currentTarget.style.background = '#5a6268'; e.currentTarget.style.transform = 'translateX(-2px)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = '#6c757d'; e.currentTarget.style.transform = 'translateX(0)'; }}
>
← Назад
</button>
<h2 style={{ margin: 0, color: '#2d3436', fontSize: '24px', fontWeight: 300, display: 'flex', alignItems: 'center', gap: '12px' }}>
💰 Финансы
</h2>
<div style={{ width: '100px' }} />
</div>
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} transition={{ delay: 0.1 }}
style={{ background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', borderRadius: '16px', padding: '25px 30px', color: 'white', marginBottom: '30px', boxShadow: '0 10px 30px rgba(102, 126, 234, 0.3)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap' }}>
<div>
<div style={{ fontSize: '14px', opacity: 0.8, marginBottom: '5px' }}>{employee.organization}</div>
<div style={{ fontSize: '20px', fontWeight: 500 }}>{employee.famaly} {employee.ima} {employee.otch}</div>
<div style={{ fontSize: '14px', opacity: 0.9, marginTop: '5px' }}>Табельный номер: {employee.tnom}</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '12px', opacity: 0.7 }}>Доступно</div>
<div style={{ fontSize: '28px', fontWeight: 600 }}>0.00</div>
<div style={{ fontSize: '12px', opacity: 0.7 }}>руб.</div>
</div>
</div>
</motion.div>
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }}
style={{ background: 'white', borderRadius: '16px', padding: '25px', boxShadow: '0 4px 20px rgba(0,0,0,0.06)', marginBottom: '25px' }}>
<h3 style={{ margin: '0 0 20px 0', color: '#2d3436', fontSize: '18px', fontWeight: 500, display: 'flex', alignItems: 'center', gap: '10px' }}>
📊 Финансово-экономические показатели
</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '15px' }}>
{indicators.map((item, index) => (
<motion.div key={index} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 * index }}
style={{ background: '#f8f9fa', borderRadius: '12px', padding: '16px 20px', border: '1px solid #f1f2f6' }}>
<div style={{ fontSize: '13px', color: '#636e72', marginBottom: '8px' }}>{item.title}</div>
<div style={{ fontSize: '24px', fontWeight: 600, color: '#2d3436' }}>
{item.value}
{item.unit && <span style={{ fontSize: '14px', fontWeight: 400, color: '#636e72', marginLeft: '4px' }}>{item.unit}</span>}
</div>
{item.change !== undefined && (
<div style={{ fontSize: '12px', color: item.change >= 0 ? '#2ecc71' : '#e74c3c', marginTop: '4px' }}>
{item.change >= 0 ? '↑' : '↓'} {Math.abs(item.change)}%
</div>
)}
</motion.div>
))}
</div>
</motion.div>
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.3 }}
style={{ background: 'white', borderRadius: '16px', padding: '25px', boxShadow: '0 4px 20px rgba(0,0,0,0.06)' }}>
<h3 style={{ margin: '0 0 20px 0', color: '#2d3436', fontSize: '18px', fontWeight: 500, display: 'flex', alignItems: 'center', gap: '10px' }}>
📄 Расчётный лист
</h3>
<div style={{ display: 'flex', gap: '15px', flexWrap: 'wrap', alignItems: 'center' }}>
<div style={{ flex: 1, minWidth: '200px' }}>
<label htmlFor="period-select" style={{ display: 'block', fontSize: '13px', color: '#636e72', marginBottom: '6px' }}>
📅 Выберите период:
</label>
<select
id="period-select"
value={selectedPeriod}
onChange={(e) => setSelectedPeriod(e.target.value)}
style={{ width: '100%', padding: '12px 16px', border: '2px solid #dfe6e9', borderRadius: '10px', fontSize: '15px', background: 'white', cursor: 'pointer', outline: 'none', transition: 'border-color 0.3s ease' }}
onFocus={(e) => e.target.style.borderColor = '#6c5ce7'}
onBlur={(e) => e.target.style.borderColor = '#dfe6e9'}
>
{periods.map(period => (
<option key={period} value={period}>{formatPeriod(period)}</option>
))}
</select>
</div>
<button
onClick={() => onViewPayslip(selectedPeriod)}
style={{ padding: '12px 30px', background: 'linear-gradient(135deg, #6c5ce7, #a29bfe)', color: 'white', border: 'none', borderRadius: '10px', fontSize: '15px', fontWeight: 500, cursor: 'pointer', transition: 'all 0.3s ease', boxShadow: '0 4px 15px rgba(108, 92, 231, 0.3)', display: 'flex', alignItems: 'center', gap: '8px' }}
onMouseEnter={(e) => { e.currentTarget.style.transform = 'translateY(-2px)'; e.currentTarget.style.boxShadow = '0 6px 20px rgba(108, 92, 231, 0.4)'; }}
onMouseLeave={(e) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 4px 15px rgba(108, 92, 231, 0.3)'; }}
>
📄 Просмотреть
</button>
</div>
<div style={{ marginTop: '20px', padding: '16px 20px', background: '#fff5f5', borderRadius: '10px', border: '1px solid #fde8e8' }}>
<div style={{ fontSize: '13px', color: '#636e72', marginBottom: '5px' }}>💡 Информация о премии</div>
<div style={{ fontSize: '14px', color: '#2d3436' }}>
Премия по итогам работы за {formatPeriod(selectedPeriod)} составляет 15% от оклада.
</div>
</div>
</motion.div>
</motion.div>
);
};
export default Finance;
+148 -11
View File
@@ -1,5 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import config from "../../src/config.json"; import { motion } from 'framer-motion';
import config from '../config.json';
interface GuestModeProps { interface GuestModeProps {
onBack: () => void; onBack: () => void;
} }
@@ -176,39 +178,136 @@ const GuestMode: React.FC<GuestModeProps> = ({ onBack }) => {
}; };
return ( return (
<div className="guest-mode"> <motion.div
<div className="top-bar"> className="guest-mode"
<button className="back-button" onClick={onBack}> initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{
maxWidth: '1200px',
margin: '20px auto',
padding: '30px',
background: 'white',
borderRadius: '16px',
boxShadow: '0 10px 40px rgba(0, 0, 0, 0.08)',
minHeight: '80vh',
}}
>
<div className="top-bar" style={{ marginBottom: '20px' }}>
<button
className="back-button"
onClick={onBack}
style={{
padding: '10px 20px',
background: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '14px',
transition: 'all 0.3s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#5a6268';
e.currentTarget.style.transform = 'translateX(-2px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#6c757d';
e.currentTarget.style.transform = 'translateX(0)';
}}
>
← Назад ← Назад
</button> </button>
</div> </div>
<div className="guest-content"> <div className="guest-content">
<h2>Гостевой режим</h2> <h2 style={{
<div className="tab-navigation"> textAlign: 'center',
color: '#2d3436',
fontSize: '28px',
fontWeight: 300,
marginBottom: '30px',
}}>
Гостевой режим
</h2>
<div className="tab-navigation" style={{
display: 'flex',
gap: '10px',
marginBottom: '25px',
borderBottom: '2px solid #f0f0f0',
paddingBottom: '10px',
}}>
<button <button
className={`tab-button ${activeTab === 'handbook' ? 'active' : ''}`} className={`tab-button ${activeTab === 'handbook' ? 'active' : ''}`}
onClick={() => setActiveTab('handbook')} onClick={() => setActiveTab('handbook')}
style={{
padding: '12px 28px',
border: 'none',
background: 'none',
cursor: 'pointer',
fontSize: '16px',
fontWeight: activeTab === 'handbook' ? 600 : 400,
color: activeTab === 'handbook' ? '#6c5ce7' : '#636e72',
borderBottom: activeTab === 'handbook' ? '3px solid #6c5ce7' : '3px solid transparent',
transition: 'all 0.3s ease',
}}
> >
Справочник Справочник
</button> </button>
<button <button
className={`tab-button ${activeTab === 'union' ? 'active' : ''}`} className={`tab-button ${activeTab === 'union' ? 'active' : ''}`}
onClick={() => setActiveTab('union')} onClick={() => setActiveTab('union')}
style={{
padding: '12px 28px',
border: 'none',
background: 'none',
cursor: 'pointer',
fontSize: '16px',
fontWeight: activeTab === 'union' ? 600 : 400,
color: activeTab === 'union' ? '#6c5ce7' : '#636e72',
borderBottom: activeTab === 'union' ? '3px solid #6c5ce7' : '3px solid transparent',
transition: 'all 0.3s ease',
}}
> >
Профсоюз Профсоюз
</button> </button>
<button <button
className={`tab-button ${activeTab === 'info' ? 'active' : ''}`} className={`tab-button ${activeTab === 'info' ? 'active' : ''}`}
onClick={() => setActiveTab('info')} onClick={() => setActiveTab('info')}
style={{
padding: '12px 28px',
border: 'none',
background: 'none',
cursor: 'pointer',
fontSize: '16px',
fontWeight: activeTab === 'info' ? 600 : 400,
color: activeTab === 'info' ? '#6c5ce7' : '#636e72',
borderBottom: activeTab === 'info' ? '3px solid #6c5ce7' : '3px solid transparent',
transition: 'all 0.3s ease',
}}
> >
Общая информация Общая информация
</button> </button>
</div> </div>
<div className="tab-content"> <div className="tab-content" style={{ minHeight: '400px' }}>
{activeTab === 'handbook' && ( {activeTab === 'handbook' && (
<div className="handbook-content"> <div className="handbook-content">
{loading ? ( {loading ? (
<div className="loading">Загрузка справочника...</div> <div className="loading" style={{
textAlign: 'center',
color: '#636e72',
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>Загрузка справочника...</p>
</div>
) : ( ) : (
<HandbookBlocks html={handbookContent} /> <HandbookBlocks html={handbookContent} />
)} )}
@@ -217,7 +316,22 @@ const GuestMode: React.FC<GuestModeProps> = ({ onBack }) => {
{activeTab === 'union' && ( {activeTab === 'union' && (
<div className="union-content"> <div className="union-content">
{loading ? ( {loading ? (
<div className="loading">Загрузка...</div> <div className="loading" style={{
textAlign: 'center',
color: '#636e72',
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>Загрузка...</p>
</div>
) : ( ) : (
unionBlocks.length ? renderContentBlocks(unionBlocks) : <p>Нет информации о профсоюзе.</p> unionBlocks.length ? renderContentBlocks(unionBlocks) : <p>Нет информации о профсоюзе.</p>
)} )}
@@ -226,7 +340,22 @@ const GuestMode: React.FC<GuestModeProps> = ({ onBack }) => {
{activeTab === 'info' && ( {activeTab === 'info' && (
<div className="info-content"> <div className="info-content">
{loading ? ( {loading ? (
<div className="loading">Загрузка...</div> <div className="loading" style={{
textAlign: 'center',
color: '#636e72',
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>Загрузка...</p>
</div>
) : ( ) : (
infoBlocks.length ? renderContentBlocks(infoBlocks) : <p>Нет общей информации.</p> infoBlocks.length ? renderContentBlocks(infoBlocks) : <p>Нет общей информации.</p>
)} )}
@@ -234,7 +363,15 @@ const GuestMode: React.FC<GuestModeProps> = ({ onBack }) => {
)} )}
</div> </div>
</div> </div>
</div> <style>
{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}
</style>
</motion.div>
); );
}; };
+68
View File
@@ -0,0 +1,68 @@
import React from 'react';
import { motion } from 'framer-motion';
interface LaborSafetyProps {
onBack: () => void;
employee: any;
}
const LaborSafety: React.FC<LaborSafetyProps> = ({ onBack, employee }) => {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
className="labor-safety-section"
style={{ padding: '20px', maxWidth: '1200px', margin: '0 auto', paddingBottom: '80px' }}
>
<div className="top-bar" style={{ marginBottom: '25px' }}>
<button
className="back-button"
onClick={onBack}
style={{ padding: '10px 20px', background: '#6c757d', color: 'white', border: 'none', borderRadius: '8px', cursor: 'pointer', fontSize: '14px', transition: 'all 0.3s ease' }}
onMouseEnter={(e) => { e.currentTarget.style.background = '#5a6268'; e.currentTarget.style.transform = 'translateX(-2px)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = '#6c757d'; e.currentTarget.style.transform = 'translateX(0)'; }}
>
← Назад
</button>
</div>
<h2 style={{ color: '#2d3436', fontSize: '24px', fontWeight: 300, marginBottom: '30px', display: 'flex', alignItems: 'center', gap: '12px' }}>
⛑️ Охрана труда
</h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: '20px' }}>
<motion.div whileHover={{ y: -5 }} style={{ background: 'white', borderRadius: '16px', padding: '25px', boxShadow: '0 4px 20px rgba(0,0,0,0.06)', cursor: 'pointer', transition: 'all 0.3s ease', border: '1px solid #f1f2f6' }}>
<div style={{ fontSize: '32px', color: '#e74c3c', marginBottom: '15px' }}>⚠️</div>
<h3 style={{ margin: '0 0 10px 0', color: '#2d3436', fontSize: '18px' }}>Изменения в документации</h3>
<p style={{ color: '#636e72', fontSize: '14px', lineHeight: 1.6, margin: 0 }}>Информирование о внесённых изменениях в ОЗИ и СТП</p>
</motion.div>
<motion.div whileHover={{ y: -5 }} style={{ background: 'white', borderRadius: '16px', padding: '25px', boxShadow: '0 4px 20px rgba(0,0,0,0.06)', cursor: 'pointer', transition: 'all 0.3s ease', border: '1px solid #f1f2f6' }}>
<div style={{ fontSize: '32px', color: '#3498db', marginBottom: '15px' }}>📋</div>
<h3 style={{ margin: '0 0 10px 0', color: '#2d3436', fontSize: '18px' }}>Средства индивидуальной защиты (СИЗ)</h3>
<p style={{ color: '#636e72', fontSize: '14px', lineHeight: 1.6, margin: 0 }}>График выдачи, напоминания, информация по СИЗ</p>
</motion.div>
<motion.div whileHover={{ y: -5 }} style={{ background: 'white', borderRadius: '16px', padding: '25px', boxShadow: '0 4px 20px rgba(0,0,0,0.06)', cursor: 'pointer', transition: 'all 0.3s ease', border: '1px solid #f1f2f6' }}>
<div style={{ fontSize: '32px', color: '#9b59b6', marginBottom: '15px' }}>📅</div>
<h3 style={{ margin: '0 0 10px 0', color: '#2d3436', fontSize: '18px' }}>График экзаменов</h3>
<p style={{ color: '#636e72', fontSize: '14px', lineHeight: 1.6, margin: 0 }}>Просмотр графика сдачи экзаменов по охране труда</p>
</motion.div>
<motion.div whileHover={{ y: -5 }} style={{ background: 'white', borderRadius: '16px', padding: '25px', boxShadow: '0 4px 20px rgba(0,0,0,0.06)', cursor: 'pointer', transition: 'all 0.3s ease', border: '1px solid #f1f2f6' }}>
<div style={{ fontSize: '32px', color: '#2ecc71', marginBottom: '15px' }}>🔔</div>
<h3 style={{ margin: '0 0 10px 0', color: '#2d3436', fontSize: '18px' }}>Уведомления</h3>
<p style={{ color: '#636e72', fontSize: '14px', lineHeight: 1.6, margin: 0 }}>Напоминания о своевременном получении СИЗ</p>
</motion.div>
</div>
<div style={{ marginTop: '30px', padding: '20px', background: '#f8f9fa', borderRadius: '12px', border: '1px solid #f1f2f6', textAlign: 'center', color: '#636e72' }}>
<p style={{ margin: 0 }}>ℹ️ Раздел в разработке. Функционал будет доступен в ближайшее время.</p>
</div>
</motion.div>
);
};
export default LaborSafety;
+238 -27
View File
@@ -1,18 +1,52 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { motion } from 'framer-motion';
interface LoginWindowProps { interface LoginWindowProps {
onLogin: (id: string) => void; onLogin: (id: string) => void;
onGuestMode: () => void; onGuestMode: () => void;
loginError?: string; loginError?: string;
inputRef?: React.RefObject<HTMLInputElement>; // ✅ Реф для управления фокусом
} }
const LoginWindow: React.FC<LoginWindowProps> = ({ onLogin, onGuestMode, loginError }) => {
const LoginWindow: React.FC<LoginWindowProps> = ({
onLogin,
onGuestMode,
loginError,
inputRef
}) => {
const [id, setId] = useState(''); const [id, setId] = useState('');
const inputRef = useRef<HTMLInputElement>(null); const [isCardDetected, setIsCardDetected] = useState(false);
// ✅ Используем переданный реф или внутренний
const internalInputRef = useRef<HTMLInputElement>(null);
const activeInputRef = inputRef || internalInputRef;
// ✅ При монтировании — фокус на поле ввода
useEffect(() => {
if (activeInputRef.current) {
setTimeout(() => {
activeInputRef.current?.focus();
}, 100);
}
}, [activeInputRef]);
// ✅ При ошибке — фокус на поле ввода
useEffect(() => {
if (loginError) {
setId('');
if (activeInputRef.current) {
setTimeout(() => {
activeInputRef.current?.focus();
}, 100);
}
}
}, [loginError, activeInputRef]);
useEffect(() => { useEffect(() => {
const testConnection = async () => { const testConnection = async () => {
try { try {
const response = await fetch('http://localhost:3001/api/test', { const response = await fetch('http://localhost:3001/api/test', {
mode: 'cors', // Явно указываем режим CORS mode: 'cors',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
@@ -29,21 +63,26 @@ const LoginWindow: React.FC<LoginWindowProps> = ({ onLogin, onGuestMode, loginEr
} }
}; };
testConnection(); testConnection();
const setupCardListener = () => { const setupCardListener = () => {
console.log('[LoginWindow] Setting up card listener...'); console.log('[LoginWindow] Setting up card listener...');
const handleCardDetected = (uid: string) => { const handleCardDetected = (uid: string) => {
console.log('Card detected in renderer via preload:', uid); console.log('Card detected in renderer via preload:', uid);
setIsCardDetected(true);
onLogin(uid); onLogin(uid);
setTimeout(() => setIsCardDetected(false), 2000);
}; };
window.electronAPI.onCardDetected(handleCardDetected);
};
if (window.electronAPI) { if (window.electronAPI) {
setupCardListener(); window.electronAPI.onCardDetected(handleCardDetected);
} else { } else {
console.log('[LoginWindow] electronAPI not ready, waiting for event...'); console.log('[LoginWindow] electronAPI not ready, waiting for event...');
window.addEventListener('electronApiReady', setupCardListener, { once: true }); window.addEventListener('electronApiReady', setupCardListener, { once: true });
} }
};
setupCardListener();
return () => { return () => {
console.log('[LoginWindow] Cleaning up listeners.'); console.log('[LoginWindow] Cleaning up listeners.');
window.removeEventListener('electronApiReady', setupCardListener); window.removeEventListener('electronApiReady', setupCardListener);
@@ -53,13 +92,6 @@ const LoginWindow: React.FC<LoginWindowProps> = ({ onLogin, onGuestMode, loginEr
}; };
}, [onLogin]); }, [onLogin]);
useEffect(() => {
if (loginError) {
setId('');
inputRef.current?.focus();
}
}, [loginError]);
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (id.trim()) { if (id.trim()) {
@@ -68,37 +100,216 @@ const LoginWindow: React.FC<LoginWindowProps> = ({ onLogin, onGuestMode, loginEr
}; };
return ( return (
<div className="login-window"> <motion.div
<h2>Вход в систему</h2> className="login-window"
<div className="card-prompt"> initial={{ opacity: 0, y: 20 }}
<p>Пожалуйста, приложите вашу карту пропуска к считывателю</p> animate={{ opacity: 1, y: 0 }}
<div className="card-icon">💳</div> transition={{ duration: 0.6, ease: 'easeOut' }}
style={{
maxWidth: '420px',
margin: '10% auto',
padding: '40px',
background: 'rgba(255, 255, 255, 0.98)',
borderRadius: '20px',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.15)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255,255,255,0.2)',
position: 'relative',
}}
>
{/* Индикатор считывания карты */}
{isCardDetected && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
style={{
position: 'absolute',
top: '-10px',
right: '-10px',
background: '#2ecc71',
color: '#fff',
padding: '6px 12px',
borderRadius: '20px',
fontSize: '12px',
fontWeight: 600,
boxShadow: '0 4px 15px rgba(46, 204, 113, 0.4)',
}}
>
✅ Карта считана
</motion.div>
)}
<h2 style={{
textAlign: 'center',
color: '#2d3436',
fontSize: '28px',
fontWeight: 300,
marginBottom: '10px',
letterSpacing: '1px',
}}>
Инфокиоск
</h2>
<p style={{
textAlign: 'center',
color: '#636e72',
fontSize: '14px',
marginBottom: '30px',
opacity: 0.7,
}}>
Приложите карту пропуска
</p>
<div style={{
display: 'flex',
justifyContent: 'center',
marginBottom: '20px',
}}>
<div style={{
fontSize: '64px',
animation: 'pulse 2s ease-in-out infinite',
filter: isCardDetected ? 'drop-shadow(0 0 20px rgba(46, 204, 113, 0.5))' : 'none',
}}>
💳
</div>
</div> </div>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="input-group"> <div className="input-group" style={{ marginBottom: '15px' }}>
<label htmlFor="id"></label> <label htmlFor="id" style={{
display: 'block',
marginBottom: '8px',
color: '#636e72',
fontSize: '13px',
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: '0.5px',
}}>
Введите ID или приложите карту
</label>
<input <input
ref={inputRef} ref={activeInputRef}
type="password" type="text"
id="id" id="id"
value={id} value={id}
onChange={(e) => setId(e.target.value)} onChange={(e) => setId(e.target.value)}
placeholder=" " placeholder="Введите табельный номер..."
autoFocus autoFocus
style={{
width: '100%',
padding: '14px 16px',
border: `2px solid ${loginError ? '#e74c3c' : '#dfe6e9'}`,
borderRadius: '12px',
fontSize: '16px',
transition: 'all 0.3s ease',
background: '#f8f9fa',
outline: 'none',
}}
onFocus={(e) => {
e.target.style.borderColor = '#6c5ce7';
e.target.style.boxShadow = '0 0 0 4px rgba(108, 92, 231, 0.1)';
}}
onBlur={(e) => {
e.target.style.borderColor = loginError ? '#e74c3c' : '#dfe6e9';
e.target.style.boxShadow = 'none';
}}
/> />
</div> </div>
{loginError && <div className="error" style={{ color: 'red', marginTop: 8 }}>{loginError}</div>}
<div className="button-group"> {loginError && (
<button type="submit" disabled={!id.trim()}> <motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
style={{
color: '#e74c3c',
fontSize: '13px',
marginTop: '-10px',
marginBottom: '15px',
background: '#fde8e8',
padding: '10px 14px',
borderRadius: '8px',
border: '1px solid #f5c6cb',
}}
>
⚠️ {loginError}
</motion.div>
)}
<div style={{
display: 'flex',
gap: '12px',
marginTop: '20px',
}}>
<button
type="submit"
disabled={!id.trim()}
style={{
flex: 1,
padding: '14px',
border: 'none',
borderRadius: '12px',
fontSize: '16px',
fontWeight: 600,
cursor: id.trim() ? 'pointer' : 'not-allowed',
background: id.trim() ? 'linear-gradient(135deg, #6c5ce7, #a29bfe)' : '#dfe6e9',
color: id.trim() ? '#fff' : '#b2bec3',
transition: 'all 0.3s ease',
boxShadow: id.trim() ? '0 4px 15px rgba(108, 92, 231, 0.3)' : 'none',
}}
onMouseEnter={(e) => {
if (id.trim()) {
e.currentTarget.style.transform = 'translateY(-2px)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
}}
>
Войти Войти
</button> </button>
<button type="button" onClick={onGuestMode}> <button
type="button"
onClick={onGuestMode}
style={{
flex: 1,
padding: '14px',
border: '2px solid #dfe6e9',
borderRadius: '12px',
fontSize: '16px',
fontWeight: 500,
cursor: 'pointer',
background: 'transparent',
color: '#636e72',
transition: 'all 0.3s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#6c5ce7';
e.currentTarget.style.color = '#6c5ce7';
e.currentTarget.style.transform = 'translateY(-2px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#dfe6e9';
e.currentTarget.style.color = '#636e72';
e.currentTarget.style.transform = 'translateY(0)';
}}
>
Гостевой режим Гостевой режим
</button> </button>
</div> </div>
</form> </form>
<div style={{
marginTop: '20px',
textAlign: 'center',
fontSize: '11px',
color: '#b2bec3',
borderTop: '1px solid #f1f2f6',
paddingTop: '15px',
}}>
<span>Для автоматического входа приложите карту к считывателю</span>
</div> </div>
</motion.div>
); );
}; };
+863
View File
@@ -0,0 +1,863 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
FaArrowLeft,
FaSpinner,
FaPlus,
FaMinus,
FaHome,
FaArrowUp,
FaArrowDown,
FaArrowLeft as FaArrowLeftIcon,
FaArrowRight,
FaSearch,
FaTimes,
FaMapMarkerAlt
} from 'react-icons/fa';
interface MapViewerProps {
fileUrl: string;
onBack: () => void;
title?: string;
}
interface MapMarker {
id: string;
name: string;
x: number;
y: number;
description?: string;
}
const MapViewer: React.FC<MapViewerProps> = ({
fileUrl,
onBack,
title = 'Карта предприятия'
}) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [zoom, setZoom] = useState(1);
const [minZoom, setMinZoom] = useState(1);
const [showControls, setShowControls] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [showSearch, setShowSearch] = useState(false);
const [searchResults, setSearchResults] = useState<MapMarker[]>([]);
// 📐 Реальные размеры изображения
const [imgWidth, setImgWidth] = useState(0);
const [imgHeight, setImgHeight] = useState(0);
const [imageLoaded, setImageLoaded] = useState(false);
// 📍 Панорамирование
const [offsetX, setOffsetX] = useState(0);
const [offsetY, setOffsetY] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [dragStartX, setDragStartX] = useState(0);
const [dragStartY, setDragStartY] = useState(0);
const [startOffsetX, setStartOffsetX] = useState(0);
const [startOffsetY, setStartOffsetY] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
// 📍 Тестовые метки
const markers: MapMarker[] = [
{ id: '1', name: 'Главный вход', x: 50, y: 20, description: 'Вход для посетителей' },
{ id: '2', name: 'Проходная', x: 30, y: 50, description: 'КПП №1' },
{ id: '3', name: 'Склад', x: 70, y: 70, description: 'Склад готовой продукции' },
{ id: '4', name: 'Столовая', x: 45, y: 80, description: 'Столовая №2' },
{ id: '5', name: 'Администрация', x: 20, y: 30, description: 'Главный офис' },
];
// 📄 Загрузка изображения
useEffect(() => {
const loadImage = () => {
const img = new Image();
img.onload = () => {
setImgWidth(img.width);
setImgHeight(img.height);
setImageLoaded(true);
setLoading(false);
};
img.onerror = () => {
setError('Не удалось загрузить изображение');
setLoading(false);
};
img.src = fileUrl;
};
loadImage();
}, [fileUrl]);
// ✅ Расчет минимального масштаба ПОСЛЕ загрузки изображения и рендера контейнера
useEffect(() => {
if (!imageLoaded || !containerRef.current) return;
const calculateMinZoom = () => {
const container = containerRef.current;
if (!container || imgHeight === 0) return;
const rect = container.getBoundingClientRect();
const newMinZoom = rect.height / imgHeight;
setMinZoom(newMinZoom);
setZoom(newMinZoom);
};
// Ждем один кадр, чтобы DOM обновился
requestAnimationFrame(() => {
calculateMinZoom();
});
// Также пересчитываем при ресайзе
const handleResize = () => {
if (imgHeight > 0 && containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
const newMinZoom = rect.height / imgHeight;
setMinZoom(newMinZoom);
setZoom(prev => Math.max(prev, newMinZoom));
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [imageLoaded, imgHeight]);
// 🔄 Масштабирование
const handleZoomIn = () => {
setZoom(prev => Math.min(prev + 0.2, 3));
};
const handleZoomOut = () => {
setZoom(prev => Math.max(prev - 0.2, minZoom));
};
const handleZoomReset = () => {
setZoom(minZoom);
setOffsetX(0);
setOffsetY(0);
};
// 📍 Получение границ
const getBounds = useCallback(() => {
const container = containerRef.current;
if (!container || imgWidth === 0 || imgHeight === 0) {
return { maxX: 0, maxY: 0 };
}
const rect = container.getBoundingClientRect();
const displayWidth = imgWidth * zoom;
const displayHeight = imgHeight * zoom;
const maxX = Math.max(0, (displayWidth - rect.width) / 2);
const maxY = Math.max(0, (displayHeight - rect.height) / 2);
return { maxX, maxY };
}, [imgWidth, imgHeight, zoom]);
// 📍 Ограничение смещения
const clampOffset = useCallback((x: number, y: number) => {
const { maxX, maxY } = getBounds();
return {
x: Math.max(-maxX, Math.min(maxX, x)),
y: Math.max(-maxY, Math.min(maxY, y))
};
}, [getBounds]);
// 📍 Панорамирование
const handlePan = useCallback((dx: number, dy: number) => {
const step = 80 / zoom;
const newX = offsetX + dx * step;
const newY = offsetY + dy * step;
const clamped = clampOffset(newX, newY);
setOffsetX(clamped.x);
setOffsetY(clamped.y);
}, [offsetX, offsetY, zoom, clampOffset]);
// 🖱️ Drag
const handleMouseDown = (e: React.MouseEvent) => {
setIsDragging(true);
setDragStartX(e.clientX);
setDragStartY(e.clientY);
setStartOffsetX(offsetX);
setStartOffsetY(offsetY);
};
const handleMouseMove = (e: React.MouseEvent) => {
if (!isDragging) return;
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
const newX = startOffsetX + dx;
const newY = startOffsetY + dy;
const clamped = clampOffset(newX, newY);
setOffsetX(clamped.x);
setOffsetY(clamped.y);
};
const handleMouseUp = () => {
setIsDragging(false);
};
const handleTouchStart = (e: React.TouchEvent) => {
const touch = e.touches[0];
setIsDragging(true);
setDragStartX(touch.clientX);
setDragStartY(touch.clientY);
setStartOffsetX(offsetX);
setStartOffsetY(offsetY);
};
const handleTouchMove = (e: React.TouchEvent) => {
if (!isDragging) return;
const touch = e.touches[0];
const dx = touch.clientX - dragStartX;
const dy = touch.clientY - dragStartY;
const newX = startOffsetX + dx;
const newY = startOffsetY + dy;
const clamped = clampOffset(newX, newY);
setOffsetX(clamped.x);
setOffsetY(clamped.y);
};
const handleTouchEnd = () => {
setIsDragging(false);
};
// 🔍 Поиск
const handleSearch = (query: string) => {
setSearchQuery(query);
if (query.trim().length > 0) {
const results = markers.filter(marker =>
marker.name.toLowerCase().includes(query.toLowerCase()) ||
(marker.description && marker.description.toLowerCase().includes(query.toLowerCase()))
);
setSearchResults(results);
if (results.length > 0) {
const marker = results[0];
const container = containerRef.current;
if (container) {
const rect = container.getBoundingClientRect();
const displayWidth = imgWidth * zoom;
const displayHeight = imgHeight * zoom;
const targetX = (marker.x / 100) * displayWidth - rect.width / 2;
const targetY = (marker.y / 100) * displayHeight - rect.height / 2;
const clamped = clampOffset(-targetX, -targetY);
setOffsetX(clamped.x);
setOffsetY(clamped.y);
}
}
} else {
setSearchResults([]);
}
};
const clearSearch = () => {
setSearchQuery('');
setSearchResults([]);
setShowSearch(false);
};
// ⏱️ Авто-скрытие
useEffect(() => {
let timeout: NodeJS.Timeout;
const handleActivity = () => {
setShowControls(true);
clearTimeout(timeout);
timeout = setTimeout(() => {
setShowControls(false);
}, 4000);
};
document.addEventListener('mousemove', handleActivity);
document.addEventListener('touchstart', handleActivity);
document.addEventListener('keydown', handleActivity);
return () => {
document.removeEventListener('mousemove', handleActivity);
document.removeEventListener('touchstart', handleActivity);
document.removeEventListener('keydown', handleActivity);
clearTimeout(timeout);
};
}, []);
// ⌨️ ESC
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (showSearch) {
clearSearch();
} else {
onBack();
}
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [showSearch, onBack]);
// 🎨 Рендер
if (loading) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.9)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10000,
}}
>
<FaSpinner style={{
fontSize: '60px',
color: '#2ecc71',
animation: 'spin 1s linear infinite',
}} />
<span style={{ color: 'white', marginTop: '20px', fontSize: '18px' }}>
Загрузка карты...
</span>
</motion.div>
);
}
if (error) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.9)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10000,
padding: '20px',
}}
>
<div style={{ fontSize: '64px', marginBottom: '20px' }}>🗺️</div>
<h3 style={{ color: 'white', margin: '0 0 10px 0', fontSize: '24px' }}>
Карта не найдена
</h3>
<p style={{ color: '#b2bec3', margin: '0 0 10px 0', textAlign: 'center' }}>
{error}
</p>
<button
onClick={onBack}
style={{
padding: '16px 40px',
background: '#2ecc71',
color: 'white',
border: 'none',
borderRadius: '12px',
cursor: 'pointer',
fontSize: '18px',
fontWeight: 500,
}}
>
Вернуться
</button>
</motion.div>
);
}
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: '#1a1a2e',
zIndex: 10000,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
userSelect: 'none',
}}
>
{/* ⬅️ Кнопка Назад */}
<button
onClick={onBack}
style={{
position: 'absolute',
top: '16px',
left: '16px',
zIndex: 100,
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '10px 18px',
background: 'rgba(0, 0, 0, 0.7)',
backdropFilter: 'blur(10px)',
color: 'white',
border: '1px solid rgba(255,255,255,0.15)',
borderRadius: '10px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 500,
transition: 'all 0.3s ease',
}}
>
<FaArrowLeft style={{ fontSize: '16px' }} />
<span>Назад</span>
</button>
{/* 🔍 Поле поиска — сверху справа */}
<div style={{
position: 'absolute',
top: '16px',
right: '16px',
zIndex: 100,
display: 'flex',
alignItems: 'center',
gap: '10px',
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
borderRadius: '24px',
padding: '6px 12px 6px 16px',
border: showSearch ? '2px solid #4285f4' : '2px solid transparent',
boxShadow: showSearch ? '0 4px 20px rgba(66, 133, 244, 0.3)' : '0 2px 10px rgba(0,0,0,0.2)',
minWidth: showSearch ? '280px' : '44px',
transition: 'all 0.3s ease',
height: '44px',
}}>
<button
onClick={() => setShowSearch(!showSearch)}
style={{
padding: '4px',
background: 'transparent',
color: '#5f6368',
border: 'none',
cursor: 'pointer',
fontSize: '18px',
borderRadius: '50%',
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: '28px',
minHeight: '28px',
}}
>
<FaSearch />
</button>
{showSearch && (
<motion.div
initial={{ width: 0, opacity: 0 }}
animate={{ width: 'auto', opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
overflow: 'hidden',
flex: 1,
}}
>
<input
type="text"
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Поиск по карте..."
autoFocus
style={{
padding: '8px 0',
background: 'transparent',
color: '#202124',
border: 'none',
outline: 'none',
fontSize: '15px',
minWidth: '180px',
width: '100%',
fontFamily: 'inherit',
}}
/>
{searchQuery && (
<button
onClick={clearSearch}
style={{
padding: '4px 6px',
background: 'transparent',
color: '#5f6368',
border: 'none',
cursor: 'pointer',
fontSize: '16px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<FaTimes />
</button>
)}
</motion.div>
)}
</div>
{/* 📍 Результаты поиска */}
{searchResults.length > 0 && showSearch && (
<div style={{
position: 'absolute',
top: '68px',
right: '16px',
zIndex: 99,
background: 'rgba(255, 255, 255, 0.98)',
backdropFilter: 'blur(10px)',
borderRadius: '16px',
border: '1px solid rgba(0,0,0,0.1)',
boxShadow: '0 8px 30px rgba(0,0,0,0.2)',
padding: '8px 0',
minWidth: '300px',
maxHeight: '300px',
overflowY: 'auto',
}}>
{searchResults.map((marker) => (
<button
key={marker.id}
onClick={() => {
const container = containerRef.current;
if (container) {
const rect = container.getBoundingClientRect();
const displayWidth = imgWidth * zoom;
const displayHeight = imgHeight * zoom;
const targetX = (marker.x / 100) * displayWidth - rect.width / 2;
const targetY = (marker.y / 100) * displayHeight - rect.height / 2;
const clamped = clampOffset(-targetX, -targetY);
setOffsetX(clamped.x);
setOffsetY(clamped.y);
}
setSearchQuery('');
setSearchResults([]);
setShowSearch(false);
}}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '12px 20px',
background: 'transparent',
color: '#202124',
border: 'none',
cursor: 'pointer',
width: '100%',
textAlign: 'left',
transition: 'all 0.15s ease',
fontSize: '14px',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f1f3f4';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
>
<FaMapMarkerAlt style={{ color: '#ea4335', fontSize: '18px', flexShrink: 0 }} />
<div>
<div style={{ fontWeight: 500 }}>{marker.name}</div>
{marker.description && (
<div style={{ fontSize: '12px', color: '#5f6368' }}>
{marker.description}
</div>
)}
</div>
</button>
))}
</div>
)}
{/* 🗺️ Контейнер с картой */}
<div
ref={containerRef}
style={{
flex: 1,
background: '#1a1a2e',
overflow: 'hidden',
position: 'relative',
cursor: isDragging ? 'grabbing' : 'grab',
touchAction: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{/* ✅ TIFF изображение */}
<img
ref={imageRef}
src={fileUrl}
alt="Карта предприятия"
style={{
width: `${imgWidth * zoom}px`,
height: `${imgHeight * zoom}px`,
transform: `translate(${offsetX}px, ${offsetY}px)`,
transformOrigin: 'center center',
transition: isDragging ? 'none' : 'transform 0.3s ease',
willChange: 'transform',
position: 'absolute',
top: '50%',
left: '50%',
marginLeft: `${-imgWidth * zoom / 2}px`,
marginTop: `${-imgHeight * zoom / 2}px`,
pointerEvents: 'none',
objectFit: 'contain',
}}
/>
{/* 👆 Подсказка */}
{!isDragging && showControls && (
<div style={{
position: 'absolute',
bottom: '110px',
left: '50%',
transform: 'translateX(-50%)',
color: 'rgba(255,255,255,0.5)',
fontSize: '13px',
textAlign: 'center',
pointerEvents: 'none',
zIndex: 50,
background: 'rgba(0,0,0,0.5)',
padding: '8px 20px',
borderRadius: '10px',
backdropFilter: 'blur(5px)',
whiteSpace: 'nowrap',
}}>
👆 Перетащите для перемещения
</div>
)}
</div>
{/* 🎮 Нижняя панель управления */}
<AnimatePresence>
{showControls && (
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
transition={{ duration: 0.3 }}
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
zIndex: 100,
background: 'linear-gradient(0deg, rgba(0,0,0,0.9) 0%, transparent 100%)',
padding: '30px 16px 20px 16px',
display: 'flex',
justifyContent: 'center',
gap: '16px',
flexWrap: 'wrap',
}}
>
{/* Кнопки панорамирования */}
<div style={{
display: 'grid',
gridTemplateColumns: '56px 56px 56px',
gridTemplateRows: '56px 56px 56px',
gap: '5px',
background: 'rgba(0,0,0,0.6)',
backdropFilter: 'blur(20px)',
padding: '8px',
borderRadius: '16px',
border: '1px solid rgba(255,255,255,0.08)',
}}>
<button
onClick={() => handlePan(0, 1)}
style={{
gridColumn: '2',
gridRow: '1',
padding: '4px',
background: 'rgba(255,255,255,0.10)',
color: 'white',
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: '10px',
cursor: 'pointer',
fontSize: '18px',
transition: 'all 0.15s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<FaArrowUp />
</button>
<button
onClick={() => handlePan(1, 0)}
style={{
gridColumn: '1',
gridRow: '2',
padding: '4px',
background: 'rgba(255,255,255,0.10)',
color: 'white',
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: '10px',
cursor: 'pointer',
fontSize: '18px',
transition: 'all 0.15s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<FaArrowLeftIcon />
</button>
<button
onClick={handleZoomReset}
style={{
gridColumn: '2',
gridRow: '2',
padding: '4px',
background: 'rgba(46, 204, 113, 0.25)',
color: '#2ecc71',
border: '1px solid rgba(46, 204, 113, 0.15)',
borderRadius: '10px',
cursor: 'pointer',
fontSize: '18px',
transition: 'all 0.15s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<FaHome />
</button>
<button
onClick={() => handlePan(-1, 0)}
style={{
gridColumn: '3',
gridRow: '2',
padding: '4px',
background: 'rgba(255,255,255,0.10)',
color: 'white',
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: '10px',
cursor: 'pointer',
fontSize: '18px',
transition: 'all 0.15s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<FaArrowRight />
</button>
<button
onClick={() => handlePan(0, -1)}
style={{
gridColumn: '2',
gridRow: '3',
padding: '4px',
background: 'rgba(255,255,255,0.10)',
color: 'white',
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: '10px',
cursor: 'pointer',
fontSize: '18px',
transition: 'all 0.15s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<FaArrowDown />
</button>
</div>
{/* Кнопки масштабирования */}
<div style={{
display: 'flex',
gap: '6px',
background: 'rgba(0,0,0,0.6)',
backdropFilter: 'blur(20px)',
padding: '8px',
borderRadius: '16px',
border: '1px solid rgba(255,255,255,0.08)',
alignItems: 'center',
}}>
<button
onClick={handleZoomOut}
disabled={zoom <= minZoom}
style={{
padding: '12px 16px',
background: 'rgba(255,255,255,0.10)',
color: 'white',
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: '10px',
cursor: zoom > minZoom ? 'pointer' : 'default',
fontSize: '18px',
fontWeight: 600,
transition: 'all 0.15s ease',
minWidth: '48px',
minHeight: '48px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
opacity: zoom > minZoom ? 1 : 0.3,
pointerEvents: zoom > minZoom ? 'auto' : 'none',
}}
>
<FaMinus />
</button>
<span style={{
color: 'rgba(255,255,255,0.8)',
fontSize: '13px',
fontWeight: 600,
minWidth: '44px',
textAlign: 'center',
}}>
{Math.round(zoom * 100)}%
</span>
<button
onClick={handleZoomIn}
style={{
padding: '12px 16px',
background: 'rgba(255,255,255,0.10)',
color: 'white',
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: '10px',
cursor: 'pointer',
fontSize: '18px',
fontWeight: 600,
transition: 'all 0.15s ease',
minWidth: '48px',
minHeight: '48px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<FaPlus />
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
};
export default MapViewer;
+62
View File
@@ -0,0 +1,62 @@
import React from 'react';
import { motion } from 'framer-motion';
interface MedicineProps {
onBack: () => void;
employee: any;
}
const Medicine: React.FC<MedicineProps> = ({ onBack, employee }) => {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
className="medicine-section"
style={{ padding: '20px', maxWidth: '1200px', margin: '0 auto', paddingBottom: '80px' }}
>
<div className="top-bar" style={{ marginBottom: '25px' }}>
<button
className="back-button"
onClick={onBack}
style={{ padding: '10px 20px', background: '#6c757d', color: 'white', border: 'none', borderRadius: '8px', cursor: 'pointer', fontSize: '14px', transition: 'all 0.3s ease' }}
onMouseEnter={(e) => { e.currentTarget.style.background = '#5a6268'; e.currentTarget.style.transform = 'translateX(-2px)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = '#6c757d'; e.currentTarget.style.transform = 'translateX(0)'; }}
>
← Назад
</button>
</div>
<h2 style={{ color: '#2d3436', fontSize: '24px', fontWeight: 300, marginBottom: '30px', display: 'flex', alignItems: 'center', gap: '12px' }}>
🏥 Медицина
</h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: '20px' }}>
<motion.div whileHover={{ y: -5 }} style={{ background: 'white', borderRadius: '16px', padding: '25px', boxShadow: '0 4px 20px rgba(0,0,0,0.06)', cursor: 'pointer', transition: 'all 0.3s ease', border: '1px solid #f1f2f6' }}>
<div style={{ fontSize: '32px', color: '#3498db', marginBottom: '15px' }}>📋</div>
<h3 style={{ margin: '0 0 10px 0', color: '#2d3436', fontSize: '18px' }}>Медицинская карта</h3>
<p style={{ color: '#636e72', fontSize: '14px', lineHeight: 1.6, margin: 0 }}>Просмотр информации из медицинской карты</p>
</motion.div>
<motion.div whileHover={{ y: -5 }} style={{ background: 'white', borderRadius: '16px', padding: '25px', boxShadow: '0 4px 20px rgba(0,0,0,0.06)', cursor: 'pointer', transition: 'all 0.3s ease', border: '1px solid #f1f2f6' }}>
<div style={{ fontSize: '32px', color: '#2ecc71', marginBottom: '15px' }}>🏥</div>
<h3 style={{ margin: '0 0 10px 0', color: '#2d3436', fontSize: '18px' }}>Запись в поликлинику</h3>
<p style={{ color: '#636e72', fontSize: '14px', lineHeight: 1.6, margin: 0 }}>Онлайн запись к врачу</p>
</motion.div>
<motion.div whileHover={{ y: -5 }} style={{ background: 'white', borderRadius: '16px', padding: '25px', boxShadow: '0 4px 20px rgba(0,0,0,0.06)', cursor: 'pointer', transition: 'all 0.3s ease', border: '1px solid #f1f2f6' }}>
<div style={{ fontSize: '32px', color: '#f39c12', marginBottom: '15px' }}>🛡️</div>
<h3 style={{ margin: '0 0 10px 0', color: '#2d3436', fontSize: '18px' }}>Страховая компания</h3>
<p style={{ color: '#636e72', fontSize: '14px', lineHeight: 1.6, margin: 0 }}>Оформление заявок на медицинские услуги</p>
</motion.div>
</div>
<div style={{ marginTop: '30px', padding: '20px', background: '#f8f9fa', borderRadius: '12px', border: '1px solid #f1f2f6', textAlign: 'center', color: '#636e72' }}>
<p style={{ margin: 0 }}>ℹ️ Раздел в разработке. Функционал будет доступен в ближайшее время.</p>
</div>
</motion.div>
);
};
export default Medicine;
+105
View File
@@ -0,0 +1,105 @@
import React from 'react';
interface MainMenuProps {
onSectionSelect: (section: string) => void;
employee: any;
}
const MainMenu: React.FC<MainMenuProps> = ({ onSectionSelect, employee }) => {
console.log('🔵 MainMenu компонент рендерится!');
const menuItems = [
{ id: 'employee-info', title: 'Моя информация', emoji: '📋' },
{ id: 'finance', title: 'Финансы', emoji: '💰' },
{ id: 'medicine', title: 'Медицина', emoji: '🏥' },
{ id: 'trade-union', title: 'Профсоюз', emoji: '🤝' },
{ id: 'labor-safety', title: 'Охрана труда', emoji: '⛑️' },
{ id: 'contacts', title: 'Контакты', emoji: '📞' },
{ id: 'news', title: 'Новости', emoji: '📰' },
{ id: 'profile', title: 'Обо мне', emoji: '👤' },
];
return (
<div style={{
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '40px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
}}>
{employee && (
<div style={{
color: '#fff',
fontSize: '1.2rem',
marginBottom: '10px',
}}>
Здравствуйте, {employee.ima} {employee.famaly}
</div>
)}
<h1 style={{
color: '#fff',
fontSize: '2.5rem',
marginBottom: '40px',
fontWeight: 300,
}}>
Выберите раздел
</h1>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 200px))',
gap: '25px',
maxWidth: '900px',
width: '100%',
justifyContent: 'center',
}}>
{menuItems.map((item) => (
<button
key={item.id}
onClick={() => onSectionSelect(item.id)}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '12px',
padding: '25px 20px',
background: 'rgba(255,255,255,0.95)',
border: 'none',
borderRadius: '16px',
cursor: 'pointer',
boxShadow: '0 8px 30px rgba(0,0,0,0.12)',
minHeight: '140px',
fontSize: '2.5rem',
transition: 'all 0.3s ease',
}}
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',
}}>
{item.title}
</span>
</button>
))}
</div>
</div>
);
};
console.log('🟢 MainMenu.tsx экспорт готов');
export default MainMenu;
+232
View File
@@ -0,0 +1,232 @@
import React, { useState, type ComponentType, type SVGAttributes } from 'react';
import { motion } from 'framer-motion';
import { FaNewspaper, FaCalendarAlt, FaUser, FaTag } from 'react-icons/fa';
interface NewsProps {
onBack: () => void;
}
interface NewsItem {
id: number;
title: string;
date: string;
content: string;
category: string;
author: string;
}
const IconWrapper = ({ icon: Icon, style }: { icon: any; style?: React.CSSProperties }) => (
<Icon style={style} />
);
const News: React.FC<NewsProps> = ({ onBack }) => {
const [news, setNews] = useState<NewsItem[]>([
{
id: 1,
title: 'День химика - поздравления коллективу!',
date: '2024-05-27',
content: 'Уважаемые сотрудники! Поздравляем вас с профессиональным праздником - Днем химика! Желаем успехов в работе, крепкого здоровья и благополучия.',
category: 'Праздники',
author: 'Администрация',
},
{
id: 2,
title: 'Новые правила охраны труда',
date: '2024-05-25',
content: 'С 1 июня вводятся новые правила по охране труда. Просим ознакомиться с изменениями в разделе "Охрана труда".',
category: 'Охрана труда',
author: 'Отдел ОТ',
},
{
id: 3,
title: 'Спортивные соревнования среди цехов',
date: '2024-05-20',
content: 'Приглашаем принять участие в ежегодных спортивных соревнованиях. Заявки принимаются до 10 июня.',
category: 'Спорт',
author: 'Профсоюз',
},
]);
const [selectedCategory, setSelectedCategory] = useState<string>('Все');
const categories = ['Все', ...Array.from(new Set(news.map(item => item.category)))];
const filteredNews = selectedCategory === 'Все'
? news
: news.filter(item => item.category === selectedCategory);
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
className="news-section"
style={{
padding: '20px',
maxWidth: '1200px',
margin: '0 auto',
paddingBottom: '80px',
}}
>
<div className="top-bar" style={{ marginBottom: '25px' }}>
<button
className="back-button"
onClick={onBack}
style={{
padding: '10px 20px',
background: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '14px',
transition: 'all 0.3s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#5a6268';
e.currentTarget.style.transform = 'translateX(-2px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#6c757d';
e.currentTarget.style.transform = 'translateX(0)';
}}
>
← Назад
</button>
</div>
<h2 style={{
color: '#2d3436',
fontSize: '24px',
fontWeight: 300,
marginBottom: '30px',
display: 'flex',
alignItems: 'center',
gap: '12px',
}}>
<IconWrapper icon={FaNewspaper} style={{ color: '#1abc9c' }} />
Новости
</h2>
{/* Фильтр по категориям */}
<div style={{
display: 'flex',
gap: '10px',
flexWrap: 'wrap',
marginBottom: '25px',
}}>
{categories.map(category => (
<motion.button
key={category}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => setSelectedCategory(category)}
style={{
padding: '8px 20px',
border: 'none',
borderRadius: '20px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: selectedCategory === category ? 600 : 400,
background: selectedCategory === category ? '#1abc9c' : '#f8f9fa',
color: selectedCategory === category ? 'white' : '#636e72',
transition: 'all 0.3s ease',
}}
>
{category}
</motion.button>
))}
</div>
{/* Список новостей */}
<div style={{
display: 'flex',
flexDirection: 'column',
gap: '20px',
}}>
{filteredNews.map((item, index) => (
<motion.div
key={item.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={{ y: -4 }}
style={{
background: 'white',
borderRadius: '16px',
padding: '25px',
boxShadow: '0 4px 20px rgba(0,0,0,0.06)',
border: '1px solid #f1f2f6',
transition: 'all 0.3s ease',
}}
>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
flexWrap: 'wrap',
gap: '10px',
marginBottom: '12px',
}}>
<h3 style={{
margin: 0,
color: '#2d3436',
fontSize: '18px',
fontWeight: 600,
}}>
{item.title}
</h3>
<span style={{
padding: '4px 12px',
background: '#f0f0f0',
borderRadius: '12px',
fontSize: '12px',
color: '#636e72',
}}>
<IconWrapper icon={FaTag} style={{ marginRight: '4px', fontSize: '10px' }} />
{item.category}
</span>
</div>
<p style={{
color: '#636e72',
fontSize: '14px',
lineHeight: 1.8,
margin: '0 0 15px 0',
}}>
{item.content}
</p>
<div style={{
display: 'flex',
gap: '20px',
fontSize: '13px',
color: '#b2bec3',
}}>
<span>
<IconWrapper icon={FaCalendarAlt} style={{ marginRight: '5px' }} />
{new Date(item.date).toLocaleDateString('ru-RU')}
</span>
<span>
<IconWrapper icon={FaUser} style={{ marginRight: '5px' }} />
{item.author}
</span>
</div>
</motion.div>
))}
</div>
{filteredNews.length === 0 && (
<div style={{
textAlign: 'center',
padding: '40px',
color: '#636e72',
}}>
Новостей в этой категории пока нет
</div>
)}
</motion.div>
);
};
export default News;
+3 -1
View File
@@ -1,7 +1,9 @@
import React from 'react'; import React from 'react';
import { Document, Page, pdfjs } from 'react-pdf'; import { Document, Page, pdfjs } from 'react-pdf';
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs'; // ИСПОЛЬЗОВАТЬ CDN - ЭТО РАБОТАЕТ
pdfjs.GlobalWorkerOptions.workerSrc =
'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
interface PayslipViewerProps { interface PayslipViewerProps {
fileUrl: string; fileUrl: string;
+642
View File
@@ -0,0 +1,642 @@
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import {
FaUserCircle, FaClock, FaBriefcase, FaAward,
FaExclamationTriangle, FaCalendarCheck, FaUtensils,
FaFileAlt, FaEdit, FaSave, FaTimes
} from 'react-icons/fa';
interface ProfileProps {
onBack: () => void;
employee: any;
}
interface Tab {
id: string;
label: string;
icon: any;
}
// Функция-обертка для рендеринга иконок
const renderIcon = (Icon: any, style?: React.CSSProperties) => {
return React.createElement(Icon, { style });
};
const Profile: React.FC<ProfileProps> = ({ onBack, employee }) => {
const [activeTab, setActiveTab] = useState('about');
const [isEditing, setIsEditing] = useState(false);
const [formData, setFormData] = useState({
fullName: employee?.fullName || 'Иванов Иван Иванович',
position: employee?.position || 'Инженер-технолог',
department: employee?.department || 'Цех №5',
phone: employee?.phone || '+7 (999) 123-45-67',
email: employee?.email || 'ivanov@company.ru',
address: employee?.address || 'г. Москва, ул. Ленина, д. 1, кв. 10',
birthDate: employee?.birthDate || '1985-06-15',
education: employee?.education || 'Высшее, МГУ им. Ломоносова, химический факультет',
});
const tabs: Tab[] = [
{ id: 'about', label: 'Обо мне', icon: FaUserCircle },
{ id: 'achievements', label: 'Достижения', icon: FaAward },
{ id: 'vacation', label: 'Отпуск', icon: FaCalendarCheck },
{ id: 'meal', label: 'Питание', icon: FaUtensils },
{ id: 'certificates', label: 'Справки', icon: FaFileAlt },
];
const achievements = [
{ type: 'Поощрение', name: 'Благодарность за успешный проект', date: '2024-01-15' },
{ type: 'Поощрение', name: 'Премия по итогам года', date: '2023-12-20' },
{ type: 'Взыскание', name: 'Замечание за опоздание', date: '2023-10-01' },
];
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleSave = () => {
setIsEditing(false);
};
const renderTabContent = () => {
switch (activeTab) {
case 'about':
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '20px', marginBottom: '25px' }}>
<div style={{
width: '80px',
height: '80px',
borderRadius: '50%',
background: '#e8f0fe',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '40px',
color: '#34495e',
}}>
{renderIcon(FaUserCircle)}
</div>
<div>
{isEditing ? (
<input
type="text"
name="fullName"
value={formData.fullName}
onChange={handleInputChange}
style={{
fontSize: '20px',
fontWeight: 600,
color: '#2d3436',
border: '1px solid #dfe6e9',
borderRadius: '4px',
padding: '5px 10px',
width: '100%',
}}
/>
) : (
<div style={{ fontSize: '20px', fontWeight: 600, color: '#2d3436' }}>
{formData.fullName}
</div>
)}
<div style={{ fontSize: '14px', color: '#636e72' }}>
{formData.position} • {formData.department}
</div>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '15px' }}>
<div>
<div style={{ fontSize: '13px', color: '#b2bec3', marginBottom: '4px' }}>Телефон</div>
{isEditing ? (
<input
type="text"
name="phone"
value={formData.phone}
onChange={handleInputChange}
style={{
width: '100%',
padding: '5px 10px',
border: '1px solid #dfe6e9',
borderRadius: '4px',
}}
/>
) : (
<div style={{ fontSize: '15px', color: '#2d3436' }}>{formData.phone}</div>
)}
</div>
<div>
<div style={{ fontSize: '13px', color: '#b2bec3', marginBottom: '4px' }}>Email</div>
{isEditing ? (
<input
type="email"
name="email"
value={formData.email}
onChange={handleInputChange}
style={{
width: '100%',
padding: '5px 10px',
border: '1px solid #dfe6e9',
borderRadius: '4px',
}}
/>
) : (
<div style={{ fontSize: '15px', color: '#2d3436' }}>{formData.email}</div>
)}
</div>
<div>
<div style={{ fontSize: '13px', color: '#b2bec3', marginBottom: '4px' }}>Адрес</div>
{isEditing ? (
<input
type="text"
name="address"
value={formData.address}
onChange={handleInputChange}
style={{
width: '100%',
padding: '5px 10px',
border: '1px solid #dfe6e9',
borderRadius: '4px',
}}
/>
) : (
<div style={{ fontSize: '15px', color: '#2d3436' }}>{formData.address}</div>
)}
</div>
<div>
<div style={{ fontSize: '13px', color: '#b2bec3', marginBottom: '4px' }}>Дата рождения</div>
{isEditing ? (
<input
type="date"
name="birthDate"
value={formData.birthDate}
onChange={handleInputChange}
style={{
width: '100%',
padding: '5px 10px',
border: '1px solid #dfe6e9',
borderRadius: '4px',
}}
/>
) : (
<div style={{ fontSize: '15px', color: '#2d3436' }}>
{new Date(formData.birthDate).toLocaleDateString('ru-RU')}
</div>
)}
</div>
<div style={{ gridColumn: '1 / -1' }}>
<div style={{ fontSize: '13px', color: '#b2bec3', marginBottom: '4px' }}>Образование</div>
{isEditing ? (
<textarea
name="education"
value={formData.education}
onChange={handleInputChange}
style={{
width: '100%',
padding: '5px 10px',
border: '1px solid #dfe6e9',
borderRadius: '4px',
minHeight: '60px',
}}
/>
) : (
<div style={{ fontSize: '15px', color: '#2d3436' }}>{formData.education}</div>
)}
</div>
</div>
<div style={{ marginTop: '25px', display: 'flex', gap: '10px' }}>
{isEditing ? (
<>
<button
onClick={handleSave}
style={{
padding: '10px 25px',
background: '#1abc9c',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
transition: 'all 0.3s ease',
}}
onMouseEnter={(e) => e.currentTarget.style.background = '#16a085'}
onMouseLeave={(e) => e.currentTarget.style.background = '#1abc9c'}
>
{renderIcon(FaSave)}
Сохранить
</button>
<button
onClick={() => setIsEditing(false)}
style={{
padding: '10px 25px',
background: '#e74c3c',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
transition: 'all 0.3s ease',
}}
onMouseEnter={(e) => e.currentTarget.style.background = '#c0392b'}
onMouseLeave={(e) => e.currentTarget.style.background = '#e74c3c'}
>
{renderIcon(FaTimes)}
Отмена
</button>
</>
) : (
<button
onClick={() => setIsEditing(true)}
style={{
padding: '10px 25px',
background: '#34495e',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
transition: 'all 0.3s ease',
}}
onMouseEnter={(e) => e.currentTarget.style.background = '#2c3e50'}
onMouseLeave={(e) => e.currentTarget.style.background = '#34495e'}
>
{renderIcon(FaEdit)}
Редактировать
</button>
)}
</div>
</div>
);
case 'achievements':
return (
<div>
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '15px',
marginBottom: '25px',
}}>
<div style={{
background: '#f8f9fa',
padding: '15px',
borderRadius: '12px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '8px' }}>
{renderIcon(FaClock, { color: '#3498db' })}
<div style={{ fontSize: '13px', color: '#636e72' }}>Общий стаж</div>
</div>
<div style={{ fontSize: '20px', fontWeight: 600, color: '#2d3436' }}>
{employee?.totalExperience || 15} лет
</div>
</div>
<div style={{
background: '#f8f9fa',
padding: '15px',
borderRadius: '12px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '8px' }}>
{renderIcon(FaBriefcase, { color: '#2ecc71' })}
<div style={{ fontSize: '13px', color: '#636e72' }}>Стаж на предприятии</div>
</div>
<div style={{ fontSize: '20px', fontWeight: 600, color: '#2d3436' }}>
{employee?.companyExperience || 8} лет
</div>
</div>
</div>
<div>
<div style={{ fontSize: '16px', fontWeight: 500, color: '#2d3436', marginBottom: '15px' }}>
Достижения и взыскания
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{achievements.map((item, index) => (
<div
key={index}
style={{
padding: '12px 15px',
background: '#f8f9fa',
borderRadius: '8px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
{item.type === 'Поощрение' ?
renderIcon(FaAward, { color: '#f39c12' }) :
renderIcon(FaExclamationTriangle, { color: '#e74c3c' })
}
<span style={{ fontSize: '14px', color: '#2d3436' }}>{item.name}</span>
</div>
<span style={{ fontSize: '12px', color: '#b2bec3' }}>
{new Date(item.date).toLocaleDateString('ru-RU')}
</span>
</div>
))}
</div>
</div>
</div>
);
case 'vacation':
return (
<div>
<h3 style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
color: '#2d3436',
fontSize: '18px',
fontWeight: 500,
marginBottom: '20px',
}}>
{renderIcon(FaCalendarCheck, { color: '#6c5ce7' })}
Информация об отпуске
</h3>
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '15px',
}}>
<div style={{
background: '#f8f9fa',
padding: '20px',
borderRadius: '12px',
textAlign: 'center',
}}>
<div style={{ fontSize: '13px', color: '#636e72', marginBottom: '8px' }}>
Всего дней отпуска
</div>
<div style={{ fontSize: '28px', fontWeight: 600, color: '#2d3436' }}>
{employee?.vacationDays || 28}
</div>
</div>
<div style={{
background: '#f8f9fa',
padding: '20px',
borderRadius: '12px',
textAlign: 'center',
}}>
<div style={{ fontSize: '13px', color: '#636e72', marginBottom: '8px' }}>
Использовано дней
</div>
<div style={{ fontSize: '28px', fontWeight: 600, color: '#2d3436' }}>
{employee?.usedVacationDays || 14}
</div>
</div>
<div style={{
background: '#f8f9fa',
padding: '20px',
borderRadius: '12px',
textAlign: 'center',
gridColumn: '1 / -1',
}}>
<div style={{ fontSize: '13px', color: '#636e72', marginBottom: '8px' }}>
Осталось дней
</div>
<div style={{ fontSize: '28px', fontWeight: 600, color: '#1abc9c' }}>
{(employee?.vacationDays || 28) - (employee?.usedVacationDays || 14)}
</div>
</div>
</div>
</div>
);
case 'meal':
return (
<div>
<h3 style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
color: '#2d3436',
fontSize: '18px',
fontWeight: 500,
marginBottom: '20px',
}}>
{renderIcon(FaUtensils, { color: '#f39c12' })}
Питание по пропуску
</h3>
<div style={{
background: '#f8f9fa',
padding: '20px',
borderRadius: '12px',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '15px' }}>
<span style={{ color: '#636e72' }}>Статус</span>
<span style={{ color: '#2ecc71', fontWeight: 500 }}>
{employee?.mealPlan?.hasMealPass ? 'Активен' : 'Неактивен'}
</span>
</div>
{employee?.mealPlan?.hasMealPass && (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '15px' }}>
<span style={{ color: '#636e72' }}>Номер пропуска</span>
<span style={{ color: '#2d3436', fontWeight: 500 }}>
{employee.mealPlan.passNumber || 'MP-2024-005'}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: '#636e72' }}>Осталось обедов</span>
<span style={{ color: '#2d3436', fontWeight: 500 }}>
{employee.mealPlan.remainingMeals || 15}
</span>
</div>
</>
)}
</div>
</div>
);
case 'certificates':
return (
<div>
<h3 style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
color: '#2d3436',
fontSize: '18px',
fontWeight: 500,
marginBottom: '20px',
}}>
{renderIcon(FaFileAlt, { color: '#6c5ce7' })}
Заказ справок
</h3>
<div style={{
background: '#f8f9fa',
padding: '20px',
borderRadius: '12px',
textAlign: 'center',
}}>
<p style={{ color: '#636e72', marginBottom: '20px' }}>
Вы можете заказать следующие справки:
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<button style={{
padding: '12px',
background: 'white',
border: '1px solid #dfe6e9',
borderRadius: '8px',
cursor: 'pointer',
transition: 'all 0.3s ease',
color: '#2d3436',
fontSize: '14px',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f0f0f0';
e.currentTarget.style.borderColor = '#b2bec3';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'white';
e.currentTarget.style.borderColor = '#dfe6e9';
}}
>
Справка о доходах
</button>
<button style={{
padding: '12px',
background: 'white',
border: '1px solid #dfe6e9',
borderRadius: '8px',
cursor: 'pointer',
transition: 'all 0.3s ease',
color: '#2d3436',
fontSize: '14px',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f0f0f0';
e.currentTarget.style.borderColor = '#b2bec3';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'white';
e.currentTarget.style.borderColor = '#dfe6e9';
}}
>
Справка о трудовом стаже
</button>
<button style={{
padding: '12px',
background: 'white',
border: '1px solid #dfe6e9',
borderRadius: '8px',
cursor: 'pointer',
transition: 'all 0.3s ease',
color: '#2d3436',
fontSize: '14px',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f0f0f0';
e.currentTarget.style.borderColor = '#b2bec3';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'white';
e.currentTarget.style.borderColor = '#dfe6e9';
}}
>
Справка с места работы
</button>
</div>
</div>
</div>
);
default:
return null;
}
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
style={{
padding: '20px',
maxWidth: '1200px',
margin: '0 auto',
paddingBottom: '80px',
}}
>
<div style={{ marginBottom: '25px' }}>
<button
onClick={onBack}
style={{
padding: '10px 20px',
background: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '14px',
transition: 'all 0.3s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#5a6268';
e.currentTarget.style.transform = 'translateX(-2px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#6c757d';
e.currentTarget.style.transform = 'translateX(0)';
}}
>
← Назад
</button>
</div>
<div style={{
background: 'white',
borderRadius: '16px',
boxShadow: '0 4px 20px rgba(0,0,0,0.06)',
overflow: 'hidden',
}}>
<div style={{
display: 'flex',
borderBottom: '1px solid #f1f2f6',
overflowX: 'auto',
}}>
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
style={{
padding: '15px 20px',
background: 'transparent',
border: 'none',
borderBottom: activeTab === tab.id ? '3px solid #1abc9c' : '3px solid transparent',
cursor: 'pointer',
color: activeTab === tab.id ? '#1abc9c' : '#636e72',
fontSize: '14px',
fontWeight: activeTab === tab.id ? 600 : 400,
transition: 'all 0.3s ease',
display: 'flex',
alignItems: 'center',
gap: '8px',
whiteSpace: 'nowrap',
}}
>
{renderIcon(tab.icon)}
{tab.label}
</button>
))}
</div>
<div style={{ padding: '25px' }}>
{renderTabContent()}
</div>
</div>
</motion.div>
);
};
export default Profile;
+162
View File
@@ -0,0 +1,162 @@
import React from 'react';
import { motion } from 'framer-motion';
import { FaHandsHelping, FaCalendarCheck, FaMoneyBillWave, FaHome } from 'react-icons/fa';
interface TradeUnionProps {
onBack: () => void;
employee: any;
}
// Функция-обертка для рендеринга иконок
const renderIcon = (Icon: any, style?: React.CSSProperties) => {
return React.createElement(Icon, { style });
};
const TradeUnion: React.FC<TradeUnionProps> = ({ onBack, employee }) => {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
className="trade-union-section"
style={{
padding: '20px',
maxWidth: '1200px',
margin: '0 auto',
paddingBottom: '80px',
}}
>
<div className="top-bar" style={{ marginBottom: '25px' }}>
<button
className="back-button"
onClick={onBack}
style={{
padding: '10px 20px',
background: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '14px',
transition: 'all 0.3s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#5a6268';
e.currentTarget.style.transform = 'translateX(-2px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#6c757d';
e.currentTarget.style.transform = 'translateX(0)';
}}
>
← Назад
</button>
</div>
<h2 style={{
color: '#2d3436',
fontSize: '24px',
fontWeight: 300,
marginBottom: '30px',
display: 'flex',
alignItems: 'center',
gap: '12px',
}}>
{renderIcon(FaHandsHelping, { color: '#e74c3c', fontSize: '24px' })}
Профсоюз
</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
gap: '20px',
}}>
<motion.div
whileHover={{ y: -5 }}
style={{
background: 'white',
borderRadius: '16px',
padding: '25px',
boxShadow: '0 4px 20px rgba(0,0,0,0.06)',
cursor: 'pointer',
transition: 'all 0.3s ease',
border: '1px solid #f1f2f6',
}}
>
<div style={{ fontSize: '32px', color: '#e74c3c', marginBottom: '15px' }}>
{renderIcon(FaCalendarCheck)}
</div>
<h3 style={{ margin: '0 0 10px 0', color: '#2d3436', fontSize: '18px' }}>
Мероприятия
</h3>
<p style={{ color: '#636e72', fontSize: '14px', lineHeight: 1.6, margin: 0 }}>
Анонсы и запись на мероприятия
</p>
</motion.div>
<motion.div
whileHover={{ y: -5 }}
style={{
background: 'white',
borderRadius: '16px',
padding: '25px',
boxShadow: '0 4px 20px rgba(0,0,0,0.06)',
cursor: 'pointer',
transition: 'all 0.3s ease',
border: '1px solid #f1f2f6',
}}
>
<div style={{ fontSize: '32px', color: '#f39c12', marginBottom: '15px' }}>
{renderIcon(FaMoneyBillWave)}
</div>
<h3 style={{ margin: '0 0 10px 0', color: '#2d3436', fontSize: '18px' }}>
Материальная помощь
</h3>
<p style={{ color: '#636e72', fontSize: '14px', lineHeight: 1.6, margin: 0 }}>
Размеры и виды материальной помощи
</p>
</motion.div>
<motion.div
whileHover={{ y: -5 }}
style={{
background: 'white',
borderRadius: '16px',
padding: '25px',
boxShadow: '0 4px 20px rgba(0,0,0,0.06)',
cursor: 'pointer',
transition: 'all 0.3s ease',
border: '1px solid #f1f2f6',
}}
>
<div style={{ fontSize: '32px', color: '#3498db', marginBottom: '15px' }}>
{renderIcon(FaHome)}
</div>
<h3 style={{ margin: '0 0 10px 0', color: '#2d3436', fontSize: '18px' }}>
Жилищные условия
</h3>
<p style={{ color: '#636e72', fontSize: '14px', lineHeight: 1.6, margin: 0 }}>
Информация об очереди на улучшение жилищных условий
</p>
</motion.div>
</div>
<div style={{
marginTop: '30px',
padding: '20px',
background: '#f8f9fa',
borderRadius: '12px',
border: '1px solid #f1f2f6',
textAlign: 'center',
color: '#636e72',
}}>
<p style={{ margin: 0 }}>
ℹ️ Раздел в разработке. Функционал будет доступен в ближайшее время.
</p>
</div>
</motion.div>
);
};
export default TradeUnion;
@@ -0,0 +1,91 @@
import React, { useEffect, useRef } from 'react';
import { motion } from 'framer-motion';
interface VideoBackgroundProps {
onComplete: () => void;
videoSrc?: string;
}
const VideoBackground: React.FC<VideoBackgroundProps> = ({
onComplete,
videoSrc = '/assets/videos/flag.mp4'
}) => {
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
if (videoRef.current) {
videoRef.current.play().catch(err => {
console.warn('Видео не воспроизвелось автоматически:', err);
// Если видео не запустилось — показываем экран логина через 5 секунд
setTimeout(onComplete, 5000);
});
}
// ❌ Убираем таймер, который закрывал видео через 30 секунд
// Теперь видео будет крутиться бесконечно (loop=true)
// Только клик или касание пользователя завершают видео
return () => {
// Очистка не нужна, так как таймера больше нет
};
}, [onComplete]);
// ❌ Убираем handleVideoEnd — не нужен, так как loop=true
const handleClick = () => {
onComplete(); // По клику переходим дальше
};
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
className="video-splash"
onClick={handleClick}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 9999,
background: '#000',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<video
ref={videoRef}
src={videoSrc}
autoPlay
muted
playsInline
loop // ✅ Бесконечное воспроизведение
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
<div style={{
position: 'absolute',
bottom: '60px',
left: 0,
right: 0,
textAlign: 'center',
color: '#fff',
fontSize: '14px',
opacity: 0.6,
textShadow: '0 2px 4px rgba(0,0,0,0.5)',
}}>
Нажмите для продолжения
</div>
</motion.div>
);
};
export default VideoBackground;
+159
View File
@@ -0,0 +1,159 @@
import { useEffect, useRef, useCallback, useState } from 'react';
interface UseActivityTimerProps {
timeout: number; // Время до предупреждения (10 сек)
warningTimeout: number; // Время до закрытия после предупреждения (5 сек)
onInactive: () => void; // Колбэк при закрытии
onWarning?: () => void; // Колбэк при показе предупреждения
}
export const useActivityTimer = ({
timeout,
warningTimeout,
onInactive,
onWarning
}: UseActivityTimerProps) => {
// 📊 Состояния
const [isWarning, setIsWarning] = useState(false);
const [timeLeft, setTimeLeft] = useState(timeout);
// 📦 Рефы для таймеров
const mainTimerRef = useRef<NodeJS.Timeout | null>(null);
const warningTimerRef = useRef<NodeJS.Timeout | null>(null);
const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);
const lastActivityRef = useRef<number>(Date.now());
const onInactiveRef = useRef(onInactive);
const onWarningRef = useRef(onWarning);
// Обновляем ссылки на колбэки
useEffect(() => {
onInactiveRef.current = onInactive;
}, [onInactive]);
useEffect(() => {
onWarningRef.current = onWarning;
}, [onWarning]);
// 🧹 Очистка всех таймеров
const clearAllTimers = useCallback(() => {
if (mainTimerRef.current) {
clearTimeout(mainTimerRef.current);
mainTimerRef.current = null;
}
if (warningTimerRef.current) {
clearTimeout(warningTimerRef.current);
warningTimerRef.current = null;
}
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current);
countdownIntervalRef.current = null;
}
}, []);
// 🔄 Сброс таймера (при активности)
const resetTimer = useCallback(() => {
// Обновляем время последней активности
lastActivityRef.current = Date.now();
// Сбрасываем состояние предупреждения
setIsWarning(false);
setTimeLeft(timeout);
// Очищаем все таймеры
clearAllTimers();
console.log(`[ACTIVITY TIMER] 🔄 Таймер сброшен (таймаут: ${timeout}ms)`);
// ✅ Основной таймер (через timeout мс показываем предупреждение)
mainTimerRef.current = setTimeout(() => {
console.log(`[ACTIVITY TIMER] ⚠️ Показываем предупреждение!`);
// Показываем предупреждение
setIsWarning(true);
setTimeLeft(warningTimeout);
// Вызываем колбэк предупреждения
if (onWarningRef.current) {
onWarningRef.current();
}
// 🔄 Таймер обратного отсчета (обновляем каждую секунду)
countdownIntervalRef.current = setInterval(() => {
setTimeLeft(prev => {
const newTime = prev - 1000;
console.log(`[ACTIVITY TIMER] ⏰ Осталось: ${newTime/1000} сек`);
return Math.max(0, newTime);
});
}, 1000);
// ✅ Таймер закрытия (через warningTimeout мс)
warningTimerRef.current = setTimeout(() => {
console.log(`[ACTIVITY TIMER] 🛑 Время вышло! Вызываем onInactive`);
// Очищаем интервал
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current);
countdownIntervalRef.current = null;
}
// Вызываем колбэк
onInactiveRef.current();
}, warningTimeout);
}, timeout);
}, [timeout, warningTimeout, clearAllTimers]);
// 🖱️ Обработчик активности
useEffect(() => {
// События, которые считаются активностью
const events = [
'mousedown',
'mousemove',
'keydown',
'scroll',
'touchstart',
'click',
'wheel',
'touchmove',
'focus'
];
const handleActivity = () => {
// Если было предупреждение — сбрасываем
if (isWarning) {
console.log('[ACTIVITY TIMER] 👆 Активность! Сбрасываем предупреждение');
}
resetTimer();
};
// Добавляем обработчики
events.forEach(event => {
document.addEventListener(event, handleActivity, { passive: true });
});
// Запускаем таймер
resetTimer();
console.log(`[ACTIVITY TIMER] 🚀 Инициализирован (таймаут: ${timeout}ms, предупреждение: ${warningTimeout}ms)`);
// Очистка
return () => {
events.forEach(event => {
document.removeEventListener(event, handleActivity);
});
clearAllTimers();
console.log('[ACTIVITY TIMER] 🧹 Очищен');
};
}, [resetTimer, clearAllTimers]);
// ✅ Возвращаем состояние для отображения в UI
return {
isWarning, // ⚠️ Показывать ли предупреждение
timeLeft, // ⏰ Оставшееся время в мс
resetTimer // 🔄 Ручной сброс
};
};
export default useActivityTimer;
+236
View File
@@ -0,0 +1,236 @@
/* ============================================
АНИМАЦИИ
============================================ */
/* Плавное появление */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in {
animation: fadeIn 0.5s ease-out forwards;
}
/* Плавное появление с задержкой */
.fade-in-delay {
animation: fadeIn 0.5s ease-out forwards;
animation-delay: 0.3s;
opacity: 0;
}
/* Пульсация */
@keyframes pulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
.pulse {
animation: pulse 2s ease-in-out infinite;
}
/* Вращение (для спиннера) */
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.spin {
animation: spin 1s linear infinite;
}
/* Скелетон загрузки */
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
/* Скольжение снизу */
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.slide-up {
animation: slideUp 0.6s ease-out forwards;
}
/* Скольжение сверху */
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.slide-down {
animation: slideDown 0.6s ease-out forwards;
}
/* Масштабирование */
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
.scale-in {
animation: scaleIn 0.5s ease-out forwards;
}
/* Бегущая строка */
@keyframes ticker {
0% {
transform: translateX(100%);
}
100% {
transform: translateX(-100%);
}
}
.ticker {
animation: ticker 20s linear infinite;
}
.ticker:hover {
animation-play-state: paused;
}
/* Затемнение фона */
@keyframes overlayIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.overlay-in {
animation: overlayIn 0.3s ease-out forwards;
}
/* Всплывающая подсказка */
@keyframes tooltipIn {
from {
opacity: 0;
transform: translateY(10px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.tooltip-in {
animation: tooltipIn 0.2s ease-out forwards;
}
/* Затухание */
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.fade-out {
animation: fadeOut 0.3s ease-out forwards;
}
/* Пульсирующее предупреждение */
@keyframes pulseWarning {
0% {
transform: scale(1);
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
50% {
transform: scale(1.03);
box-shadow: 0 20px 80px rgba(255, 193, 7, 0.5);
}
100% {
transform: scale(1);
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
}
/* Мигающий текст обратного отсчета */
@keyframes blinkText {
0% { opacity: 1; }
50% { opacity: 0.3; }
100% { opacity: 1; }
}
/* Карта */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Для плавного появления/исчезновения контролов */
@keyframes fadeSlideDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeSlideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
+393 -42
View File
@@ -1,4 +1,10 @@
/* Общие стили */ /* Общие стили */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body { body {
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -8,9 +14,12 @@ body {
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
background-color: #f5f5f5; background-color: #f5f5f5;
overflow: hidden;
} }
/* Окно логина */ /* ============================================
ОКНО ЛОГИНА
============================================ */
.login-window { .login-window {
max-width: 400px; max-width: 400px;
margin: 12% auto; margin: 12% auto;
@@ -18,7 +27,6 @@ body {
background: white; background: white;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
} }
.login-window h2 { .login-window h2 {
@@ -83,7 +91,199 @@ button:disabled {
cursor: not-allowed; cursor: not-allowed;
} }
/* Информация о сотруднике */ /* ============================================
ГЛАВНОЕ МЕНЮ
============================================ */
.main-menu {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.main-menu h1 {
color: #fff;
font-size: 2.5rem;
margin-bottom: 40px;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
font-weight: 300;
}
.menu-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 200px));
gap: 25px;
max-width: 900px;
width: 100%;
justify-content: center;
}
.menu-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 25px 20px;
background: rgba(255, 255, 255, 0.95);
border: none;
border-radius: 16px;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
min-height: 140px;
}
.menu-item:hover {
transform: translateY(-5px) scale(1.05);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
}
.menu-item:active {
transform: scale(0.95);
}
.menu-item .icon {
font-size: 2.5rem;
}
.menu-item .title {
font-size: 1rem;
font-weight: 500;
color: #2d3436;
text-align: center;
}
/* ============================================
ВИДЕО-ЗАСТАВКА
============================================ */
.video-splash {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
background: #000;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.video-splash video {
width: 100%;
height: 100%;
object-fit: cover;
}
.video-splash .hint {
position: absolute;
bottom: 60px;
left: 0;
right: 0;
text-align: center;
color: #fff;
font-size: 14px;
opacity: 0.6;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
}
/* ============================================
НИЖНЯЯ ПАНЕЛЬ (КУРСЫ ВАЛЮТ)
============================================ */
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: rgba(44, 62, 80, 0.95);
backdrop-filter: blur(10px);
color: #fff;
padding: 8px 20px;
display: flex;
align-items: center;
gap: 20px;
overflow: hidden;
z-index: 1000;
height: 40px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
font-size: 13px;
font-family: monospace;
}
.bottom-bar .status {
display: flex;
align-items: center;
gap: 15px;
flex-shrink: 0;
}
.bottom-bar .status .online {
color: #2ecc71;
font-size: 12px;
}
.bottom-bar .status .offline {
color: #e74c3c;
font-size: 12px;
}
.bottom-bar .status .label {
font-weight: 600;
opacity: 0.7;
}
.bottom-bar .rates {
display: flex;
gap: 25px;
overflow: hidden;
flex: 1;
}
.bottom-bar .rates .item {
display: flex;
gap: 8px;
align-items: center;
white-space: nowrap;
}
.bottom-bar .rates .item .currency {
font-weight: 600;
}
.bottom-bar .rates .item .change-up {
color: #2ecc71;
font-size: 11px;
font-weight: 500;
}
.bottom-bar .rates .item .change-down {
color: #e74c3c;
font-size: 11px;
font-weight: 500;
}
.bottom-bar .rates .divider {
color: rgba(255, 255, 255, 0.3);
font-size: 18px;
font-weight: 100;
}
.bottom-bar .time {
margin-left: auto;
font-size: 11px;
opacity: 0.4;
white-space: nowrap;
flex-shrink: 0;
}
/* ============================================
ИНФОРМАЦИЯ О СОТРУДНИКЕ
============================================ */
.employee-info { .employee-info {
max-width: 800px; max-width: 800px;
margin: 20px auto; margin: 20px auto;
@@ -128,7 +328,9 @@ button:disabled {
color: #666; color: #666;
} }
/* Секция расчётных листов */ /* ============================================
РАСЧЁТНЫЙ ЛИСТ
============================================ */
.payslip-section { .payslip-section {
margin-top: 30px; margin-top: 30px;
padding: 20px; padding: 20px;
@@ -170,33 +372,16 @@ button:disabled {
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
} }
.action-buttons { /* ============================================
display: flex; ВЕРХНЯЯ ПАНЕЛЬ (КНОПКА НАЗАД)
gap: 10px; ============================================ */
margin-top: 20px;
}
.action-buttons button {
background-color: #28a745;
color: white;
flex: 1;
}
.action-buttons button:hover {
background-color: #218838;
}
.action-buttons button:first-child {
background-color: #007bff;
}
.action-buttons button:first-child:hover {
background-color: #0056b3;
}
/* Верхняя панель с кнопкой назад */
.top-bar { .top-bar {
margin-bottom: 20px; margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
} }
.back-button { .back-button {
@@ -210,20 +395,19 @@ button:disabled {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 5px; gap: 5px;
transition: background-color 0.2s; transition: all 0.2s ease;
}
.back-button:hover {
background-color: #545b62;
}
/* Делаем кнопку назад компактнее */
.back-button {
flex: 0 0 auto; flex: 0 0 auto;
width: auto; width: auto;
} }
/* Гостевой режим */ .back-button:hover {
background-color: #545b62;
transform: translateX(-2px);
}
/* ============================================
ГОСТЕВОЙ РЕЖИМ
============================================ */
.guest-mode { .guest-mode {
max-width: 1200px; max-width: 1200px;
margin: 20px auto; margin: 20px auto;
@@ -287,7 +471,6 @@ button:disabled {
line-height: 1.8; line-height: 1.8;
} }
.loading { .loading {
text-align: center; text-align: center;
color: #666; color: #666;
@@ -308,7 +491,9 @@ button:disabled {
color: #555; color: #555;
} }
/* --- Handbook Content Styles --- */ /* ============================================
HANDBOOK СТИЛИ
============================================ */
.handbook-content { .handbook-content {
max-width: 900px; max-width: 900px;
margin: 0 auto; margin: 0 auto;
@@ -317,37 +502,42 @@ button:disabled {
border-radius: 8px; border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
} }
.handbook-content h1 { .handbook-content h1 {
text-align: center; text-align: center;
font-size: 2rem; font-size: 2rem;
margin: 24px 0 16px 0; margin: 24px 0 16px 0;
color: #1a237e; color: #1a237e;
} }
.handbook-content h2 { .handbook-content h2 {
text-align: center; text-align: center;
font-size: 1.5rem; font-size: 1.5rem;
margin: 20px 0 12px 0; margin: 20px 0 12px 0;
color: #283593; color: #283593;
} }
.handbook-content p { .handbook-content p {
margin: 10px 0; margin: 10px 0;
font-size: 1.08rem; font-size: 1.08rem;
line-height: 1.6; line-height: 1.6;
} }
.handbook-content ul { .handbook-content ul {
margin: 12px 0 12px 32px; margin: 12px 0 12px 32px;
padding-left: 18px; padding-left: 18px;
} }
.handbook-content li { .handbook-content li {
margin: 6px 0; margin: 6px 0;
font-size: 1.05rem; font-size: 1.05rem;
line-height: 1.5; line-height: 1.5;
} }
.handbook-content strong { .handbook-content strong {
font-weight: bold; font-weight: bold;
color: #000000; color: #000000;
text-align: center; text-align: center;
} }
.handbook-content h3 { .handbook-content h3 {
@@ -355,6 +545,7 @@ button:disabled {
color: #000000; color: #000000;
text-align: center; text-align: center;
} }
.handbook-content em { .handbook-content em {
font-style: italic; font-style: italic;
color: #555; color: #555;
@@ -373,11 +564,13 @@ button:disabled {
border-collapse: collapse; border-collapse: collapse;
margin-bottom: 24px; margin-bottom: 24px;
} }
.handbook-table th, .handbook-table th,
.handbook-table td { .handbook-table td {
border: 1px solid #ccc; border: 1px solid #ccc;
padding: 8px 12px; padding: 8px 12px;
} }
.handbook-table th { .handbook-table th {
background: #f5f5f5; background: #f5f5f5;
} }
@@ -399,3 +592,161 @@ button:disabled {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
} }
/* ============================================
РАЗДЕЛЫ (Finance, Medicine, TradeUnion и т.д.)
============================================ */
.section-container {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
padding-bottom: 80px;
}
.section-title {
color: #2d3436;
font-size: 24px;
font-weight: 300;
margin-bottom: 30px;
display: flex;
align-items: center;
gap: 12px;
}
.section-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
}
.section-card {
background: white;
border-radius: 16px;
padding: 25px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
cursor: pointer;
transition: all 0.3s ease;
border: 1px solid #f1f2f6;
}
.section-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
}
.section-card .card-icon {
font-size: 32px;
margin-bottom: 15px;
}
.section-card .card-title {
margin: 0 0 10px 0;
color: #2d3436;
font-size: 18px;
}
.section-card .card-description {
color: #636e72;
font-size: 14px;
line-height: 1.6;
margin: 0;
}
/* ============================================
ИНФО-БЛОК
============================================ */
.info-block {
margin-top: 30px;
padding: 20px;
background: #f8f9fa;
border-radius: 12px;
border: 1px solid #f1f2f6;
text-align: center;
color: #636e72;
}
.info-block p {
margin: 0;
}
/* ============================================
АДАПТИВНОСТЬ
============================================ */
@media (max-width: 768px) {
.login-window {
margin: 20% 15px;
}
.employee-header {
flex-direction: column;
align-items: center;
}
.employee-photo {
width: 120px;
height: 120px;
}
.main-menu h1 {
font-size: 1.8rem;
}
.menu-grid {
grid-template-columns: repeat(auto-fit, minmax(120px, 150px));
gap: 15px;
}
.section-grid {
grid-template-columns: 1fr;
}
.tab-navigation {
flex-wrap: wrap;
}
.tab-button {
padding: 8px 16px;
font-size: 14px;
}
.bottom-bar {
font-size: 11px;
padding: 6px 12px;
height: 34px;
gap: 10px;
flex-wrap: nowrap;
overflow-x: auto;
}
.bottom-bar .rates {
gap: 15px;
}
}
@media (max-width: 480px) {
.login-window {
padding: 15px;
}
.button-group {
flex-direction: column;
}
.menu-grid {
grid-template-columns: repeat(auto-fit, minmax(100px, 120px));
gap: 10px;
}
.menu-item {
padding: 15px 10px;
min-height: 100px;
}
.menu-item .icon {
font-size: 1.8rem;
}
.menu-item .title {
font-size: 0.85rem;
}
}