print_add
This commit is contained in:
Binary file not shown.
Generated
+939
-8
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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.
@@ -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
@@ -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 />
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
Vendored
+14
@@ -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 @@
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user