add compomemts&front
This commit is contained in:
@@ -1638,3 +1638,23 @@
|
||||
[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.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
|
||||
|
||||
Generated
-1
@@ -7,7 +7,6 @@
|
||||
"": {
|
||||
"name": "admin-server",
|
||||
"version": "1.0.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"axios": "^1.10.0",
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
"start": "node server.js",
|
||||
"electron": "electron .",
|
||||
"electron-dev": "set NODE_ENV=development&& electron .",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"payslipsPath": "\\\\PC-20010\\payslips",
|
||||
"payslipsPath": "\\\\PC-20010\\payslips2",
|
||||
"server": {
|
||||
"host": "localhost",
|
||||
"port": "3001"
|
||||
|
||||
Generated
+588
-700
File diff suppressed because it is too large
Load Diff
+16
-12
@@ -9,13 +9,17 @@
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"axios": "^1.6.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"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-dom": "^19.2.0",
|
||||
"react-pdf": "^10.0.0",
|
||||
"serialport": "^13.0.0",
|
||||
"react-icons": "^4.11.0",
|
||||
"react-pdf": "^8.0.0",
|
||||
"react-zoom-pan-pinch": "^3.3.0",
|
||||
"typescript": "^5.8.0",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
@@ -40,7 +44,7 @@
|
||||
},
|
||||
"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",
|
||||
"filter": [
|
||||
"**/*"
|
||||
@@ -69,13 +73,13 @@
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^26.2.0",
|
||||
"@vitejs/plugin-react":"^6.0.5",
|
||||
"concurrently":"^7.5.0",
|
||||
"electron":"^37.0.0",
|
||||
"electron-builder":"^26.15.3",
|
||||
"electron-rebuild":"^3.2.9",
|
||||
"vite":"^8.2.1",
|
||||
"vitest":"^2.1.8",
|
||||
"wait-on":"^6.0.1"
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"concurrently": "^7.5.0",
|
||||
"electron": "^37.0.0",
|
||||
"electron-builder": "^26.15.3",
|
||||
"electron-rebuild": "^3.2.9",
|
||||
"vite": "^8.2.1",
|
||||
"vitest": "^2.1.8",
|
||||
"wait-on": "^6.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.3 MiB |
Binary file not shown.
@@ -5,7 +5,6 @@ const isDev = require('electron-is-dev');
|
||||
const fs = require('fs');
|
||||
const COM_PORT = process.env.COM_PORT;
|
||||
const COM_SPEED = process.env.COM_SPEED;
|
||||
const { SerialPort } = require('serialport');
|
||||
const axios = require('axios');
|
||||
const { logger } = require('../../backend/src/config/logger');
|
||||
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) => {
|
||||
console.log('[IPC] Renderer requested app config. Sending:', appConfig);
|
||||
|
||||
+276
-24
@@ -1,30 +1,111 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import './styles/main.css';
|
||||
import './styles/animations.css';
|
||||
import LoginWindow from './components/LoginWindow';
|
||||
import EmployeeInfo from './components/EmployeeInfo';
|
||||
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 EmployeeInfo from './components/EmployeeInfo';
|
||||
import { useActivityTimer } from './components/useActivityTimer'
|
||||
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 {
|
||||
id: string;
|
||||
tnom: string;
|
||||
famaly: string;
|
||||
ima: string;
|
||||
otch: string;
|
||||
category: string;
|
||||
category?: string;
|
||||
organization: string;
|
||||
identNumber: string;
|
||||
identNumber?: string;
|
||||
departmentName?: string;
|
||||
profession?: string;
|
||||
cardNumber?: string;
|
||||
}
|
||||
|
||||
function App() {
|
||||
// 📊 Состояния приложения
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
const [isGuestMode, setIsGuestMode] = useState(false);
|
||||
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 [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) => {
|
||||
try {
|
||||
const isCardUid = /^\d{7}$/.test(id);
|
||||
@@ -37,6 +118,7 @@ function App() {
|
||||
setEmployee(data);
|
||||
setIsLoggedIn(true);
|
||||
setLoginError('');
|
||||
handleUserActivity();
|
||||
} else {
|
||||
setLoginError('Сотрудник не найден');
|
||||
}
|
||||
@@ -46,54 +128,224 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
// 👤 Режим гостя
|
||||
const handleGuestMode = () => {
|
||||
setIsGuestMode(true);
|
||||
handleUserActivity();
|
||||
};
|
||||
|
||||
// ↩️ Выход из системы
|
||||
const handleBack = () => {
|
||||
setIsLoggedIn(false);
|
||||
setEmployee(null);
|
||||
setIsGuestMode(false);
|
||||
resetToInitialState();
|
||||
};
|
||||
|
||||
// ↩️ Возврат в главное меню
|
||||
const handleBackToMenu = () => {
|
||||
setCurrentSection(null);
|
||||
setShowPayslip(false);
|
||||
setSelectedPeriod('');
|
||||
handleUserActivity();
|
||||
};
|
||||
|
||||
// 📂 Выбор раздела
|
||||
const handleSectionSelect = (section: string) => {
|
||||
setCurrentSection(section);
|
||||
handleUserActivity();
|
||||
};
|
||||
|
||||
// 📄 Просмотр расчетного листка
|
||||
const handleViewPayslip = (period: string) => {
|
||||
setSelectedPeriod(period);
|
||||
setShowPayslip(true);
|
||||
handleUserActivity();
|
||||
};
|
||||
|
||||
// ↩️ Назад из расчетного листка
|
||||
const handlePayslipBack = () => {
|
||||
setShowPayslip(false);
|
||||
setSelectedPeriod('');
|
||||
handleUserActivity();
|
||||
};
|
||||
|
||||
// 🔗 URL для расчетного листка
|
||||
const getPayslipUrl = (employee: Employee, period: string) => {
|
||||
const fileName = `${employee.tnom}.pdf`;
|
||||
|
||||
return `${config.backendUrl}/api/payslip/${period}/${fileName}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
{!isLoggedIn && !isGuestMode ? (
|
||||
<LoginWindow onLogin={handleLogin} onGuestMode={handleGuestMode} loginError={loginError} />
|
||||
) : isGuestMode ? (
|
||||
<GuestMode onBack={handleBack} />
|
||||
) : showPayslip ? (
|
||||
// ============================================
|
||||
// ⚠️ Предупреждение о неактивности
|
||||
// ============================================
|
||||
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 (
|
||||
<PayslipViewer
|
||||
fileUrl={employee && selectedPeriod ? getPayslipUrl(employee, selectedPeriod) : "/payslips/test.pdf"}
|
||||
onBack={handlePayslipBack}
|
||||
/>
|
||||
) : employee ? (
|
||||
<EmployeeInfo
|
||||
employee={employee}
|
||||
onViewPayslip={handleViewPayslip}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
) : null}
|
||||
);
|
||||
}
|
||||
|
||||
// 📂 Разделы приложения
|
||||
if (currentSection) {
|
||||
switch (currentSection) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import config from "../../src/config.json";
|
||||
import { motion } from 'framer-motion';
|
||||
import config from '../config.json';
|
||||
|
||||
interface GuestModeProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
@@ -176,39 +178,136 @@ const GuestMode: React.FC<GuestModeProps> = ({ onBack }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="guest-mode">
|
||||
<div className="top-bar">
|
||||
<button className="back-button" onClick={onBack}>
|
||||
<motion.div
|
||||
className="guest-mode"
|
||||
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>
|
||||
</div>
|
||||
<div className="guest-content">
|
||||
<h2>Гостевой режим</h2>
|
||||
<div className="tab-navigation">
|
||||
<h2 style={{
|
||||
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
|
||||
className={`tab-button ${activeTab === 'handbook' ? 'active' : ''}`}
|
||||
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
|
||||
className={`tab-button ${activeTab === 'union' ? 'active' : ''}`}
|
||||
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
|
||||
className={`tab-button ${activeTab === 'info' ? 'active' : ''}`}
|
||||
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>
|
||||
</div>
|
||||
<div className="tab-content">
|
||||
<div className="tab-content" style={{ minHeight: '400px' }}>
|
||||
{activeTab === 'handbook' && (
|
||||
<div className="handbook-content">
|
||||
{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} />
|
||||
)}
|
||||
@@ -217,7 +316,22 @@ const GuestMode: React.FC<GuestModeProps> = ({ onBack }) => {
|
||||
{activeTab === 'union' && (
|
||||
<div className="union-content">
|
||||
{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>
|
||||
)}
|
||||
@@ -226,7 +340,22 @@ const GuestMode: React.FC<GuestModeProps> = ({ onBack }) => {
|
||||
{activeTab === 'info' && (
|
||||
<div className="info-content">
|
||||
{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>
|
||||
)}
|
||||
@@ -234,8 +363,16 @@ const GuestMode: React.FC<GuestModeProps> = ({ onBack }) => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GuestMode;
|
||||
export default GuestMode;
|
||||
@@ -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;
|
||||
@@ -1,49 +1,88 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface LoginWindowProps {
|
||||
onLogin: (id: string) => void;
|
||||
onGuestMode: () => void;
|
||||
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 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(() => {
|
||||
const testConnection = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:3001/api/test', {
|
||||
mode: 'cors', // Явно указываем режим CORS
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
try {
|
||||
const response = await fetch('http://localhost:3001/api/test', {
|
||||
mode: 'cors',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log('Backend response:', data);
|
||||
} else {
|
||||
console.error('Backend returned error status:', response.status);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Connection test failed:', err);
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log('Backend response:', data);
|
||||
} else {
|
||||
console.error('Backend returned error status:', response.status);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Connection test failed:', err);
|
||||
}
|
||||
};
|
||||
testConnection();
|
||||
|
||||
const setupCardListener = () => {
|
||||
console.log('[LoginWindow] Setting up card listener...');
|
||||
const handleCardDetected = (uid: string) => {
|
||||
console.log('Card detected in renderer via preload:', uid);
|
||||
setIsCardDetected(true);
|
||||
onLogin(uid);
|
||||
setTimeout(() => setIsCardDetected(false), 2000);
|
||||
};
|
||||
|
||||
window.electronAPI.onCardDetected(handleCardDetected);
|
||||
if (window.electronAPI) {
|
||||
window.electronAPI.onCardDetected(handleCardDetected);
|
||||
} else {
|
||||
console.log('[LoginWindow] electronAPI not ready, waiting for event...');
|
||||
window.addEventListener('electronApiReady', setupCardListener, { once: true });
|
||||
}
|
||||
};
|
||||
if (window.electronAPI) {
|
||||
setupCardListener();
|
||||
} else {
|
||||
console.log('[LoginWindow] electronAPI not ready, waiting for event...');
|
||||
window.addEventListener('electronApiReady', setupCardListener, { once: true });
|
||||
}
|
||||
|
||||
setupCardListener();
|
||||
|
||||
return () => {
|
||||
console.log('[LoginWindow] Cleaning up listeners.');
|
||||
window.removeEventListener('electronApiReady', setupCardListener);
|
||||
@@ -53,13 +92,6 @@ const LoginWindow: React.FC<LoginWindowProps> = ({ onLogin, onGuestMode, loginEr
|
||||
};
|
||||
}, [onLogin]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loginError) {
|
||||
setId('');
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [loginError]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (id.trim()) {
|
||||
@@ -68,38 +100,217 @@ const LoginWindow: React.FC<LoginWindowProps> = ({ onLogin, onGuestMode, loginEr
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-window">
|
||||
<h2>Вход в систему</h2>
|
||||
<div className="card-prompt">
|
||||
<p>Пожалуйста, приложите вашу карту пропуска к считывателю</p>
|
||||
<div className="card-icon">💳</div>
|
||||
<motion.div
|
||||
className="login-window"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
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>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="input-group">
|
||||
<label htmlFor="id"></label>
|
||||
<div className="input-group" style={{ marginBottom: '15px' }}>
|
||||
<label htmlFor="id" style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
color: '#636e72',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
}}>
|
||||
Введите ID или приложите карту
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="password"
|
||||
ref={activeInputRef}
|
||||
type="text"
|
||||
id="id"
|
||||
value={id}
|
||||
onChange={(e) => setId(e.target.value)}
|
||||
placeholder=" "
|
||||
placeholder="Введите табельный номер..."
|
||||
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>
|
||||
{loginError && <div className="error" style={{ color: 'red', marginTop: 8 }}>{loginError}</div>}
|
||||
<div className="button-group">
|
||||
<button type="submit" disabled={!id.trim()}>
|
||||
|
||||
{loginError && (
|
||||
<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 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>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
marginTop: '20px',
|
||||
textAlign: 'center',
|
||||
fontSize: '11px',
|
||||
color: '#b2bec3',
|
||||
borderTop: '1px solid #f1f2f6',
|
||||
paddingTop: '15px',
|
||||
}}>
|
||||
<span>Для автоматического входа приложите карту к считывателю</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginWindow;
|
||||
export default LoginWindow;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,7 +1,9 @@
|
||||
import React from 'react';
|
||||
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 {
|
||||
fileUrl: string;
|
||||
@@ -18,28 +20,28 @@ const PayslipViewer: React.FC<PayslipViewerProps> = ({ fileUrl, onBack }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="payslip-viewer">
|
||||
<div className="top-bar">
|
||||
<button className="back-button" onClick={onBack}>
|
||||
← Назад
|
||||
</button>
|
||||
<button className="print-button" onClick={handlePrint}>
|
||||
🖨️ Печать
|
||||
</button>
|
||||
</div>
|
||||
<div className="pdf-container" style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
width: '100%'
|
||||
}}>
|
||||
<Document file={fileUrl} loading="Загрузка PDF...">
|
||||
<div style={{ margin: '0 auto' }}>
|
||||
<Page pageNumber={1} />
|
||||
</div>
|
||||
</Document>
|
||||
</div>
|
||||
<div className="payslip-viewer">
|
||||
<div className="top-bar">
|
||||
<button className="back-button" onClick={onBack}>
|
||||
← Назад
|
||||
</button>
|
||||
<button className="print-button" onClick={handlePrint}>
|
||||
🖨️ Печать
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
<div className="pdf-container" style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
width: '100%'
|
||||
}}>
|
||||
<Document file={fileUrl} loading="Загрузка PDF...">
|
||||
<div style={{ margin: '0 auto' }}>
|
||||
<Page pageNumber={1} />
|
||||
</div>
|
||||
</Document>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PayslipViewer;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+440
-89
@@ -1,4 +1,10 @@
|
||||
/* Общие стили */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -8,9 +14,12 @@ body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #f5f5f5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Окно логина */
|
||||
/* ============================================
|
||||
ОКНО ЛОГИНА
|
||||
============================================ */
|
||||
.login-window {
|
||||
max-width: 400px;
|
||||
margin: 12% auto;
|
||||
@@ -18,7 +27,6 @@ body {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
|
||||
}
|
||||
|
||||
.login-window h2 {
|
||||
@@ -83,7 +91,199 @@ button:disabled {
|
||||
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 {
|
||||
max-width: 800px;
|
||||
margin: 20px auto;
|
||||
@@ -128,7 +328,9 @@ button:disabled {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Секция расчётных листов */
|
||||
/* ============================================
|
||||
РАСЧЁТНЫЙ ЛИСТ
|
||||
============================================ */
|
||||
.payslip-section {
|
||||
margin-top: 30px;
|
||||
padding: 20px;
|
||||
@@ -170,33 +372,16 @@ button:disabled {
|
||||
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 {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
@@ -210,20 +395,19 @@ button:disabled {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background-color: #545b62;
|
||||
}
|
||||
|
||||
/* Делаем кнопку назад компактнее */
|
||||
.back-button {
|
||||
transition: all 0.2s ease;
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* Гостевой режим */
|
||||
.back-button:hover {
|
||||
background-color: #545b62;
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
ГОСТЕВОЙ РЕЖИМ
|
||||
============================================ */
|
||||
.guest-mode {
|
||||
max-width: 1200px;
|
||||
margin: 20px auto;
|
||||
@@ -287,7 +471,6 @@ button:disabled {
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
@@ -308,94 +491,262 @@ button:disabled {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* --- Handbook Content Styles --- */
|
||||
/* ============================================
|
||||
HANDBOOK СТИЛИ
|
||||
============================================ */
|
||||
.handbook-content {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 12px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 12px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.handbook-content h1 {
|
||||
text-align: center;
|
||||
font-size: 2rem;
|
||||
margin: 24px 0 16px 0;
|
||||
color: #1a237e;
|
||||
text-align: center;
|
||||
font-size: 2rem;
|
||||
margin: 24px 0 16px 0;
|
||||
color: #1a237e;
|
||||
}
|
||||
|
||||
.handbook-content h2 {
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
margin: 20px 0 12px 0;
|
||||
color: #283593;
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
margin: 20px 0 12px 0;
|
||||
color: #283593;
|
||||
}
|
||||
|
||||
.handbook-content p {
|
||||
margin: 10px 0;
|
||||
font-size: 1.08rem;
|
||||
line-height: 1.6;
|
||||
margin: 10px 0;
|
||||
font-size: 1.08rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.handbook-content ul {
|
||||
margin: 12px 0 12px 32px;
|
||||
padding-left: 18px;
|
||||
margin: 12px 0 12px 32px;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.handbook-content li {
|
||||
margin: 6px 0;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.5;
|
||||
margin: 6px 0;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.handbook-content strong {
|
||||
font-weight: bold;
|
||||
color: #000000;
|
||||
text-align: center;
|
||||
|
||||
font-weight: bold;
|
||||
color: #000000;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.handbook-content h3 {
|
||||
font-weight: bold;
|
||||
color: #000000;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
color: #000000;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.handbook-content em {
|
||||
font-style: italic;
|
||||
color: #555;
|
||||
text-align: center;
|
||||
}
|
||||
font-style: italic;
|
||||
color: #555;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.handbook-content h4 {
|
||||
font-weight: bold;
|
||||
color: #000000;
|
||||
text-align: center;
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
color: #000000;
|
||||
text-align: center;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.handbook-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 24px;
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.handbook-table th,
|
||||
.handbook-table td {
|
||||
border: 1px solid #ccc;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ccc;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.handbook-table th {
|
||||
background: #f5f5f5;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.handbook-content table,
|
||||
.handbook-content th,
|
||||
.handbook-content td,
|
||||
.handbook-content tr {
|
||||
border: 1px solid #bdbdbd;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid #bdbdbd;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.handbook-content th,
|
||||
.handbook-content td {
|
||||
padding: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.handbook-content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user