print_add

This commit is contained in:
2026-09-01 11:47:15 +03:00
parent 1fe0d67731
commit 70a20665ae
38 changed files with 5494 additions and 889 deletions
Binary file not shown.
+939 -8
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -9,20 +9,25 @@
"@testing-library/user-event": "^13.5.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"axios": "^1.19.0",
"axios": "^1.20.0",
"date-fns": "^2.30.0",
"dompurify": "^3.4.13",
"dotenv": "^17.4.2",
"electron-is-dev": "^2.0.0",
"framer-motion": "^13.1.0",
"pdf-lib": "^1.17.1",
"pdf-to-printer": "^5.8.1",
"pdfjs-dist": "^3.11.174",
"pdfkit": "^0.20.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-icons": "^4.11.0",
"react-pdf": "^8.0.0",
"react-zoom-pan-pinch": "^3.3.0",
"sharp": "^0.35.4",
"typescript": "^5.8.0",
"web-vitals": "^2.1.4"
"web-vitals": "^2.1.4",
"xlsx": "^0.18.5"
},
"scripts": {
"start": "vite",
Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

+99 -15
View File
@@ -3,11 +3,11 @@ const path = require('path');
require('dotenv').config({path: path.join(__dirname, 'config.env')})
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 axios = require('axios');
const { logger } = require('../../backend/src/config/logger');
const { logger } = require('../../backend/src/config/logger');
const config = require('../src/config.json')
const { print } = require('pdf-to-printer');
protocol.registerSchemesAsPrivileged([
{
scheme: 'app',
@@ -64,13 +64,12 @@ function createWindow() {
mainWindow = new BrowserWindow({
kiosk: true,
titleBarStyle:'hidden',
titleBarStyle: 'hidden',
alwaysOnTop: false,
webPreferences: {
preload: preloadPath,
contextIsolation: true,
nodeIntegration: false
},
});
@@ -91,6 +90,98 @@ function createWindow() {
});
}
// ============================================
// 📄 ПЕЧАТЬ PDF ЧЕРЕЗ pdf-to-printer
// ============================================
ipcMain.handle('print-pdf', async (event, data = {}) => {
let tempFile = null;
try {
if (!data.pdfUrl) {
throw new Error('URL файла PDF не передан');
}
const cleanUrl = data.pdfUrl.split('#')[0];
logger.info('[PRINT] Загрузка PDF:', cleanUrl);
// ✅ Скачиваем PDF
const response = await axios.get(cleanUrl, {
responseType: 'arraybuffer',
timeout: 30000
});
const pdfBuffer = Buffer.from(response.data);
logger.info('[PRINT] Размер PDF:', pdfBuffer.length, 'байт');
if (pdfBuffer.length < 100) {
throw new Error('PDF слишком маленький или пустой');
}
// ✅ Сохраняем во временный файл
const tempDir = app.getPath('temp');
tempFile = path.join(tempDir, `print_${Date.now()}.pdf`);
fs.writeFileSync(tempFile, pdfBuffer);
logger.info('[PRINT] Временный файл создан:', tempFile);
// ✅ ПЕЧАТАЕМ ЧЕРЕЗ pdf-to-printer
const printerName = 'GP-80250 Series';
logger.info('[PRINT] Отправляем на печать через pdf-to-printer:', printerName);
// ✅ Опции печати
const options = {
printer: printerName,
// scale: 'fit', // Масштабирование
// paperSize: 'A4',
// landscape: false,
// copies: 1,
// silent: true // Тихая печать
};
const result = await print(tempFile, options);
logger.info('[PRINT] Результат печати:', result);
// ✅ Удаляем временный файл
try {
if (tempFile && fs.existsSync(tempFile)) {
fs.unlinkSync(tempFile);
logger.info('[PRINT] Временный файл удален');
}
} catch (e) {}
logger.info('[PRINT] ✅ Документ отправлен на печать');
return true;
} catch (error) {
logger.error('[PRINT] ❌ Ошибка:', error.message);
try {
if (tempFile && fs.existsSync(tempFile)) {
fs.unlinkSync(tempFile);
}
} catch (e) {}
throw error;
}
});
// ============================================
// 📄 ПОЛУЧЕНИЕ СПИСКА ПРИНТЕРОВ
// ============================================
ipcMain.handle('get-printers', async () => {
return [
{
name: 'GP-80250 Series',
displayName: 'GP-80250 Series',
isDefault: true,
status: 'ready'
}
];
});
// ============================================
// 🚀 ЗАПУСК
// ============================================
app.whenReady().then(() => {
createWindow();
global.allowMinimize = false;
@@ -112,14 +203,13 @@ app.whenReady().then(() => {
}
}, 10);
});
globalShortcut.register('Control+Alt+Enter', () => {
global.allowClose = true;
app.quit();
});
globalShortcut.register('F11', () => {
});
globalShortcut.register('F11', () => {});
});
app.on('window-all-closed', () => {
@@ -138,12 +228,6 @@ app.on('will-quit', () => {
globalShortcut.unregisterAll();
});
//считыватель
ipcMain.handle('get-app-config', async (event) => {
console.log('[IPC] Renderer requested app config. Sending:', appConfig);
return appConfig;
});
app.whenReady().then(() => {
return appConfig;
});
Binary file not shown.
Binary file not shown.
+12 -5
View File
@@ -1,6 +1,12 @@
const { logger } = require('../../backend/src/config/logger');
logger.info('[Preload] Script started.');
const { contextBridge, ipcRenderer } = require('electron');
try {
const { logger } = require('../../backend/src/config/logger');
logger.info('[Preload] Script started.');
} catch (e) {
console.error('[Preload] Логгер не загрузился, но едем дальше.', e);
}
contextBridge.exposeInMainWorld(
'electronAPI',
{
@@ -8,12 +14,13 @@ contextBridge.exposeInMainWorld(
onCardDetected: (callback) => {
ipcRenderer.on('card-detected', (event, uid) => callback(uid));
},
removeCardDetectedListeners: () => {
ipcRenderer.removeAllListeners('card-detected');
}
},
printPdf: (options) => ipcRenderer.invoke('print-pdf', options),
getPrinters: () => ipcRenderer.invoke('get-printers'),
}
);
logger.log('[Preload] Script finished. electronAPI exposed.');
console.log('[Preload] Script finished. electronAPI exposed.');
window.dispatchEvent(new Event('electronApiReady'));
+11 -12
View File
@@ -45,6 +45,7 @@ function App() {
const [currentSection, setCurrentSection] = useState<string | null>(null);
const [showPayslip, setShowPayslip] = useState(false);
const [selectedPeriod, setSelectedPeriod] = useState('');
const [payslipFileUrl, setPayslipFileUrl] = useState<string>('');
// 📊 Состояния для таймера
const [showInactiveWarning, setShowInactiveWarning] = useState(false);
@@ -64,6 +65,7 @@ function App() {
setCurrentSection(null);
setShowPayslip(false);
setSelectedPeriod('');
setPayslipFileUrl('');
setShowInactiveWarning(false);
setLoginError('');
@@ -138,6 +140,7 @@ function App() {
setCurrentSection(null);
setShowPayslip(false);
setSelectedPeriod('');
setPayslipFileUrl('');
handleUserActivity();
};
@@ -146,8 +149,8 @@ function App() {
handleUserActivity();
};
const handleViewPayslip = (period: string) => {
setSelectedPeriod(period);
const handleViewPayslip = (fileUrl: string) => {
setPayslipFileUrl(fileUrl);
setShowPayslip(true);
handleUserActivity();
};
@@ -155,14 +158,10 @@ function App() {
const handlePayslipBack = () => {
setShowPayslip(false);
setSelectedPeriod('');
setPayslipFileUrl('');
handleUserActivity();
};
const getPayslipUrl = (employee: Employee, period: string) => {
const fileName = `${employee.tnom}.pdf`;
return `${config.backendUrl}/api/payslip/${period}/${fileName}`;
};
// ============================================
// ⚠️ Предупреждение о неактивности
// ============================================
@@ -279,7 +278,7 @@ function App() {
if (showPayslip) {
return (
<PayslipViewer
fileUrl={employee && selectedPeriod ? getPayslipUrl(employee, selectedPeriod) : "/payslips/test.pdf"}
fileUrl={payslipFileUrl || "/payslips/test.pdf"}
onBack={handlePayslipBack}
/>
);
@@ -316,7 +315,7 @@ function App() {
// 🚀 Рендер приложения
// ============================================
const showTopBar = !showVideo && !showPayslip;
const topBarHeight = 70; // высота виджета
const topBarHeight = 70;
return (
<div
@@ -331,9 +330,9 @@ function App() {
{/* ⚠️ Предупреждение о неактивности */}
{renderWarning()}
{/* 🌤️ ВЕРХНИЙ ВИДЖЕТ: ПОГОДА + ВРЕМЯ + ВЫХОД */}
{/* 🌤️ ВЕРХНИЙ ВИДЖЕТ: ПОГОДА + ВРЕМЯ + ПРИВЕТСТВИЕ + ВЫХОД */}
{showTopBar && (
<WeatherWidget onLogout={handleBack} />
<WeatherWidget onLogout={handleBack} employee={employee || undefined} />
)}
{/* 📄 Основной контент с отступом сверху */}
@@ -348,7 +347,7 @@ function App() {
{renderContent()}
</div>
{/* 📊 НИЖНЯЯ ПАНЕЛЬ: КУРСЫ ВАЛЮТ */}
{/* 📊 НИЖНЯЯ ПАНЕЛЬ: ТОЛЬКО КУРСЫ ВАЛЮТ (БЕЗ ПРИВЕТСТВИЯ) */}
{showTopBar && (
<BottomBar />
)}
+156 -161
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import React, { useState, useEffect, useRef, useCallback, memo } from 'react';
import { motion } from 'framer-motion';
import { FaArrowLeft, FaSearch, FaArrowUp, FaArrowDown } from 'react-icons/fa';
import MapViewer from './MapViewer';
@@ -75,7 +75,7 @@ function parseHandbookBlocks(html: string) {
}
// ============================================
// 📚 Функции рендеринга с подсветкой и поиском
// 📚 Функции рендеринга
// ============================================
const ADMIN_SERVER_URL = config.adminServerUrl || 'http://localhost:3005';
@@ -85,185 +85,164 @@ function patchMediaSrc(html: string): string {
.replace(/<video([^>]+)src=["'](\/uploads\/[^"']+)["']/g, `<video$1src="${ADMIN_SERVER_URL}$2"`);
}
function stripHtml(html: string): string {
const tmp = document.createElement('div');
tmp.innerHTML = html;
return tmp.textContent || tmp.innerText || '';
}
// ✅ Подсветка текста внутри HTML с сохранением разметки
function highlightTextInHtml(html: string, searchTerm: string): { html: string; count: number } {
if (!searchTerm.trim()) return { html, count: 0 };
// ✅ Простая подсветка через replace (без DOM-обхода)
function highlightText(html: string, searchTerm: string): string {
if (!searchTerm.trim()) return html;
const term = searchTerm.trim();
const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(${escapedTerm})`, 'gi');
const temp = document.createElement('div');
temp.innerHTML = html;
let matchCount = 0;
function walkNodes(node: Node): void {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent || '';
if (text.toLowerCase().includes(term.toLowerCase())) {
const fragment = document.createDocumentFragment();
let lastIndex = 0;
let match;
const tempRegex = new RegExp(escapedTerm, 'gi');
while ((match = tempRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
fragment.appendChild(document.createTextNode(text.substring(lastIndex, match.index)));
}
const mark = document.createElement('mark');
mark.style.cssText = 'background: #ffdd57; color: #1a3c34; padding: 0 4px; border-radius: 3px; font-weight: 600;';
mark.dataset.matchIndex = String(matchCount);
mark.textContent = match[0];
fragment.appendChild(mark);
lastIndex = match.index + match[0].length;
matchCount++;
}
if (lastIndex < text.length) {
fragment.appendChild(document.createTextNode(text.substring(lastIndex)));
}
node.parentNode?.replaceChild(fragment, node);
}
} else if (node.nodeType === Node.ELEMENT_NODE) {
const children = Array.from(node.childNodes);
for (const child of children) {
walkNodes(child);
}
}
}
walkNodes(temp);
return { html: temp.innerHTML, count: matchCount };
}
function blockMatchesSearch(block: any, searchTerm: string): boolean {
if (!searchTerm.trim()) return true;
const term = searchTerm.trim().toLowerCase();
if (typeof block === 'string') {
return stripHtml(block).toLowerCase().includes(term);
} else if (block.type === 'phones') {
return block.rows.some((row: { desc: string; internal: string; city: string }) =>
row.desc.toLowerCase().includes(term) ||
row.internal.includes(term) ||
row.city.includes(term)
return html.replace(/>([^<]*)</g, (match: string, text: string) => {
const highlighted = text.replace(regex, (m: string) =>
`<mark style="background: #ffdd57; color: #1a3c34; padding: 0 4px; border-radius: 3px; font-weight: 600;">${m}</mark>`
);
}
return true;
return `>${highlighted}<`;
});
}
function countMatches(html: string, searchTerm: string): number {
if (!searchTerm.trim()) return 0;
const term = searchTerm.trim();
const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(escapedTerm, 'gi');
const matches = html.match(regex);
return matches ? matches.length : 0;
}
// ============================================
// 📦 Компонент для рендеринга справочника
// 📦 Компонент для рендеринга справочника (ОПТИМИЗИРОВАННЫЙ)
// ============================================
const HandbookRenderer: React.FC<{
blocks: any[];
searchTerm: string;
const HandbookRenderer = memo(({
blocks,
searchTerm,
onMatchCount
}: {
blocks: any[];
searchTerm: string;
onMatchCount: (count: number) => void;
}> = ({ blocks, searchTerm, onMatchCount }) => {
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [renderedHtml, setRenderedHtml] = useState<{ html: string; count: number }[]>([]);
const [renderedBlocks, setRenderedBlocks] = useState<any[]>([]);
const [totalMatches, setTotalMatches] = useState(0);
const hasSearch = searchTerm.trim().length > 0;
const [isSearching, setIsSearching] = useState(false);
const processingRef = useRef(false);
// ✅ Обработка блоков с задержкой через requestAnimationFrame
useEffect(() => {
if (!blocks.length) return;
const result: { html: string; count: number }[] = [];
let total = 0;
// ✅ Если идет обработка — пропускаем
if (processingRef.current) return;
processingRef.current = true;
blocks.forEach(block => {
if (typeof block === 'string') {
const content = patchMediaSrc(block);
if (hasSearch) {
const highlighted = highlightTextInHtml(content, searchTerm);
result.push({ html: highlighted.html, count: highlighted.count });
total += highlighted.count;
} else {
result.push({ html: content, count: 0 });
}
} else if (block.type === 'phones') {
const term = searchTerm.trim().toLowerCase();
let rows = block.rows;
if (hasSearch && term) {
rows = rows.filter((row: { desc: string; internal: string; city: string }) =>
row.desc.toLowerCase().includes(term) ||
row.internal.includes(term) ||
row.city.includes(term)
);
}
if (rows.length === 0) {
result.push({ html: '', count: 0 });
return;
}
// ✅ Показываем индикатор поиска
setIsSearching(true);
let tableHtml = `<table class="handbook-table">`;
tableHtml += `
<thead>
<tr>
<th>Описание</th>
<th>АТС предприятия</th>
<th>Городская АТС</th>
</tr>
</thead>
<tbody>`;
// ✅ Используем requestAnimationFrame для отложенной обработки
requestAnimationFrame(() => {
// ✅ Еще одна задержка через setTimeout для накопления изменений
const timeoutId = setTimeout(() => {
const processed = blocks.map(block => {
if (typeof block === 'string') {
const content = patchMediaSrc(block);
if (searchTerm.trim()) {
return highlightText(content, searchTerm);
}
return content;
} else if (block.type === 'phones') {
const term = searchTerm.trim().toLowerCase();
let rows = block.rows;
if (term) {
rows = rows.filter((row: { desc: string; internal: string; city: string }) =>
row.desc.toLowerCase().includes(term) ||
row.internal.includes(term) ||
row.city.includes(term)
);
}
if (rows.length === 0) return null;
rows.forEach((row: { desc: string; internal: string; city: string }) => {
let descHtml = row.desc;
let internalHtml = row.internal;
let cityHtml = row.city;
let rowHasMatch = false;
let tableHtml = `<table class="handbook-table">`;
tableHtml += `
<thead>
<tr>
<th>Описание</th>
<th>АТС предприятия</th>
<th>Городская АТС</th>
</tr>
</thead>
<tbody>`;
if (hasSearch && term) {
const descResult = highlightTextInHtml(row.desc, searchTerm);
descHtml = descResult.html;
if (descResult.count > 0) rowHasMatch = true;
const internalResult = highlightTextInHtml(row.internal, searchTerm);
internalHtml = internalResult.html;
if (internalResult.count > 0) rowHasMatch = true;
const cityResult = highlightTextInHtml(row.city, searchTerm);
cityHtml = cityResult.html;
if (cityResult.count > 0) rowHasMatch = true;
total += descResult.count + internalResult.count + cityResult.count;
rows.forEach((row: { desc: string; internal: string; city: string }) => {
let descHtml = row.desc;
let internalHtml = row.internal;
let cityHtml = row.city;
if (term) {
descHtml = highlightText(row.desc, searchTerm);
internalHtml = highlightText(row.internal, searchTerm);
cityHtml = highlightText(row.city, searchTerm);
}
tableHtml += `
<tr>
<td>${descHtml}</td>
<td>${internalHtml}</td>
<td>${cityHtml}</td>
</tr>`;
});
tableHtml += `</tbody></table>`;
return tableHtml;
}
return null;
}).filter(Boolean);
tableHtml += `
<tr>
<td>${descHtml}</td>
<td>${internalHtml}</td>
<td>${cityHtml}</td>
</tr>`;
});
setRenderedBlocks(processed);
setIsSearching(false);
processingRef.current = false;
tableHtml += `</tbody></table>`;
result.push({ html: tableHtml, count: 0 });
}
// ✅ Подсчет совпадений
if (searchTerm.trim()) {
let total = 0;
processed.forEach(block => {
if (typeof block === 'string') {
total += countMatches(block, searchTerm);
}
});
setTotalMatches(total);
onMatchCount(total);
} else {
setTotalMatches(0);
onMatchCount(0);
}
}, 50);
return () => clearTimeout(timeoutId);
});
setRenderedHtml(result);
setTotalMatches(total);
onMatchCount(total);
}, [blocks, searchTerm, onMatchCount]);
useEffect(() => {
if (!containerRef.current) return;
const marks = containerRef.current.querySelectorAll('mark');
marks.forEach((el, idx) => {
el.dataset.matchIndex = String(idx);
});
}, [renderedHtml]);
return (
<div ref={containerRef} className="handbook-content">
{renderedHtml.map((item, idx) => (
item.html ? <div key={idx} style={{ marginBottom: 16 }} dangerouslySetInnerHTML={{ __html: item.html }} /> : null
{isSearching && (
<div style={{
textAlign: 'center',
padding: '8px',
color: '#636e72',
fontSize: '13px',
borderBottom: '1px solid #f1f2f6',
marginBottom: '16px'
}}>
<span style={{ display: 'inline-block', animation: 'spin 1s linear infinite', marginRight: '8px' }}>⟳</span>
Поиск...
</div>
)}
{renderedBlocks.map((block, idx) => (
<div key={idx} style={{ marginBottom: 16 }} dangerouslySetInnerHTML={{ __html: block }} />
))}
</div>
);
};
});
// ============================================
// 🏠 Основной компонент Contacts
@@ -279,6 +258,10 @@ const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
const contentRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// ✅ Debounce для поиска (300ms)
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('');
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
const MAP_FILE_URL = '/assets/maps/enterprise-map.tiff';
@@ -288,6 +271,26 @@ const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
}
}, [activeTab]);
// ✅ DEBOUNCE: обновляем debouncedSearchTerm с задержкой 300ms
useEffect(() => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
debounceTimerRef.current = setTimeout(() => {
setDebouncedSearchTerm(searchTerm);
debounceTimerRef.current = null;
}, 100);
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
};
}, [searchTerm]);
const loadHandbook = async () => {
setLoadingHandbook(true);
setErrorHandbook(null);
@@ -329,10 +332,10 @@ const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
const container = contentRef.current;
if (!container) return;
const marks = container.querySelectorAll('mark[data-match-index]');
const marks = container.querySelectorAll('mark');
if (marks.length === 0) return;
const targetMark = Array.from(marks).find((el: Element) => el.getAttribute('data-match-index') === String(index));
const targetMark = marks[index];
if (!targetMark) return;
const containerRect = container.getBoundingClientRect();
@@ -629,7 +632,6 @@ const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
</div>
</div>
{/* ✅ БЕЛЫЙ ФОН, ТЁМНЫЙ ТЕКСТ */}
<div
ref={contentRef}
style={{
@@ -676,10 +678,8 @@ const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
) : blocks.length > 0 ? (
<HandbookRenderer
blocks={blocks}
searchTerm={searchTerm}
onMatchCount={(count) => {
setMatchCount(count);
}}
searchTerm={debouncedSearchTerm}
onMatchCount={setMatchCount}
/>
) : (
<p style={{ textAlign: 'center', color: '#b2bec3' }}>Нет данных для отображения</p>
@@ -744,7 +744,6 @@ const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
)}
</div>
{/* ✅ ТЁМНЫЙ ТЕКСТ ДЛЯ СПРАВОЧНИКА */}
<style>
{`
@keyframes spin {
@@ -800,10 +799,6 @@ const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
padding: 0 4px;
border-radius: 3px;
font-weight: 600;
transition: outline 0.3s ease;
}
mark[data-match-index] {
cursor: pointer;
}
::-webkit-scrollbar {
width: 8px;
+688 -45
View File
@@ -1,4 +1,7 @@
import React, { useState } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { FaArrowLeft, FaSpinner, FaEye, FaPrint, FaTimes, FaExpand, FaCompress, FaChevronUp, FaChevronDown, FaCheckCircle, FaExclamationCircle } from 'react-icons/fa';
import config from '../config.json';
interface Employee {
id: string;
@@ -31,7 +34,22 @@ const EmployeeInfo: React.FC<EmployeeInfoProps> = ({
return `${month}${year}`;
});
// Генерируем список периодов за последние 12 месяцев
const [showPdfPreview, setShowPdfPreview] = useState(false);
const [pdfLoading, setPdfLoading] = useState(false);
const [pdfError, setPdfError] = useState<string | null>(null);
const [payslipUrl, setPayslipUrl] = useState<string | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [hasPayslip, setHasPayslip] = useState<boolean | null>(null);
const [checkingPayslip, setCheckingPayslip] = useState(false);
const [isPrinting, setIsPrinting] = useState(false);
const [printStatus, setPrintStatus] = useState<'idle' | 'success' | 'error' | 'sending'>('idle');
const [printMessage, setPrintMessage] = useState('');
const scrollContainerRef = useRef<HTMLDivElement>(null);
const iframeRef = useRef<HTMLIFrameElement>(null);
const generatePeriods = () => {
const periods = [];
const now = new Date();
@@ -50,52 +68,677 @@ const EmployeeInfo: React.FC<EmployeeInfoProps> = ({
const monthNames = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'];
return (
<div className="employee-info">
<div className="top-bar">
<button className="back-button" onClick={onBack}>
← Назад
</button>
</div>
const getFullUrl = (url: string) => {
return url.startsWith('http') ? url : `http://localhost:3001${url}`;
};
const checkPayslip = async () => {
if (!employee?.tnom) return;
try {
setCheckingPayslip(true);
const backendUrl = config.backendUrl || 'http://localhost:3001';
const response = await fetch(`${backendUrl}/api/payslip/url/${employee.tnom}?period=${selectedPeriod}`);
if (response.ok) {
const data = await response.json();
setHasPayslip(data.exists);
if (data.exists && data.url) {
setPayslipUrl(data.url);
}
} else {
setHasPayslip(false);
}
} catch (error) {
console.error('Ошибка проверки расчетного листка:', error);
setHasPayslip(false);
} finally {
setCheckingPayslip(false);
}
};
useEffect(() => {
checkPayslip();
}, [selectedPeriod, employee]);
const handlePreviewPdf = async () => {
if (!employee?.tnom) return;
setShowPdfPreview(true);
setPdfLoading(true);
setPdfError(null);
setIsFullscreen(false);
try {
const backendUrl = config.backendUrl || 'http://localhost:3001';
const response = await fetch(`${backendUrl}/api/payslip/url/${employee.tnom}?period=${selectedPeriod}`);
<div className="employee-header">
<div className="employee-photo">
<div className="photo-placeholder">Фото</div>
</div>
<div className="employee-details">
<h2>{`${employee.famaly} ${employee.ima} ${employee.otch}`}</h2>
<p>Табельный номер: {employee.tnom}</p>
{employee.category?.trim() && <p>Категория: {employee.category}</p>}
{employee.departmentName && (
<p>Подразделение: {employee.departmentName}</p>
)}
<p>Организация: {employee.organization}</p>
{employee.profession && <p>Профессия: {employee.profession}</p>}
</div>
</div>
if (response.ok) {
const data = await response.json();
if (data.exists && data.url) {
const fullUrl = data.url.startsWith('http') ? data.url : `${backendUrl}${data.url}`;
setPayslipUrl(fullUrl);
setPdfLoading(false);
} else {
setPdfError('Расчетный листок не найден');
setPdfLoading(false);
}
} else {
setPdfError('Ошибка при проверке расчетного листка');
setPdfLoading(false);
}
} catch (error) {
console.error('Ошибка загрузки PDF:', error);
setPdfError('Не удалось загрузить расчетный листок');
setPdfLoading(false);
}
};
const handleClosePreview = () => {
setShowPdfPreview(false);
setPdfLoading(false);
setPdfError(null);
setIsFullscreen(false);
};
const handlePrint = async () => {
try {
setIsPrinting(true);
setPrintStatus('sending');
setPrintMessage('Отправка документа на печать...');
<div className="payslip-section">
<h3>Расчётный лист</h3>
<div className="period-selector">
<label htmlFor="period-select">Выберите период:</label>
<select
id="period-select"
value={selectedPeriod}
onChange={(e) => setSelectedPeriod(e.target.value)}
>
{periods.map(period => (
<option key={period} value={period}>
{monthNames[parseInt(period.substring(0, 2)) - 1]} {period.substring(2)}
</option>
))}
</select>
</div>
const urlToPrint = payslipUrl ; // payslipUrl для Employee/Finance, fileUrl для Viewer
if (!urlToPrint) throw new Error('URL документа отсутствует');
const fullUrl = urlToPrint.startsWith('http') ? urlToPrint : `http://localhost:3001${urlToPrint}`;
// 🚀 ВАРИАНТ 1: Мы в десктопе (Electron)
if ((window as any).electronAPI && (window as any).electronAPI.printPdf) {
console.log('🖨️ [PRINT] Печатаем тихо через Electron API');
await (window as any).electronAPI.printPdf({
pdfUrl: fullUrl,
deviceName: '' // <-- Оставляем пустым! Electron сам плюнет на дефолтный принтер
});
<button onClick={() => onViewPayslip(selectedPeriod)}>
Посмотреть расчётный лист
</button>
</div>
</div>
setPrintStatus('success');
setPrintMessage('✅ Отправлено на принтер');
}
// 🌐 ВАРИАНТ 2: Мы в браузере (Chrome/Edge)
else {
console.log('🖨️ [PRINT] Electron API нет. Обходим CORS и печатаем через браузер');
// Скачиваем PDF как Blob, чтобы сделать его "своим" для браузера
const response = await fetch(fullUrl);
if (!response.ok) throw new Error('Не удалось скачать PDF с сервера');
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
// Создаем невидимый iframe
const printIframe = document.createElement('iframe');
printIframe.style.display = 'none';
printIframe.src = blobUrl;
document.body.appendChild(printIframe);
printIframe.onload = () => {
try {
printIframe.contentWindow?.focus();
printIframe.contentWindow?.print();
setPrintStatus('success');
setPrintMessage('✅ Документ отправлен на печать');
} catch (e) {
console.error('Ошибка iframe печати:', e);
throw new Error('Браузер заблокировал печать');
} finally {
// Чистим за собой мусор через 5 секунд
setTimeout(() => {
document.body.removeChild(printIframe);
URL.revokeObjectURL(blobUrl);
}, 5000);
}
};
}
} catch (error) {
console.error('❌ [PRINT] Ошибка печати:', error);
setPrintStatus('error');
setPrintMessage('❌ Ошибка: ' + (error instanceof Error ? error.message : 'Неизвестная ошибка'));
} finally {
setIsPrinting(false);
setTimeout(() => {
setPrintStatus('idle');
setPrintMessage('');
}, 3000);
}
};
const toggleFullscreen = () => {
setIsFullscreen(!isFullscreen);
};
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={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
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>
<h3 style={{
color: 'black',
fontSize: '25px',
fontWeight: 600,
}}>
📋 Расчетный лист
</h3>
<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', alignItems: 'center', gap: '25px', flexWrap: 'wrap' }}>
<div>
<div style={{ fontSize: '22px', fontWeight: 500 }}>
{`${employee.famaly} ${employee.ima} ${employee.otch}`}
</div>
<div style={{ fontSize: '14px', opacity: 0.9, marginTop: '5px' }}>
Табельный номер: {employee.tnom}
</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)',
}}
>
<h3 style={{ margin: '0 0 20px 0', color: '#2d3436', fontSize: '18px', fontWeight: 500 }}>
📄 Расчетный листок
</h3>
<div style={{
display: 'flex',
gap: '15px',
flexWrap: 'wrap',
alignItems: 'center',
marginBottom: '20px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<label style={{ fontSize: '14px', color: '#636e72' }}>
Выберите период:
</label>
<select
value={selectedPeriod}
onChange={(e) => setSelectedPeriod(e.target.value)}
style={{
padding: '8px 14px',
borderRadius: '8px',
border: '1px solid #dfe6e9',
fontSize: '14px',
background: 'white',
cursor: 'pointer',
}}
>
{periods.map(period => {
const month = parseInt(period.substring(0, 2)) - 1;
const year = period.substring(2);
return (
<option key={period} value={period}>
{monthNames[month]} {year}
</option>
);
})}
</select>
</div>
</div>
{checkingPayslip ? (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '12px 16px',
background: '#f8f9fa',
borderRadius: '10px',
marginBottom: '16px',
}}>
<FaSpinner style={{ animation: 'spin 1s linear infinite', fontSize: '18px', color: '#636e72' }} />
<span style={{ color: '#636e72', fontSize: '14px' }}>Проверка наличия расчетного листка...</span>
</div>
) : hasPayslip ? (
<div style={{
padding: '12px 16px',
background: '#f0fdf4',
borderRadius: '10px',
border: '1px solid #bbf7d0',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '10px',
}}>
<FaCheckCircle style={{ color: '#22c55e', fontSize: '18px' }} />
<div>
<div style={{ fontSize: '14px', color: '#166534', fontWeight: 500 }}>
✅ Расчетный листок доступен
</div>
<div style={{ fontSize: '13px', color: '#14532d' }}>
Табельный номер: <strong>{employee?.tnom}</strong>
</div>
</div>
</div>
) : hasPayslip === false ? (
<div style={{
padding: '12px 16px',
background: '#fef2f2',
borderRadius: '10px',
border: '1px solid #fecaca',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '10px',
}}>
<FaExclamationCircle style={{ color: '#ef4444', fontSize: '18px' }} />
<div>
<div style={{ fontSize: '14px', color: '#991b1b', fontWeight: 500 }}>
❌ Расчетный листок не найден
</div>
<div style={{ fontSize: '13px', color: '#991b1b', opacity: 0.8 }}>
Для табельного номера <strong>{employee?.tnom}</strong> файл отсутствует
</div>
</div>
</div>
) : null}
<button
onClick={handlePreviewPdf}
disabled={!hasPayslip}
style={{
padding: '10px 25px',
background: hasPayslip ? 'linear-gradient(135deg, #00b894, #00cec9)' : '#dfe6e9',
color: hasPayslip ? 'white' : '#b2bec3',
border: 'none',
borderRadius: '10px',
fontSize: '14px',
fontWeight: 500,
cursor: hasPayslip ? 'pointer' : 'not-allowed',
transition: 'all 0.3s ease',
boxShadow: hasPayslip ? '0 4px 15px rgba(0, 206, 201, 0.3)' : 'none',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
onMouseEnter={(e) => {
if (hasPayslip) {
e.currentTarget.style.transform = 'translateY(-2px)';
e.currentTarget.style.boxShadow = '0 6px 20px rgba(0, 206, 201, 0.4)';
}
}}
onMouseLeave={(e) => {
if (hasPayslip) {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 15px rgba(0, 206, 201, 0.3)';
}
}}
>
<FaEye />
Предпросмотр
</button>
</motion.div>
<style>{`
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
</motion.div>
<AnimatePresence>
{printStatus !== 'idle' && (
<motion.div
initial={{ opacity: 0, y: -50, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -50, scale: 0.9 }}
style={{
position: 'fixed',
top: '20px',
left: '50%',
transform: 'translateX(-50%)',
zIndex: 99999,
padding: '16px 32px',
borderRadius: '12px',
background: printStatus === 'success' ? '#10b981' :
printStatus === 'error' ? '#ef4444' :
'#f59e0b',
color: 'white',
boxShadow: '0 10px 40px rgba(0,0,0,0.2)',
display: 'flex',
alignItems: 'center',
gap: '12px',
fontSize: '16px',
fontWeight: 500,
minWidth: '300px',
justifyContent: 'center',
}}
>
{printStatus === 'sending' && <FaSpinner style={{ animation: 'spin 1s linear infinite' }} />}
{printStatus === 'success' && <FaCheckCircle />}
{printStatus === 'error' && <FaExclamationCircle />}
{printMessage}
</motion.div>
)}
</AnimatePresence>
{showPdfPreview && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.85)',
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
zIndex: 9999,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px',
}}
onClick={handleClosePreview}
>
<motion.div
initial={{ scale: 0.9, y: 30, opacity: 0 }}
animate={{ scale: 1, y: 0, opacity: 1 }}
exit={{ scale: 0.9, y: 30, opacity: 0 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
style={{
background: '#1a1a2e',
borderRadius: '24px',
width: '100%',
maxWidth: isFullscreen ? '100%' : '900px',
height: isFullscreen ? '100%' : '90vh',
maxHeight: isFullscreen ? '100%' : '90vh',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
boxShadow: '0 50px 100px rgba(0,0,0,0.5)',
position: 'relative',
}}
onClick={(e) => e.stopPropagation()}
>
<div style={{
padding: '16px 24px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexShrink: 0,
minHeight: '60px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ fontSize: '20px' }}>📄</span>
<span style={{ color: 'white', fontSize: '16px', fontWeight: 500 }}>
Расчетный листок
</span>
<span style={{
color: 'rgba(255,255,255,0.5)',
fontSize: '12px',
background: 'rgba(255,255,255,0.15)',
padding: '2px 10px',
borderRadius: '12px',
}}>
{employee?.tnom}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<button
onClick={handlePrint}
disabled={isPrinting}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '8px 16px',
background: isPrinting ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.2)',
color: 'white',
border: '1px solid rgba(255,255,255,0.3)',
borderRadius: '10px',
cursor: isPrinting ? 'not-allowed' : 'pointer',
fontSize: '14px',
fontWeight: 500,
transition: 'all 0.3s ease',
backdropFilter: 'blur(4px)',
opacity: isPrinting ? 0.5 : 1,
}}
onMouseEnter={(e) => {
if (!isPrinting) {
e.currentTarget.style.background = 'rgba(255,255,255,0.3)';
}
}}
onMouseLeave={(e) => {
if (!isPrinting) {
e.currentTarget.style.background = 'rgba(255,255,255,0.2)';
}
}}
>
{isPrinting ? <FaSpinner style={{ animation: 'spin 1s linear infinite' }} /> : <FaPrint size={16} />}
{isPrinting ? 'Отправка...' : 'Печать'}
</button>
<button
onClick={toggleFullscreen}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '36px',
height: '36px',
background: 'rgba(255,255,255,0.2)',
color: 'white',
border: '1px solid rgba(255,255,255,0.3)',
borderRadius: '10px',
cursor: 'pointer',
transition: 'all 0.3s ease',
}}
>
{isFullscreen ? <FaCompress size={16} /> : <FaExpand size={16} />}
</button>
<button
onClick={handleClosePreview}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '36px',
height: '36px',
background: 'rgba(255,255,255,0.2)',
color: 'white',
border: '1px solid rgba(255,255,255,0.3)',
borderRadius: '10px',
cursor: 'pointer',
transition: 'all 0.3s ease',
}}
>
<FaTimes size={18} />
</button>
</div>
</div>
<div style={{
flex: 1,
padding: '20px',
overflow: 'auto',
background: '#0a0a0a',
position: 'relative',
WebkitOverflowScrolling: 'touch',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
{pdfLoading && (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '20px',
}}>
<div style={{
width: '60px',
height: '60px',
border: '4px solid #2d2d2d',
borderTop: '4px solid #667eea',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
}} />
<span style={{ color: '#888', fontSize: '16px' }}>
Загрузка документа...
</span>
</div>
)}
{!pdfLoading && payslipUrl && !pdfError && (
<iframe
ref={iframeRef}
src={`${payslipUrl}#toolbar=0&navpanes=0&scrollbar=1&view=FitH`}
style={{
width: '100%',
height: '100%',
minHeight: '600px',
border: 'none',
borderRadius: '12px',
background: '#0a0a0a',
boxShadow: '0 2px 12px rgba(0,0,0,0.3)',
}}
title="PDF Preview"
onError={() => {
setPdfLoading(false);
setPdfError('Не удалось загрузить PDF');
}}
/>
)}
{pdfError && (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '15px',
padding: '40px',
textAlign: 'center',
}}>
<div style={{ fontSize: '64px' }}>📄</div>
<div style={{ fontSize: '18px', color: '#e74c3c', fontWeight: 500 }}>
{pdfError}
</div>
<button
onClick={() => {
setPdfError(null);
setPdfLoading(true);
handlePreviewPdf();
}}
style={{
padding: '10px 30px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
border: 'none',
borderRadius: '10px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 500,
transition: 'all 0.3s ease',
}}
>
🔄 Попробовать снова
</button>
</div>
)}
</div>
<div style={{
padding: '12px 24px',
background: 'rgba(255,255,255,0.05)',
borderTop: '1px solid rgba(255,255,255,0.05)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexShrink: 0,
fontSize: '13px',
color: 'rgba(255,255,255,0.5)',
}}>
<div>
👤 {employee?.famaly} {employee?.ima}
</div>
<div>
📋 Табельный номер: <strong style={{ color: 'rgba(255,255,255,0.7)' }}>{employee?.tnom}</strong>
</div>
<div>
🏢 {employee?.organization}
</div>
</div>
</motion.div>
</motion.div>
)}
</>
);
};
+667 -119
View File
@@ -1,20 +1,30 @@
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import React, { useState, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { FaArrowLeft, FaSpinner, FaEye, FaPrint, FaTimes, FaExpand, FaCompress, FaCheckCircle, FaExclamationCircle } from 'react-icons/fa';
import config from '../config.json';
import { fileURLToPath } from 'node:url';
interface FinanceProps {
onBack: () => void;
onViewPayslip: (period: string) => void;
onViewPayslip: (fileUrl: string) => void;
employee: any;
}
interface FinancialIndicator {
title: string;
value: string;
change?: number;
unit?: string;
}
const Finance: React.FC<FinanceProps> = ({ onBack, onViewPayslip, employee }) => {
const [hasPayslip, setHasPayslip] = useState<boolean | null>(null);
const [loading, setLoading] = useState(true);
const [payslipUrl, setPayslipUrl] = useState<string | null>(null);
const [showPdfPreview, setShowPdfPreview] = useState(false);
const [pdfLoading, setPdfLoading] = useState(false);
const [pdfError, setPdfError] = useState<string | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [isPrinting, setIsPrinting] = useState(false);
const [printStatus, setPrintStatus] = useState<'idle' | 'success' | 'error' | 'sending'>('idle');
const [printMessage, setPrintMessage] = useState('');
const iframeRef = useRef<HTMLIFrameElement>(null);
const [selectedPeriod, setSelectedPeriod] = useState(() => {
const now = new Date();
const year = now.getFullYear();
@@ -22,13 +32,6 @@ const Finance: React.FC<FinanceProps> = ({ onBack, onViewPayslip, employee }) =>
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();
@@ -40,122 +43,667 @@ const Finance: React.FC<FinanceProps> = ({ onBack, onViewPayslip, employee }) =>
}
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}`;
useEffect(() => {
const checkPayslip = async () => {
if (!employee?.tnom) {
setLoading(false);
return;
}
try {
setLoading(true);
const backendUrl = config.backendUrl || 'http://localhost:3001';
const response = await fetch(`${backendUrl}/api/payslip/url/${employee.tnom}?period=${selectedPeriod}`);
if (response.ok) {
const data = await response.json();
setHasPayslip(data.exists);
if (data.exists && data.url) {
setPayslipUrl(data.url);
}
} else {
setHasPayslip(false);
}
} catch (error) {
console.error('Ошибка проверки расчетного листка:', error);
setHasPayslip(false);
} finally {
setLoading(false);
}
};
checkPayslip();
}, [employee, selectedPeriod]);
const handlePreviewPdf = () => {
if (payslipUrl) {
setShowPdfPreview(true);
setPdfLoading(true);
setPdfError(null);
setIsFullscreen(false);
}
};
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>
const handleClosePreview = () => {
setShowPdfPreview(false);
setPdfLoading(false);
setPdfError(null);
setIsFullscreen(false);
};
<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>
const handlePrint = async () => {
try {
setIsPrinting(true);
setPrintStatus('sending');
setPrintMessage('Отправка документа на печать...');
const urlToPrint = payslipUrl; // payslipUrl для Employee/Finance, fileUrl для Viewer
if (!urlToPrint) throw new Error('URL документа отсутствует');
const fullUrl = urlToPrint.startsWith('http') ? urlToPrint : `http://localhost:3001${urlToPrint}`;
if ((window as any).electronAPI && (window as any).electronAPI.printPdf) {
console.log('🖨️ [PRINT] Печатаем тихо через Electron API');
await (window as any).electronAPI.printPdf({
pdfUrl: fullUrl,
deviceName: '' // <-- Оставляем пустым! Electron сам плюнет на дефолтный принтер
});
setPrintStatus('success');
setPrintMessage('✅ Отправлено на принтер');
}
// 🌐 ВАРИАНТ 2: Мы в браузере (Chrome/Edge)
else {
console.log('🖨️ [PRINT] Electron API нет. Обходим CORS и печатаем через браузер');
// Скачиваем PDF как Blob, чтобы сделать его "своим" для браузера
const response = await fetch(fullUrl);
if (!response.ok) throw new Error('Не удалось скачать PDF с сервера');
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
// Создаем невидимый iframe
const printIframe = document.createElement('iframe');
printIframe.style.display = 'none';
printIframe.src = blobUrl;
document.body.appendChild(printIframe);
printIframe.onload = () => {
try {
printIframe.contentWindow?.focus();
printIframe.contentWindow?.print();
setPrintStatus('success');
setPrintMessage('✅ Документ отправлен на печать');
} catch (e) {
console.error('Ошибка iframe печати:', e);
throw new Error('Браузер заблокировал печать');
} finally {
// Чистим за собой мусор через 5 секунд
setTimeout(() => {
document.body.removeChild(printIframe);
URL.revokeObjectURL(blobUrl);
}, 5000);
}
};
}
} catch (error) {
console.error('❌ [PRINT] Ошибка печати:', error);
setPrintStatus('error');
setPrintMessage('❌ Ошибка: ' + (error instanceof Error ? error.message : 'Неизвестная ошибка'));
} finally {
setIsPrinting(false);
setTimeout(() => {
setPrintStatus('idle');
setPrintMessage('');
}, 3000);
}
};
const toggleFullscreen = () => {
setIsFullscreen(!isFullscreen);
};
const getFullUrl = (url: string) => {
return url.startsWith('http') ? url : `http://localhost:3001${url}`;
};
const financialIndicators = [
{ title: 'Средняя ЗП по предприятию', value: '1 850', unit: 'руб.' },
{ title: 'Премия по итогам месяца', value: '15', unit: '%' },
{ title: 'Материальная помощь к отпуску', value: '500', unit: 'руб.' },
{ title: 'Выплаты к Новому году', value: '300', unit: 'руб.' },
];
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={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '25px', flexWrap: 'wrap', gap: '10px' }}>
<button
onClick={onBack}
style={{
padding: '10px 20px',
background: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '14px',
transition: 'all 0.3s ease',
}}
>
← Назад
</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 }}>
📊 Финансово-экономические показатели
</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '15px' }}>
{financialIndicators.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>
</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 }}>
📄 Расчетный листок
</h3>
<div style={{
display: 'flex',
gap: '15px',
flexWrap: 'wrap',
alignItems: 'center',
marginBottom: '20px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<label style={{ fontSize: '14px', color: '#636e72' }}>
Выберите период:
</label>
<select
value={selectedPeriod}
onChange={(e) => setSelectedPeriod(e.target.value)}
style={{
padding: '8px 14px',
borderRadius: '8px',
border: '1px solid #dfe6e9',
fontSize: '14px',
background: 'white',
cursor: 'pointer',
}}
>
{periods.map(period => {
const month = parseInt(period.substring(0, 2)) - 1;
const year = period.substring(2);
return (
<option key={period} value={period}>
{monthNames[month]} {year}
</option>
);
})}
</select>
</div>
</div>
{loading ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '30px', gap: '12px' }}>
<FaSpinner style={{ animation: 'spin 1s linear infinite', fontSize: '24px' }} />
<span>Проверка наличия расчетного листка...</span>
</div>
) : hasPayslip ? (
<div style={{ display: 'flex', gap: '15px', flexWrap: 'wrap', alignItems: 'center' }}>
<div style={{
flex: 1,
padding: '16px 20px',
background: '#f0fdf4',
borderRadius: '10px',
border: '1px solid #bbf7d0',
}}>
<div style={{ fontSize: '13px', color: '#166534', marginBottom: '4px' }}>
✅ Расчетный листок доступен
</div>
<div style={{ fontSize: '14px', color: '#14532d' }}>
Табельный номер: <strong>{employee?.tnom}</strong>
</div>
</div>
<button
onClick={handlePreviewPdf}
style={{
padding: '12px 30px',
background: 'linear-gradient(135deg, #00b894, #00cec9)',
color: 'white',
border: 'none',
borderRadius: '10px',
fontSize: '15px',
fontWeight: 500,
cursor: 'pointer',
transition: 'all 0.3s ease',
boxShadow: '0 4px 15px rgba(0, 206, 201, 0.3)',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<FaEye />
Предпросмотр
</button>
</div>
) : (
<div style={{
padding: '16px 20px',
background: '#fef2f2',
borderRadius: '10px',
border: '1px solid #fecaca',
color: '#991b1b',
}}>
<div style={{ fontSize: '13px', marginBottom: '4px' }}>
❌ Расчетный листок не найден
</div>
<div style={{ fontSize: '13px', opacity: 0.8 }}>
Для табельного номера <strong>{employee?.tnom}</strong> файл отсутствует.
{employee?.tnom && ` Файл должен называться: ${employee.tnom}.pdf`}
</div>
</div>
)}
</motion.div>
<style>{`
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
</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>}
<AnimatePresence>
{printStatus !== 'idle' && (
<motion.div
initial={{ opacity: 0, y: -50, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -50, scale: 0.9 }}
style={{
position: 'fixed',
top: '20px',
left: '50%',
transform: 'translateX(-50%)',
zIndex: 99999,
padding: '16px 32px',
borderRadius: '12px',
background: printStatus === 'success' ? '#10b981' :
printStatus === 'error' ? '#ef4444' :
'#f59e0b',
color: 'white',
boxShadow: '0 10px 40px rgba(0,0,0,0.2)',
display: 'flex',
alignItems: 'center',
gap: '12px',
fontSize: '16px',
fontWeight: 500,
minWidth: '300px',
justifyContent: 'center',
}}
>
{printStatus === 'sending' && <FaSpinner style={{ animation: 'spin 1s linear infinite' }} />}
{printStatus === 'success' && <FaCheckCircle />}
{printStatus === 'error' && <FaExclamationCircle />}
{printMessage}
</motion.div>
)}
</AnimatePresence>
{showPdfPreview && payslipUrl && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.85)',
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
zIndex: 9999,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px',
}}
onClick={handleClosePreview}
>
<motion.div
initial={{ scale: 0.9, y: 30, opacity: 0 }}
animate={{ scale: 1, y: 0, opacity: 1 }}
exit={{ scale: 0.9, y: 30, opacity: 0 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
style={{
background: '#1a1a2e',
borderRadius: '24px',
width: '100%',
maxWidth: isFullscreen ? '100%' : '900px',
height: isFullscreen ? '100%' : '90vh',
maxHeight: isFullscreen ? '100%' : '90vh',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
boxShadow: '0 50px 100px rgba(0,0,0,0.5)',
position: 'relative',
}}
onClick={(e) => e.stopPropagation()}
>
<div style={{
padding: '16px 24px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexShrink: 0,
minHeight: '60px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ fontSize: '20px' }}>📄</span>
<span style={{ color: 'white', fontSize: '16px', fontWeight: 500 }}>
Расчетный листок
</span>
<span style={{
color: 'rgba(255,255,255,0.5)',
fontSize: '12px',
background: 'rgba(255,255,255,0.15)',
padding: '2px 10px',
borderRadius: '12px',
}}>
{employee?.tnom}
</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 style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<button
onClick={handlePrint}
disabled={isPrinting}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '8px 16px',
background: isPrinting ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.2)',
color: 'white',
border: '1px solid rgba(255,255,255,0.3)',
borderRadius: '10px',
cursor: isPrinting ? 'not-allowed' : 'pointer',
fontSize: '14px',
fontWeight: 500,
transition: 'all 0.3s ease',
backdropFilter: 'blur(4px)',
opacity: isPrinting ? 0.5 : 1,
}}
>
{isPrinting ? <FaSpinner style={{ animation: 'spin 1s linear infinite' }} /> : <FaPrint size={16} />}
{isPrinting ? 'Отправка...' : 'Печать'}
</button>
<button
onClick={toggleFullscreen}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '36px',
height: '36px',
background: 'rgba(255,255,255,0.2)',
color: 'white',
border: '1px solid rgba(255,255,255,0.3)',
borderRadius: '10px',
cursor: 'pointer',
}}
>
{isFullscreen ? <FaCompress size={16} /> : <FaExpand size={16} />}
</button>
<button
onClick={handleClosePreview}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '36px',
height: '36px',
background: 'rgba(255,255,255,0.2)',
color: 'white',
border: '1px solid rgba(255,255,255,0.3)',
borderRadius: '10px',
cursor: 'pointer',
}}
>
<FaTimes size={18} />
</button>
</div>
</div>
<div style={{
flex: 1,
padding: '20px',
overflow: 'auto',
background: '#0a0a0a',
position: 'relative',
WebkitOverflowScrolling: 'touch',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
{pdfLoading && (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '20px',
}}>
<div style={{
width: '60px',
height: '60px',
border: '4px solid #2d2d2d',
borderTop: '4px solid #667eea',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
}} />
<span style={{ color: '#888', fontSize: '16px' }}>
Загрузка документа...
</span>
</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>
<iframe
ref={iframeRef}
src={`${getFullUrl(payslipUrl)}#toolbar=0&navpanes=0&scrollbar=1&view=FitH`}
style={{
width: '100%',
height: '100%',
minHeight: '600px',
border: 'none',
borderRadius: '12px',
background: '#0a0a0a',
display: pdfLoading ? 'none' : 'block',
boxShadow: '0 2px 12px rgba(0,0,0,0.3)',
}}
title="PDF Preview"
onLoad={() => setPdfLoading(false)}
onError={() => {
setPdfLoading(false);
setPdfError('Не удалось загрузить PDF');
}}
/>
{pdfError && (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '15px',
padding: '40px',
textAlign: 'center',
}}>
<div style={{ fontSize: '64px' }}>📄</div>
<div style={{ fontSize: '18px', color: '#e74c3c', fontWeight: 500 }}>
{pdfError}
</div>
<button
onClick={() => {
setPdfError(null);
setPdfLoading(true);
const iframe = document.querySelector('iframe[title="PDF Preview"]') as HTMLIFrameElement;
if (iframe) {
iframe.src = `${getFullUrl(payslipUrl)}#toolbar=0&navpanes=0&scrollbar=1&view=FitH`;
}
}}
style={{
padding: '10px 30px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
border: 'none',
borderRadius: '10px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 500,
}}
>
🔄 Попробовать снова
</button>
</div>
)}
</div>
<div style={{
padding: '12px 24px',
background: 'rgba(255,255,255,0.05)',
borderTop: '1px solid rgba(255,255,255,0.05)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexShrink: 0,
fontSize: '13px',
color: 'rgba(255,255,255,0.5)',
}}>
<div>
👤 {employee?.famaly} {employee?.ima}
</div>
<div>
📋 Табельный номер: <strong style={{ color: 'rgba(255,255,255,0.7)' }}>{employee?.tnom}</strong>
</div>
<div>
🏢 {employee?.organization}
</div>
</div>
</motion.div>
</motion.div>
)}
</>
);
};
+218 -72
View File
@@ -9,7 +9,7 @@ const MainMenu: React.FC<MainMenuProps> = ({ onSectionSelect, employee }) => {
console.log('🔵 MainMenu компонент рендерится!');
const menuItems = [
{ id: 'employee-info', emoji: '📋' }
{ id: 'employee-info', emoji: '📋', description: 'Просмотр и печать' }
];
const menuItems_2 = [
@@ -29,97 +29,173 @@ const MainMenu: React.FC<MainMenuProps> = ({ onSectionSelect, employee }) => {
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '10px',
marginTop: '-5vh',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: '20px 20px',
marginTop: '-20px',
position: 'relative',
overflow: 'hidden',
backgroundImage: 'url(/assets/images/background.jpg)',
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}}>
{employee && (
<div style={{
color: '#fff',
fontSize: '1.5rem',
marginBottom: '10px',
}}>
Приветствуем Вас, {employee.ima} {employee.famaly}
</div>
)}
<h1 style={{
color: '#fff',
fontSize: '2.5rem',
marginBottom: '40px',
fontWeight: 300,
}}>
Выберите раздел
</h1>
{/* ✅ Полупрозрачный оверлей для читаемости текста
<div style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(232, 240, 254, 0.2)',
backdropFilter: 'blur(4px)',
WebkitBackdropFilter: 'blur(4px)',
zIndex: 0,
}} /> */}
{/* ✅ Декоративные голубые размытые круги (поверх оверлея) */}
<div style={{
position: 'absolute',
top: '-25%',
right: '-10%',
width: '55%',
height: '70%',
background: 'radial-gradient(ellipse, rgba(66, 133, 244, 0.08) 0%, rgba(66, 133, 244, 0.02) 60%, transparent 80%)',
borderRadius: '50%',
pointerEvents: 'none',
zIndex: 0,
}} />
<div style={{
position: 'absolute',
bottom: '-20%',
left: '-15%',
width: '50%',
height: '60%',
background: 'radial-gradient(ellipse, rgba(52, 152, 219, 0.06) 0%, transparent 70%)',
borderRadius: '50%',
pointerEvents: 'none',
zIndex: 0,
}} />
<div style={{
position: 'absolute',
top: '30%',
left: '25%',
width: '40%',
height: '50%',
background: 'radial-gradient(ellipse, rgba(41, 128, 185, 0.04) 0%, transparent 70%)',
borderRadius: '50%',
pointerEvents: 'none',
zIndex: 0,
}} />
{/* ✅ КНОПКА "РАСЧЕТНЫЙ ЛИСТ" */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 870px))',
gap: '25px',
maxWidth: '900px',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 900px))',
gap: '30px',
maxWidth: '960px',
width: '100%',
//height: '100%',
justifyContent: 'center',
position: 'relative',
zIndex: 1,
marginBottom: '20px',
}}>
{menuItems.map((item) => (
<button
key={item.id}
onClick={() => onSectionSelect(item.id)}
style={{
background: 'linear-gradient(135deg, #1b718b 0%, #7ec87b 100%)',
borderRadius: '16px',
padding: '25px 30px',
marginBottom: '25px',
background: 'linear-gradient(145deg, rgba(255,255,255,0.92) 0%, rgba(235, 245, 255, 0.92) 100%)',
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
borderRadius: '28px',
padding: '58px 36px',
cursor: 'pointer',
transition: 'all 0.3s ease',
boxShadow: '0 4px 20px rgba(198, 231, 212, 0.25)',
display: 'inline-flex',
transition: 'all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)',
boxShadow: '0 8px 32px rgba(66, 133, 244, 0.10), 0 2px 8px rgba(66, 133, 244, 0.04), inset 0 1px 0 rgba(255,255,255,0.9)',
display: 'flex',
justifyContent: 'flex-start',
alignItems: 'center',
minHeight: '80px',
fontSize: '6.0rem',
minHeight: '110px',
gap: '20px',
border: '1px solid rgba(255,255,255,0.5)',
width: '100%',
position: 'relative',
overflow: 'hidden',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.05)';
e.currentTarget.style.boxShadow = '0 12px 40px rgba(0,0,0,0.2)';
e.currentTarget.style.transform = 'translateY(-4px) scale(1.01)';
e.currentTarget.style.boxShadow = '0 24px 64px rgba(66, 133, 244, 0.18), 0 8px 24px rgba(66, 133, 244, 0.08), inset 0 1px 0 rgba(255,255,255,0.95)';
e.currentTarget.style.borderColor = 'rgba(66, 133, 244, 0.2)';
e.currentTarget.style.background = 'linear-gradient(145deg, rgba(255,255,255,0.96) 0%, rgba(235, 245, 255, 0.96) 100%)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.boxShadow = '0 8px 30px rgba(0,0,0,0.12)';
e.currentTarget.style.transform = 'translateY(0) scale(1)';
e.currentTarget.style.boxShadow = '0 8px 32px rgba(66, 133, 244, 0.10), 0 2px 8px rgba(66, 133, 244, 0.04), inset 0 1px 0 rgba(255,255,255,0.9)';
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.5)';
e.currentTarget.style.background = 'linear-gradient(145deg, rgba(255,255,255,0.92) 0%, rgba(235, 245, 255, 0.92) 100%)';
}}
>
<div>{item.emoji}</div>
<span style={{
fontSize: '1rem',
fontWeight: 500,
color: '#2d3436',
textAlign: 'center',
}}>
<div>
<div style={{
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: '6px',
background: 'linear-gradient(180deg, #4285f4 0%, #5b9bd5 50%, #7cb9e8 100%)',
borderRadius: '0 3px 3px 0',
opacity: 0.7,
}} />
<div style={{
fontSize: '4.5rem',
marginLeft: '10px',
filter: 'drop-shadow(0 4px 12px rgba(66, 133, 244, 0.08))',
}}>{item.emoji}</div>
<div style={{ flex: 1, textAlign: 'left' }}>
<h3 style={{
color: 'white',
fontSize: '25px',
color: '#1a3a5c',
fontSize: '32px',
fontWeight: 600,
margin: 0,
letterSpacing: '-0.3px',
}}>
Расчетный лист
</h3>
<span style={{
fontSize: '16px',
color: '#6a8fb1',
fontWeight: 400,
letterSpacing: '0.2px',
}}>
{item.description}
</span>
</div>
<div style={{
fontSize: '20px',
color: 'rgba(66, 133, 244, 0.3)',
transition: 'all 0.3s ease',
padding: '8px 14px',
borderRadius: '12px',
background: 'rgba(66, 133, 244, 0.05)',
}}>
→
</div>
</span>
</button>
))}
</div>
{/* ✅ СЕТКА КНОПОК */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 200px))',
gap: '15px',
maxWidth: '900px',
gridTemplateColumns: 'repeat(auto-fit, minmax(170px, 210px))',
gap: '22px',
maxWidth: '960px',
width: '100%',
//height: '100%',
justifyContent: 'center',
marginTop: '15px'
position: 'relative',
zIndex: 1,
}}>
{menuItems_2.map((item) => (
<button
key={item.id}
@@ -129,38 +205,108 @@ const MainMenu: React.FC<MainMenuProps> = ({ onSectionSelect, employee }) => {
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '12px',
padding: '5px 5px',
background: 'rgba(255,255,255,0.95)',
border: 'none',
borderRadius: '16px',
gap: '14px',
padding: '30px 16px 26px',
background: 'rgba(255,255,255,0.85)',
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.5)',
borderRadius: '24px',
cursor: 'pointer',
boxShadow: '0 8px 30px rgba(0,0,0,0.12)',
minHeight: '140px',
fontSize: '3.5rem',
transition: 'all 0.3s ease',
boxShadow: '0 6px 24px rgba(66, 133, 244, 0.06), 0 2px 8px rgba(66, 133, 244, 0.03), inset 0 1px 0 rgba(255,255,255,0.9)',
minHeight: '190px',
fontSize: '4rem',
transition: 'all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)',
position: 'relative',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.05)';
e.currentTarget.style.boxShadow = '0 12px 40px rgba(0,0,0,0.2)';
e.currentTarget.style.transform = 'translateY(-6px) scale(1.02)';
e.currentTarget.style.boxShadow = '0 24px 64px rgba(66, 133, 244, 0.12), 0 8px 24px rgba(66, 133, 244, 0.06), inset 0 1px 0 rgba(255,255,255,0.95)';
e.currentTarget.style.borderColor = 'rgba(66, 133, 244, 0.15)';
e.currentTarget.style.background = 'rgba(255,255,255,0.92)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.boxShadow = '0 8px 30px rgba(0,0,0,0.12)';
e.currentTarget.style.transform = 'translateY(0) scale(1)';
e.currentTarget.style.boxShadow = '0 6px 24px rgba(66, 133, 244, 0.06), 0 2px 8px rgba(66, 133, 244, 0.03), inset 0 1px 0 rgba(255,255,255,0.9)';
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.5)';
e.currentTarget.style.background = 'rgba(255,255,255,0.85)';
}}
>
<div>{item.emoji}</div>
<div style={{
position: 'absolute',
top: 0,
left: '15%',
right: '15%',
height: '3px',
background: 'linear-gradient(90deg, transparent, rgba(66, 133, 244, 0.15), transparent)',
borderRadius: '0 0 3px 3px',
transition: 'all 0.4s ease',
}} />
<div style={{
fontSize: '4.2rem',
filter: 'drop-shadow(0 4px 12px rgba(66, 133, 244, 0.04))',
transition: 'all 0.3s ease',
marginBottom: '2px',
}}>
{item.emoji}
</div>
<span style={{
fontSize: '1rem',
display: 'block',
whiteSpace: 'normal',
wordWrap: 'break-word',
fontSize: '18px',
fontWeight: 500,
color: '#2d3436',
color: '#2a4a6a',
textAlign: 'center',
letterSpacing: '0.2px',
lineHeight: 1.3,
maxWidth: '100%',
}}>
{item.title}
</span>
<div style={{
position: 'absolute',
bottom: '8px',
left: '25%',
right: '25%',
height: '2px',
background: 'linear-gradient(90deg, transparent, rgba(66, 133, 244, 0.2), transparent)',
borderRadius: '2px',
opacity: 0,
transition: 'all 0.4s ease',
}}
onMouseEnter={(e) => {
const indicator = e.currentTarget.parentElement?.querySelector('div:last-child');
if (indicator) {
(indicator as HTMLElement).style.opacity = '1';
(indicator as HTMLElement).style.background = 'linear-gradient(90deg, transparent, rgba(66, 133, 244, 0.3), transparent)';
}
}}
onMouseLeave={(e) => {
const indicator = e.currentTarget.parentElement?.querySelector('div:last-child');
if (indicator) {
(indicator as HTMLElement).style.opacity = '0';
(indicator as HTMLElement).style.background = 'linear-gradient(90deg, transparent, rgba(66, 133, 244, 0.2), transparent)';
}
}} />
</button>
))}
</div>
<div style={{
marginTop: '30px',
fontSize: '13px',
color: 'rgba(66, 133, 244, 0.08)',
letterSpacing: '2px',
fontWeight: 300,
position: 'relative',
zIndex: 1,
}}>
⋆ ﹒ ˚ ﹒ ⋆ ﹒ ˚ ﹒ ⋆
</div>
</div>
);
};
+219 -30
View File
@@ -1,9 +1,6 @@
import React from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
// ИСПОЛЬЗОВАТЬ CDN - ЭТО РАБОТАЕТ
pdfjs.GlobalWorkerOptions.workerSrc =
'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
import React, { useRef, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { FaArrowLeft, FaPrint, FaSpinner, FaCheckCircle, FaExclamationCircle } from 'react-icons/fa';
interface PayslipViewerProps {
fileUrl: string;
@@ -11,36 +8,228 @@ interface PayslipViewerProps {
}
const PayslipViewer: React.FC<PayslipViewerProps> = ({ fileUrl, onBack }) => {
const handlePrint = () => {
const win = window.open(fileUrl, '_blank');
if (win) {
win.focus();
win.print();
const iframeRef = useRef<HTMLIFrameElement>(null);
const fullUrl = fileUrl.startsWith('http') ? fileUrl : `http://localhost:3001${fileUrl}`;
const [isPrinting, setIsPrinting] = useState(false);
const [printStatus, setPrintStatus] = useState<'idle' | 'success' | 'error' | 'sending'>('idle');
const [printMessage, setPrintMessage] = useState('');
// ✅ ИСПРАВЛЕННЫЙ МЕТОД ПЕЧАТИ
const handlePrint = async () => {
try {
setIsPrinting(true);
setPrintStatus('sending');
setPrintMessage('Отправка документа на печать...');
const urlToPrint = fileUrl; // payslipUrl для Employee/Finance, fileUrl для Viewer
if (!urlToPrint) throw new Error('URL документа отсутствует');
const fullUrl = urlToPrint.startsWith('http') ? urlToPrint : `http://localhost:3001${urlToPrint}`;
// 🚀 ВАРИАНТ 1: Мы в десктопе (Electron)
if ((window as any).electronAPI && (window as any).electronAPI.printPdf) {
console.log('🖨️ [PRINT] Печатаем тихо через Electron API');
await (window as any).electronAPI.printPdf({
pdfUrl: fullUrl,
deviceName: '' // <-- Оставляем пустым! Electron сам плюнет на дефолтный принтер
});
setPrintStatus('success');
setPrintMessage('✅ Отправлено на принтер');
}
// 🌐 ВАРИАНТ 2: Мы в браузере (Chrome/Edge)
else {
console.log('🖨️ [PRINT] Electron API нет. Обходим CORS и печатаем через браузер');
// Скачиваем PDF как Blob, чтобы сделать его "своим" для браузера
const response = await fetch(fullUrl);
if (!response.ok) throw new Error('Не удалось скачать PDF с сервера');
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
// Создаем невидимый iframe
const printIframe = document.createElement('iframe');
printIframe.style.display = 'none';
printIframe.src = blobUrl;
document.body.appendChild(printIframe);
printIframe.onload = () => {
try {
printIframe.contentWindow?.focus();
printIframe.contentWindow?.print();
setPrintStatus('success');
setPrintMessage('✅ Документ отправлен на печать');
} catch (e) {
console.error('Ошибка iframe печати:', e);
throw new Error('Браузер заблокировал печать');
} finally {
// Чистим за собой мусор через 5 секунд
setTimeout(() => {
document.body.removeChild(printIframe);
URL.revokeObjectURL(blobUrl);
}, 5000);
}
};
}
} catch (error) {
console.error('❌ [PRINT] Ошибка печати:', error);
setPrintStatus('error');
setPrintMessage('❌ Ошибка: ' + (error instanceof Error ? error.message : 'Неизвестная ошибка'));
} finally {
setIsPrinting(false);
setTimeout(() => {
setPrintStatus('idle');
setPrintMessage('');
}, 3000);
}
};
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={{
<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',
height: 'calc(100vh - 80px)',
display: 'flex',
justifyContent: 'center',
width: '100%'
flexDirection: 'column',
background: '#f5f6fa',
position: 'relative',
}}
>
<AnimatePresence>
{printStatus !== 'idle' && (
<motion.div
initial={{ opacity: 0, y: -50, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -50, scale: 0.9 }}
style={{
position: 'fixed',
top: '20px',
left: '50%',
transform: 'translateX(-50%)',
zIndex: 9999,
padding: '16px 32px',
borderRadius: '12px',
background: printStatus === 'success' ? '#10b981' :
printStatus === 'error' ? '#ef4444' :
'#f59e0b',
color: 'white',
boxShadow: '0 10px 40px rgba(0,0,0,0.2)',
display: 'flex',
alignItems: 'center',
gap: '12px',
fontSize: '16px',
fontWeight: 500,
minWidth: '300px',
justifyContent: 'center',
}}
>
{printStatus === 'sending' && <FaSpinner style={{ animation: 'spin 1s linear infinite' }} />}
{printStatus === 'success' && <FaCheckCircle />}
{printStatus === 'error' && <FaExclamationCircle />}
{printMessage}
</motion.div>
)}
</AnimatePresence>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '20px',
flexShrink: 0,
padding: '0 4px',
}}>
<Document file={fileUrl} loading="Загрузка PDF...">
<div style={{ margin: '0 auto' }}>
<Page pageNumber={1} />
</div>
</Document>
<button
onClick={onBack}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '10px 20px',
background: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '14px',
transition: 'all 0.3s ease',
}}
>
<FaArrowLeft />
Назад
</button>
<h2 style={{
margin: 0,
color: '#2d3436',
fontSize: '22px',
fontWeight: 500,
}}>
📄 Расчетный листок
</h2>
<button
onClick={handlePrint}
disabled={isPrinting}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '10px 30px',
background: isPrinting ? '#b2bec3' : 'linear-gradient(135deg, #00b894, #00cec9)',
color: 'white',
border: 'none',
borderRadius: '10px',
cursor: isPrinting ? 'not-allowed' : 'pointer',
fontSize: '15px',
fontWeight: 500,
transition: 'all 0.3s ease',
boxShadow: isPrinting ? 'none' : '0 4px 15px rgba(0, 206, 201, 0.3)',
opacity: isPrinting ? 0.6 : 1,
}}
>
{isPrinting ? <FaSpinner style={{ animation: 'spin 1s linear infinite' }} /> : <FaPrint />}
{isPrinting ? 'Отправка...' : 'Печать'}
</button>
</div>
</div>
<div style={{
flex: 1,
background: '#f5f6fa',
borderRadius: '12px',
overflow: 'hidden',
position: 'relative',
minHeight: 0,
border: 'none',
}}>
<iframe
ref={iframeRef}
src={`${fullUrl}#toolbar=0&navpanes=0&scrollbar=1&view=FitH`}
style={{
width: '100%',
height: '100%',
border: 'none',
display: 'block',
background: '#f5f6fa',
}}
title="PDF Viewer"
/>
</div>
<style>{`
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
</motion.div>
);
};
+399 -21
View File
@@ -1,49 +1,197 @@
import React, { useEffect, useRef } from 'react';
import { motion } from 'framer-motion';
import React, { useEffect, useRef, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
interface VideoBackgroundProps {
onComplete: () => void;
videoSrc?: string;
}
interface Slide {
id: number;
image_url: string;
title?: string;
description?: string;
link?: string;
order: number;
is_active: number;
created_at?: string;
updated_at?: string;
}
const VideoBackground: React.FC<VideoBackgroundProps> = ({
onComplete,
videoSrc = '/assets/videos/flag.mp4'
}) => {
const videoRef = useRef<HTMLVideoElement>(null);
const [slides, setSlides] = useState<Slide[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [loading, setLoading] = useState(true);
const [isAutoPlay, setIsAutoPlay] = useState(true);
const autoPlayInterval = useRef<NodeJS.Timeout | null>(null);
const ADMIN_SERVER_URL = 'http://localhost:3005';
// ✅ Загрузка слайдов с сервера модератора
useEffect(() => {
const fetchSlides = async () => {
try {
setLoading(true);
const response = await fetch(`${ADMIN_SERVER_URL}/moders/content/slides`, {
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
const sortedSlides = Array.isArray(data)
? data.sort((a: Slide, b: Slide) => (a.order || 0) - (b.order || 0))
: [];
setSlides(sortedSlides);
} else {
console.warn('Не удалось загрузить слайды, используем тестовые');
setSlides(getDefaultSlides());
}
} catch (error) {
console.warn('Ошибка загрузки слайдов:', error);
setSlides(getDefaultSlides());
} finally {
setLoading(false);
}
};
fetchSlides();
}, []);
// ✅ Тестовые слайды (если модератор не настроен)
const getDefaultSlides = (): Slide[] => {
return [
{
id: 1,
image_url: 'https://picsum.photos/1920/1080?random=1',
title: 'Добро пожаловать!',
description: 'Инфокиоск для сотрудников',
order: 1,
is_active: 1
},
{
id: 2,
image_url: 'https://picsum.photos/1920/1080?random=2',
title: 'Новости предприятия',
description: 'Будьте в курсе событий',
order: 2,
is_active: 1
},
{
id: 3,
image_url: 'https://picsum.photos/1920/1080?random=3',
title: 'С днем рождения!',
description: 'Поздравляем именинников',
order: 3,
is_active: 1
},
];
};
// ✅ Видео плеер
useEffect(() => {
if (videoRef.current) {
videoRef.current.play().catch(err => {
console.warn('Видео не воспроизвелось автоматически:', err);
// Если видео не запустилось — показываем экран логина через 5 секунд
setTimeout(onComplete, 5000);
});
}
// ❌ Убираем таймер, который закрывал видео через 30 секунд
// Теперь видео будет крутиться бесконечно (loop=true)
// Только клик или касание пользователя завершают видео
return () => {
// Очистка не нужна, так как таймера больше нет
};
}, [onComplete]);
// ❌ Убираем handleVideoEnd — не нужен, так как loop=true
// ✅ Автопрокрутка слайдов
useEffect(() => {
if (slides.length <= 1 || !isAutoPlay) return;
autoPlayInterval.current = setInterval(() => {
goToNext();
}, 5000);
return () => {
if (autoPlayInterval.current) {
clearInterval(autoPlayInterval.current);
}
};
}, [slides.length, isAutoPlay, currentIndex]);
// ✅ Навигация
const goToNext = () => {
setCurrentIndex((prev) => (prev + 1) % slides.length);
};
const goToPrev = () => {
setCurrentIndex((prev) => (prev - 1 + slides.length) % slides.length);
};
const goToSlide = (index: number) => {
setCurrentIndex(index);
resetAutoPlay();
};
const resetAutoPlay = () => {
if (autoPlayInterval.current) {
clearInterval(autoPlayInterval.current);
}
if (slides.length > 1) {
autoPlayInterval.current = setInterval(() => {
goToNext();
}, 5000);
}
};
const handleMouseEnter = () => {
setIsAutoPlay(false);
if (autoPlayInterval.current) {
clearInterval(autoPlayInterval.current);
}
};
const handleMouseLeave = () => {
setIsAutoPlay(true);
resetAutoPlay();
};
const handleClick = () => {
onComplete(); // По клику переходим дальше
onComplete();
};
if (loading) {
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 9999,
background: '#000',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<div style={{
width: '60px',
height: '60px',
border: '4px solid #2d2d2d',
borderTop: '4px solid #667eea',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
}} />
</div>
);
}
const hasSlides = slides.length > 0;
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,
@@ -56,34 +204,264 @@ const VideoBackground: React.FC<VideoBackgroundProps> = ({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
>
{/* ✅ ВИДЕО (фон) */}
<video
ref={videoRef}
src={videoSrc}
autoPlay
muted
playsInline
loop // ✅ Бесконечное воспроизведение
loop
style={{
position: 'absolute',
width: '100%',
height: '100%',
objectFit: 'cover',
zIndex: 0,
}}
/>
{/* ✅ ТЕМНЫЙ ОВЕРЛЕЙ */}
<div style={{
position: 'absolute',
bottom: '60px',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.35)',
zIndex: 1,
}} />
{/* ✅ КАРУСЕЛЬ ИЗОБРАЖЕНИЙ */}
{hasSlides && (
<div style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<AnimatePresence mode="wait">
<motion.div
key={currentIndex}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.5 }}
style={{
width: '80%',
maxWidth: '900px',
maxHeight: '70vh',
borderRadius: '20px',
overflow: 'hidden',
boxShadow: '0 30px 80px rgba(0,0,0,0.5)',
position: 'relative',
background: '#1a1a2e',
border: '1px solid rgba(255,255,255,0.05)',
}}
>
<img
src={`${ADMIN_SERVER_URL}${slides[currentIndex].image_url}`}
alt={slides[currentIndex].title || 'Слайд'}
crossOrigin="anonymous"
style={{
width: '100%',
height: '100%',
maxHeight: '70vh',
objectFit: 'cover',
display: 'block',
}}
onError={(e) => {
console.error('❌ Ошибка загрузки:', `${ADMIN_SERVER_URL}${slides[currentIndex].image_url}`);
(e.target as HTMLImageElement).src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="100%25" height="100%25"%3E%3Crect width="100%25" height="100%25" fill="%232d3436"/%3E%3Ctext x="50%25" y="50%25" text-anchor="middle" dy=".3em" fill="%23636e72" font-size="24" font-family="sans-serif"%3EИзображение не найдено%3C/text%3E%3C/svg%3E';
}}
/>
{/* ✅ Текст поверх изображения */}
{(slides[currentIndex].title || slides[currentIndex].description) && (
<div style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
padding: '30px 40px 40px',
background: 'linear-gradient(transparent, rgba(0,0,0,0.7))',
color: 'white',
}}>
{slides[currentIndex].title && (
<h2 style={{
margin: 0,
fontSize: '28px',
fontWeight: 600,
textShadow: '0 2px 10px rgba(0,0,0,0.3)',
}}>
{slides[currentIndex].title}
</h2>
)}
{slides[currentIndex].description && (
<p style={{
margin: '8px 0 0',
fontSize: '16px',
opacity: 0.8,
textShadow: '0 2px 8px rgba(0,0,0,0.3)',
}}>
{slides[currentIndex].description}
</p>
)}
</div>
)}
</motion.div>
</AnimatePresence>
{/* ✅ КНОПКИ НАВИГАЦИИ
{slides.length > 1 && (
<>
<button
onClick={(e) => {
e.stopPropagation();
goToPrev();
}}
style={{
position: 'absolute',
left: '20px',
top: '50%',
transform: 'translateY(-50%)',
width: '50px',
height: '50px',
borderRadius: '50%',
background: 'rgba(255,255,255,0.1)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255,255,255,0.15)',
color: 'white',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '20px',
transition: 'all 0.3s ease',
zIndex: 10,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.2)';
e.currentTarget.style.transform = 'translateY(-50%) scale(1.1)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.1)';
e.currentTarget.style.transform = 'translateY(-50%) scale(1)';
}}
>
<FaChevronLeft />
</button>
<button
onClick={(e) => {
e.stopPropagation();
goToNext();
}}
style={{
position: 'absolute',
right: '20px',
top: '50%',
transform: 'translateY(-50%)',
width: '50px',
height: '50px',
borderRadius: '50%',
background: 'rgba(255,255,255,0.1)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255,255,255,0.15)',
color: 'white',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '20px',
transition: 'all 0.3s ease',
zIndex: 10,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.2)';
e.currentTarget.style.transform = 'translateY(-50%) scale(1.1)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.1)';
e.currentTarget.style.transform = 'translateY(-50%) scale(1)';
}}
>
<FaChevronRight />
</button>
</>
)} */}
{/* ✅ ИНДИКАТОРЫ СЛАЙДОВ */}
{slides.length > 1 && (
<div style={{
position: 'absolute',
bottom: '30px',
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
gap: '10px',
zIndex: 10,
}}>
{slides.map((_, index) => (
<button
key={index}
onClick={(e) => {
e.stopPropagation();
goToSlide(index);
}}
style={{
width: index === currentIndex ? '12px' : '8px',
height: '8px',
borderRadius: '4px',
background: index === currentIndex
? 'rgba(255,255,255,0.9)'
: 'rgba(255,255,255,0.3)',
border: 'none',
cursor: 'pointer',
transition: 'all 0.3s ease',
padding: 0,
}}
/>
))}
</div>
)}
</div>
)}
{/* ✅ ПОДСКАЗКА */}
<div style={{
position: 'absolute',
bottom: '20px',
left: 0,
right: 0,
textAlign: 'center',
color: '#fff',
fontSize: '14px',
opacity: 0.6,
color: 'rgba(255,255,255,0.3)',
fontSize: '13px',
zIndex: 3,
textShadow: '0 2px 4px rgba(0,0,0,0.5)',
letterSpacing: '1px',
pointerEvents: 'none',
}}>
Нажмите для продолжения
{hasSlides ? 'Нажмите для входа · Для просмотра используйте стрелки' : 'Нажмите для продолжения'}
</div>
<style>{`
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
</motion.div>
);
};
+213 -142
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { motion } from 'framer-motion';
interface WeatherDay {
date: string;
@@ -12,9 +12,13 @@ interface WeatherDay {
interface WeatherWidgetProps {
onLogout?: () => void;
employee?: {
ima: string;
famaly: string;
};
}
const WeatherWidget: React.FC<WeatherWidgetProps> = ({ onLogout }) => {
const WeatherWidget: React.FC<WeatherWidgetProps> = ({ onLogout, employee }) => {
const [weatherData, setWeatherData] = useState<WeatherDay[]>([]);
const [currentWeather, setCurrentWeather] = useState<WeatherDay | null>(null);
const [isOnline, setIsOnline] = useState(navigator.onLine);
@@ -57,7 +61,6 @@ const WeatherWidget: React.FC<WeatherWidgetProps> = ({ onLogout }) => {
const response = await fetch(url);
if (!response.ok) {
// fallback
setCurrentWeather({
date: new Date().toISOString().split('T')[0],
dayOfWeek: 'Сегодня',
@@ -160,6 +163,20 @@ const WeatherWidget: React.FC<WeatherWidgetProps> = ({ onLogout }) => {
}
};
const forecastDays = weatherData.slice(1, 7);
const doubledForecast = [...forecastDays, ...forecastDays, ...forecastDays];
const getGreeting = () => {
if (!employee) return '';
const hour = currentTime.getHours();
let greeting = '';
if (hour >= 5 && hour < 12) greeting = 'Доброе утро';
else if (hour >= 12 && hour < 17) greeting = 'Добрый день';
else if (hour >= 17 && hour < 22) greeting = 'Добрый вечер';
else greeting = 'Доброй ночи';
return `${greeting}, ${employee.ima}`;
};
return (
<motion.div
initial={{ y: -100, opacity: 0 }}
@@ -171,37 +188,42 @@ const WeatherWidget: React.FC<WeatherWidgetProps> = ({ onLogout }) => {
left: 0,
right: 0,
zIndex: 9999,
background: 'rgba(44, 62, 80, 0.75)', // ✅ полупрозрачный
backdropFilter: 'blur(12px)',
padding: '5px 15px',
borderBottom: '1px solid rgba(255,255,255,0.08)',
background: 'linear-gradient(135deg, #1b718b 65%, #473f72 100%)',
backdropFilter: 'blur(16px)',
WebkitBackdropFilter: 'blur(16px)',
padding: '6px 20px',
borderBottom: '1px solid rgba(255,255,255,0.06)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
minHeight: '70px',
boxShadow: '0 2px 20px rgba(0,0,0,0.15)',
minHeight: '75px',
gap: '8px',
flexWrap: 'nowrap',
}}
>
{/* Левая часть: Время */}
{/* ===== ЛЕВАЯ ЧАСТЬ: ВРЕМЯ + ПОГОДА СЕГОДНЯ ===== */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
gap: '16px',
flexShrink: 0,
minWidth: '140px',
zIndex: 2,
}}>
{/* Время */}
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
minWidth: '90px',
}}>
<span style={{
fontSize: '30px',
fontSize: '38px',
fontWeight: 700,
color: '#fff',
letterSpacing: '0.5px',
lineHeight: 1,
lineHeight: 1.1,
fontVariantNumeric: 'tabular-nums',
textShadow: '0 2px 8px rgba(0,0,0,0.15)',
}}>
{currentTime.toLocaleString('ru-RU', {
timeZone: 'Europe/Minsk',
@@ -211,10 +233,11 @@ const WeatherWidget: React.FC<WeatherWidgetProps> = ({ onLogout }) => {
})}
</span>
<span style={{
fontSize: '12px',
color: 'rgba(255,255,255,0.5)',
fontSize: '11px',
color: 'rgba(255,255,255,0.4)',
letterSpacing: '0.3px',
fontWeight: 400,
textTransform: 'uppercase',
}}>
{currentTime.toLocaleString('ru-RU', {
timeZone: 'Europe/Minsk',
@@ -224,128 +247,180 @@ const WeatherWidget: React.FC<WeatherWidgetProps> = ({ onLogout }) => {
})}
</span>
</div>
{/* Разделитель */}
<div style={{
width: '1px',
height: '32px',
background: 'rgba(255,255,255,0.06)',
flexShrink: 0,
}} />
{/* Погода сегодня */}
{isOnline && !loading && currentWeather && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4 }}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
background: 'rgba(255,255,255,0.05)',
padding: '4px 16px 4px 12px',
borderRadius: '30px',
border: '1px solid rgba(255,255,255,0.04)',
flexShrink: 0,
}}
>
<span style={{ fontSize: '26px', lineHeight: 1 }}>
{getWeatherIcon(currentWeather.icon)}
</span>
<div style={{ display: 'flex', flexDirection: 'column', lineHeight: 1.2 }}>
<span style={{
fontSize: '18px',
fontWeight: 600,
color: '#fff',
}}>
{Math.round(currentWeather.tempMax)}°
</span>
<span style={{
fontSize: '10px',
color: 'rgba(255,255,255,0.35)',
textTransform: 'capitalize',
}}>
{currentWeather.description}
</span>
</div>
</motion.div>
)}
</div>
{/* Центральная часть: Погода */}
{/* ===== ЦЕНТРАЛЬНАЯ ЧАСТЬ: ПРИВЕТСТВИЕ ===== */}
<div style={{
flex: 1,
flex: '1 1 0%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 16px',
minHeight: '56px',
height: '100%',
minWidth: 0,
overflow: 'hidden',
position: 'relative',
}}>
<AnimatePresence mode="wait">
{isOnline && !loading && currentWeather ? (
<motion.div
key="weather"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
style={{
display: 'flex',
alignItems: 'center',
gap: '16px',
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
background: 'rgba(255,255,255,0.08)',
padding: '4px 16px 4px 10px',
borderRadius: '30px',
border: '1px solid rgba(255,255,255,0.06)',
}}>
<span style={{ fontSize: '32px', lineHeight: 1 }}>
{getWeatherIcon(currentWeather.icon)}
</span>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span style={{
fontSize: '18px',
fontWeight: 600,
color: '#fff',
}}>
{Math.round(currentWeather.tempMax)}°
</span>
<span style={{
fontSize: '11px',
color: 'rgba(255,255,255,0.5)',
}}>
{currentWeather.description}
</span>
</div>
</div>
<div style={{
display: 'flex',
gap: '10px',
overflow: 'hidden',
}}>
{weatherData.slice(1, 7).map((day, index) => (
<motion.div
key={day.date}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '2px',
padding: '2px 8px',
borderRadius: '6px',
background: 'rgba(255,255,255,0.04)',
minWidth: '40px',
}}
>
<span style={{
fontSize: '9px',
color: 'rgba(255,255,255,0.5)',
fontWeight: 500,
}}>
{day.dayOfWeek}
</span>
<span style={{ fontSize: '16px', lineHeight: 1 }}>
{getWeatherIcon(day.icon)}
</span>
<span style={{
fontSize: '10px',
fontWeight: 600,
color: 'rgba(255,255,255,0.8)',
}}>
{Math.round(day.tempMax)}°
</span>
</motion.div>
))}
</div>
</motion.div>
) : (
<motion.div
key="placeholder"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
style={{
color: 'rgba(255,255,255,0.3)',
fontSize: '13px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
</motion.div>
)}
</AnimatePresence>
{employee && (
<motion.div
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
style={{
fontSize: 'clamp(18px, 1.6vw, 28px)',
fontWeight: 500,
color: 'rgba(255,255,255,0.92)',
textAlign: 'center',
whiteSpace: 'nowrap',
letterSpacing: '0.8px',
textShadow: '0 2px 12px rgba(0,0,0,0.15)',
background: 'rgba(255,255,255,0.04)',
padding: '4px 24px',
borderRadius: '30px',
border: '1px solid rgba(255,255,255,0.04)',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{getGreeting()}
</motion.div>
)}
</div>
{/* Правая часть: Кнопка выхода */}
{/* ===== ПРАВАЯ ЧАСТЬ: ПРОГНОЗ С ПРОКРУТКОЙ ===== */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
gap: '12px',
flexShrink: 0,
minWidth: '80px',
justifyContent: 'flex-end',
maxWidth: '380px',
minWidth: '140px',
height: '100%',
overflow: 'hidden',
position: 'relative',
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: ['0%', '-33.33%'],
}}
transition={{
duration: 20,
repeat: Infinity,
ease: 'linear',
repeatType: 'loop',
}}
style={{
display: 'flex',
gap: '14px',
whiteSpace: 'nowrap',
willChange: 'transform',
transform: 'translateZ(0)',
backfaceVisibility: 'hidden',
WebkitFontSmoothing: 'antialiased',
MozOsxFontSmoothing: 'grayscale',
textRendering: 'geometricPrecision',
paddingRight: '14px',
height: '100%',
alignItems: 'center',
}}
>
{doubledForecast.map((day, index) => (
<div
key={`${day.date}-${index}`}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
whiteSpace: 'nowrap',
flexShrink: 0,
padding: '4px 12px',
borderRadius: '20px',
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.03)',
}}
>
<span style={{
fontSize: '10px',
color: 'rgba(255,255,255,0.4)',
fontWeight: 500,
letterSpacing: '0.3px',
minWidth: '20px',
}}>
{day.dayOfWeek}
</span>
<span style={{ fontSize: '18px', lineHeight: 1 }}>
{getWeatherIcon(day.icon)}
</span>
<span style={{
fontSize: '12px',
fontWeight: 600,
color: 'rgba(255,255,255,0.85)',
minWidth: '24px',
textAlign: 'right',
}}>
{Math.round(day.tempMax)}°
</span>
</div>
))}
</motion.div>
</div>
{/* ===== КНОПКА ВЫХОДА ===== */}
<div style={{
display: 'flex',
alignItems: 'center',
flexShrink: 0,
marginLeft: '4px',
zIndex: 2,
}}>
{onLogout && (
<motion.button
@@ -353,38 +428,34 @@ const WeatherWidget: React.FC<WeatherWidgetProps> = ({ onLogout }) => {
whileTap={{ scale: 0.95 }}
onClick={handleLogout}
style={{
padding: '6px 16px',
background: 'rgba(231, 76, 60, 0.7)',
padding: '6px 18px',
background: 'rgba(231, 76, 60, 0.5)',
color: 'white',
border: 'none',
borderRadius: '8px',
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: '30px',
cursor: 'pointer',
fontSize: '13px',
fontSize: '16px',
fontWeight: 500,
transition: 'all 0.2s',
transition: 'all 0.25s ease',
whiteSpace: 'nowrap',
minHeight: '34px',
minHeight: '32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
backdropFilter: 'blur(4px)',
borderWidth: '1px',
borderStyle: 'solid',
borderColor: 'rgba(255,255,255,0.1)',
boxShadow: '0 2px 10px rgba(231, 76, 60, 0.2)',
boxShadow: '0 2px 12px rgba(231, 76, 60, 0.1)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(231, 76, 60, 1)';
e.currentTarget.style.boxShadow = '0 4px 20px rgba(231, 76, 60, 0.4)';
e.currentTarget.style.background = 'rgba(231, 76, 60, 0.8)';
e.currentTarget.style.boxShadow = '0 4px 20px rgba(231, 76, 60, 0.25)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(231, 76, 60, 0.7)';
e.currentTarget.style.boxShadow = '0 2px 10px rgba(231, 76, 60, 0.2)';
e.currentTarget.style.background = 'rgba(231, 76, 60, 0.5)';
e.currentTarget.style.boxShadow = '0 2px 12px rgba(231, 76, 60, 0.1)';
}}
>
<span style={{ fontSize: '16px' }}>⏻</span>
Выйти
<span style={{ display: 'inline', padding: '5px 10px' }}>ВЫЙТИ</span>
</motion.button>
)}
</div>
+14
View File
@@ -1 +1,15 @@
/// <reference types="vite/client" />
export {};
declare global {
interface Window {
electronAPI?: {
getAppConfig: () => Promise<any>;
onCardDetected: (callback: (uid: string) => void) => void;
removeCardDetectedListeners: () => void;
printPdf: (options?: { pdfUrl?: string; deviceName?: string }) => Promise<boolean>;
getPrinters: () => Promise<any[]>;
};
}
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.test.tsx","./src/app.tsx","./src/config.js","./src/global.d.ts","./src/index.tsx","./src/r5usb.js","./src/r5usb_example.js","./src/react-app-env.d.ts","./src/reportwebvitals.ts","./src/setuptests.ts","./src/components/bottombar.tsx","./src/components/contacts.tsx","./src/components/employeeinfo.tsx","./src/components/finance.tsx","./src/components/guestmode.tsx","./src/components/laborsafety.tsx","./src/components/loginwindow.tsx","./src/components/mapviewer.tsx","./src/components/medicine.tsx","./src/components/menucomponent.tsx","./src/components/news.tsx","./src/components/payslipviewer.tsx","./src/components/profile.tsx","./src/components/tradeunion.tsx","./src/components/videobackground.tsx","./src/components/useactivitytimer.ts","./vite.config.js"],"version":"5.9.3"}
{"root":["./src/app.test.tsx","./src/app.tsx","./src/config.js","./src/global.d.ts","./src/index.tsx","./src/r5usb.js","./src/r5usb_example.js","./src/react-app-env.d.ts","./src/reportwebvitals.ts","./src/setuptests.ts","./src/components/bottombar.tsx","./src/components/contacts.tsx","./src/components/employeeinfo.tsx","./src/components/finance.tsx","./src/components/guestmode.tsx","./src/components/laborsafety.tsx","./src/components/loginwindow.tsx","./src/components/mapviewer.tsx","./src/components/medicine.tsx","./src/components/menucomponent.tsx","./src/components/news.tsx","./src/components/payslipviewer.tsx","./src/components/profile.tsx","./src/components/tradeunion.tsx","./src/components/videobackground.tsx","./src/components/weatherwidget.tsx","./src/components/useactivitytimer.ts","./vite.config.js"],"version":"5.9.3"}