This commit is contained in:
2026-10-29 16:51:26 +03:00
parent 70a20665ae
commit 4b2f0903b1
51 changed files with 6540 additions and 1212 deletions
+796 -21
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -14,14 +14,15 @@
"dompurify": "^3.4.13",
"dotenv": "^17.4.2",
"electron-is-dev": "^2.0.0",
"framer-motion": "^13.1.0",
"framer-motion": "^13.1.1",
"pdf-lib": "^1.17.1",
"pdf-to-printer": "^5.8.1",
"pdfjs-dist": "^3.11.174",
"pdfkit": "^0.20.2",
"pdfmake": "^0.1.72",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-icons": "^4.11.0",
"react-icons": "^4.12.0",
"react-pdf": "^8.0.0",
"react-zoom-pan-pinch": "^3.3.0",
"sharp": "^0.35.4",
+527 -92
View File
@@ -7,6 +7,7 @@ const axios = require('axios');
const { logger } = require('../../backend/src/config/logger');
const config = require('../src/config.json')
const { print } = require('pdf-to-printer');
const { generatePayslipPDF } = require('./pdfGenerator');
protocol.registerSchemesAsPrivileged([
{
@@ -51,6 +52,532 @@ try {
global.allowClose = false;
// ============================================
// 📄 IPC ОБРАБОТЧИКИ
// ============================================
// ✅ 1. Печать PDF (принимает URL или base64) - СКРЫТАЯ ПЕЧАТЬ
ipcMain.handle('print-pdf', async (event, data = {}) => {
let tempFile = null;
try {
if (!data.pdfUrl && !data.pdfBase64) {
throw new Error('URL или base64 PDF не передан');
}
let pdfBuffer;
// ✅ Если передан base64 — используем его
if (data.pdfBase64) {
logger.info('[PRINT] Печать PDF из base64');
pdfBuffer = Buffer.from(data.pdfBase64, 'base64');
logger.info('[PRINT] Размер PDF из base64:', pdfBuffer.length, 'байт');
}
// ✅ Если передан URL — скачиваем
else if (data.pdfUrl) {
const cleanUrl = data.pdfUrl.split('#')[0];
logger.info('[PRINT] Загрузка PDF из URL:', cleanUrl);
// Если это blob URL — получаем через fetch
if (cleanUrl.startsWith('blob:')) {
const response = await fetch(cleanUrl);
const blob = await response.blob();
pdfBuffer = Buffer.from(await blob.arrayBuffer());
} else {
const response = await axios.get(cleanUrl, {
responseType: 'arraybuffer',
timeout: 30000
});
pdfBuffer = Buffer.from(response.data);
}
logger.info('[PRINT] Размер PDF из URL:', pdfBuffer.length, 'байт');
}
if (!pdfBuffer || pdfBuffer.length < 100) {
throw new Error('PDF слишком маленький или пустой');
}
// ✅ ВРЕМЕННО СОХРАНЯЕМ ДЛЯ ПЕЧАТИ (pdf-to-printer требует файл)
const tempDir = app.getPath('temp');
tempFile = path.join(tempDir, `print_${Date.now()}.pdf`);
fs.writeFileSync(tempFile, pdfBuffer);
logger.info('[PRINT] Временный файл создан:', tempFile);
const printerName = data.deviceName || 'GP-80250 Series';
logger.info('[PRINT] СКРЫТАЯ печать через pdf-to-printer:', printerName);
// ✅ КЛЮЧЕВОЕ: silent: true — печать без диалога
await print(tempFile, {
printer: printerName,
silent: true,
printDialog: false
});
logger.info('[PRINT] ✅ Печать успешна');
// ✅ УДАЛЯЕМ ВРЕМЕННЫЙ ФАЙЛ
// try {
// if (tempFile && fs.existsSync(tempFile)) {
// fs.unlinkSync(tempFile);
// logger.info('[PRINT] Временный файл удален');
// }
// } catch (e) {
// logger.warn('[PRINT] Не удалось удалить временный файл:', e.message);
// }
return { success: true };
} catch (error) {
logger.error('[PRINT] ❌ Ошибка:', error.message);
try {
if (tempFile && fs.existsSync(tempFile)) {
fs.unlinkSync(tempFile);
}
} catch (e) {}
return { success: false, error: error.message };
}
});
// ✅ 2. ОСНОВНАЯ ЛОГИКА ПЕЧАТИ HTML (оставлена для совместимости)
async function printHtmlDirectLogic(data) {
let tempHtml = null;
let tempPdf = null;
let printWindow = null;
let savedPdfPath = null;
try {
if (!data.html) {
throw new Error('HTML-код не передан');
}
logger.info('[PRINT-HTML] ========== СОХРАНЕНИЕ PDF ==========');
logger.info('[PRINT-HTML] Длина HTML:', data.html.length);
const desktopPath = app.getPath('desktop');
const documentsPath = app.getPath('documents');
let saveDir = documentsPath;
const possiblePaths = [
path.join(documentsPath, 'payslips'),
path.join(documentsPath, 'Расчетные листки'),
path.join(desktopPath, 'payslips'),
path.join(desktopPath, 'Расчетные листки'),
documentsPath
];
for (const dir of possiblePaths) {
if (fs.existsSync(dir)) {
saveDir = dir;
logger.info('[PRINT-HTML] Найдена папка для сохранения:', saveDir);
break;
}
}
const now = new Date();
const dateStr = now.getFullYear() +
String(now.getMonth() + 1).padStart(2, '0') +
String(now.getDate()).padStart(2, '0') + '_' +
String(now.getHours()).padStart(2, '0') +
String(now.getMinutes()).padStart(2, '0') +
String(now.getSeconds()).padStart(2, '0');
const fileName = `test_${dateStr}.pdf`;
savedPdfPath = path.join(saveDir, fileName);
logger.info('[PRINT-HTML] PDF будет сохранен в:', savedPdfPath);
const tempDir = app.getPath('temp');
tempHtml = path.join(tempDir, `print_${Date.now()}.html`);
tempPdf = path.join(tempDir, `print_${Date.now()}.pdf`);
let htmlContent = data.html;
if (!htmlContent || htmlContent.trim().length < 10) {
logger.warn('[PRINT-HTML] HTML пустой, используем тестовый');
htmlContent = `
<div style="text-align:center;padding:20px;font-size:16pt;border:2px solid #000;">
<b>ТЕСТ ПЕЧАТИ</b>
<hr>
<div>Дата: ${new Date().toLocaleString()}</div>
<div>Табельный номер: TEST</div>
</div>
`;
}
const fullHtml = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Расчетный листок</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Courier New', monospace;
padding: 20px;
background: white;
color: black;
font-size: 12pt;
}
.header {
text-align: center;
font-weight: bold;
font-size: 16pt;
border-bottom: 2px solid #000;
padding-bottom: 10px;
margin-bottom: 10px;
}
.row {
display: flex;
justify-content: space-between;
padding: 4px 0;
border-bottom: 1px solid #eee;
}
.total {
font-weight: bold;
border-top: 2px solid #000;
padding-top: 10px;
margin-top: 10px;
font-size: 14pt;
}
.section-title {
font-weight: bold;
border-bottom: 1px dotted #000;
margin: 10px 0;
padding: 5px 0;
font-size: 14pt;
}
.item {
display: flex;
justify-content: space-between;
padding: 3px 0 3px 15px;
border-bottom: 1px solid #f0f0f0;
}
.footer {
text-align: center;
font-size: 10pt;
color: #666;
border-top: 1px dashed #000;
margin-top: 20px;
padding-top: 10px;
}
.debug-info {
color: #999;
font-size: 8pt;
margin-top: 10px;
padding: 5px;
border: 1px dashed #ccc;
}
</style>
</head>
<body>
${htmlContent}
<div class="debug-info">
Сгенерировано: ${new Date().toLocaleString()}
</div>
</body>
</html>`;
fs.writeFileSync(tempHtml, fullHtml, 'utf8');
logger.info('[PRINT-HTML] HTML файл создан:', tempHtml);
logger.info('[PRINT-HTML] Размер HTML:', fs.statSync(tempHtml).size, 'байт');
printWindow = new BrowserWindow({
width: 600,
height: 800,
show: true,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
webSecurity: false,
allowRunningInsecureContent: true,
}
});
printWindow.webContents.openDevTools();
logger.info('[PRINT-HTML] Загрузка HTML в окно...');
await printWindow.loadFile(tempHtml);
await new Promise((resolve) => {
printWindow.webContents.once('did-finish-load', () => {
logger.info('[PRINT-HTML] Событие did-finish-load');
resolve();
});
setTimeout(() => {
logger.info('[PRINT-HTML] Таймаут загрузки');
resolve();
}, 10000);
});
const contentCheck = await printWindow.webContents.executeJavaScript(`
(function() {
const body = document.body;
if (!body) return { hasBody: false, contentLength: 0 };
const text = body.textContent || '';
const html = body.innerHTML || '';
return {
hasBody: true,
contentLength: text.length,
htmlLength: html.length,
preview: text.substring(0, 200)
};
})()
`);
logger.info('[PRINT-HTML] Проверка контента:', contentCheck);
if (contentCheck.contentLength < 10) {
logger.warn('[PRINT-HTML] ⚠️ В окне нет контента!');
await printWindow.webContents.executeJavaScript(`
if (!document.body || document.body.textContent.length < 10) {
document.body.innerHTML = '<div style="padding:20px;font-size:20pt;color:red;">ОШИБКА: Контент не загрузился!</div>';
}
`);
}
await printWindow.webContents.executeJavaScript(`
document.body.style.display = 'block';
document.body.style.visibility = 'visible';
document.body.style.opacity = '1';
window.scrollTo(0, 0);
document.querySelectorAll('*').forEach(el => {
el.style.display = 'block';
el.style.visibility = 'visible';
el.style.opacity = '1';
});
console.log('Контент в окне:', document.body.textContent.length, 'символов');
console.log('HTML:', document.body.innerHTML.substring(0, 500));
`);
await new Promise(resolve => setTimeout(resolve, 2000));
const finalCheck = await printWindow.webContents.executeJavaScript(`
document.body ? document.body.textContent.length : 0
`);
logger.info('[PRINT-HTML] Финальная проверка контента:', finalCheck, 'символов');
logger.info('[PRINT-HTML] Конвертация в PDF...');
const pdfData = await printWindow.webContents.printToPDF({
pageSize: {
width: 226800,
height: 500000
},
margins: {
top: 0,
bottom: 0,
left: 0,
right: 0
},
printBackground: true,
landscape: false
});
fs.writeFileSync(tempPdf, pdfData);
logger.info('[PRINT-HTML] PDF создан, размер:', pdfData.length, 'байт');
const pdfBuffer = fs.readFileSync(tempPdf);
const isPdf = pdfBuffer.slice(0, 4).toString() === '%PDF';
logger.info('[PRINT-HTML] Это PDF?', isPdf);
if (!isPdf) {
logger.error('[PRINT-HTML] ⚠️ Файл НЕ является PDF!');
throw new Error('Созданный файл не является PDF');
}
if (printWindow) {
printWindow.close();
printWindow = null;
}
if (pdfData.length < 500) {
logger.warn('[PRINT-HTML] ⚠️ PDF слишком маленький:', pdfData.length, 'байт');
logger.info('[PRINT-HTML] Пробуем еще раз с большим ожиданием...');
const retryWindow = new BrowserWindow({
width: 600,
height: 800,
show: false,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
webSecurity: false,
allowRunningInsecureContent: true,
}
});
await retryWindow.loadFile(tempHtml);
await new Promise(resolve => setTimeout(resolve, 3000));
const retryPdf = await retryWindow.webContents.printToPDF({
pageSize: { width: 226800, height: 500000 },
margins: { top: 0, bottom: 0, left: 0, right: 0 },
printBackground: true,
landscape: false
});
fs.writeFileSync(tempPdf, retryPdf);
logger.info('[PRINT-HTML] Повторный PDF создан, размер:', retryPdf.length, 'байт');
retryWindow.close();
}
fs.copyFileSync(tempPdf, savedPdfPath);
logger.info('[PRINT-HTML] ✅ PDF сохранен в:', savedPdfPath);
logger.info('[PRINT-HTML] Размер сохраненного PDF:', fs.statSync(savedPdfPath).size, 'байт');
const printerName = data.printerName || 'GP-80250 Series';
logger.info('[PRINT-HTML] Отправка PDF на печать, принтер:', printerName);
let printResult = null;
let printSuccess = false;
try {
logger.info('[PRINT-HTML] Попытка печати...');
printResult = await print(tempPdf, {
printer: printerName,
scale: 'fit',
paperSize: 'A4'
});
printSuccess = true;
logger.info('[PRINT-HTML] Печать успешна:', printResult);
} catch (err) {
logger.warn('[PRINT-HTML] Печать не удалась:', err.message);
try {
printResult = await print(tempPdf);
printSuccess = true;
logger.info('[PRINT-HTML] Печать успешна (без параметров)');
} catch (err2) {
logger.error('[PRINT-HTML] Все попытки печати не удались:', err2.message);
}
}
try {
if (tempHtml && fs.existsSync(tempHtml)) fs.unlinkSync(tempHtml);
if (tempPdf && fs.existsSync(tempPdf)) fs.unlinkSync(tempPdf);
} catch (e) {}
logger.info('[PRINT-HTML] ========== ЗАВЕРШЕНО ==========');
logger.info('[PRINT-HTML] ✅ PDF сохранен в:', savedPdfPath);
return {
success: true,
pdfPath: savedPdfPath,
printSuccess: printSuccess,
message: `PDF сохранен в: ${savedPdfPath}`
};
} catch (error) {
logger.error('[PRINT-HTML] ❌ Ошибка:', error.message);
logger.error('[PRINT-HTML] Стек:', error.stack);
try {
if (printWindow) printWindow.close();
if (tempHtml && fs.existsSync(tempHtml)) fs.unlinkSync(tempHtml);
if (tempPdf && fs.existsSync(tempPdf)) fs.unlinkSync(tempPdf);
} catch (e) {}
return { success: false, error: error.message };
}
}
// ✅ 3. Обработчик print-html-direct (для совместимости)
ipcMain.handle('print-html-direct', async (event, data) => {
return await printHtmlDirectLogic(data);
});
// ✅ 4. ОСНОВНОЙ ОБРАБОТЧИК - ГЕНЕРАЦИЯ PDF В ПАМЯТИ (БЕЗ СОХРАНЕНИЯ НА ДИСК)
ipcMain.handle('print-payslip-form', async (event, data = {}) => {
try {
logger.info('[PRINT-PAYSLIP] ========== ГЕНЕРАЦИЯ PDF В ПАМЯТИ ==========');
logger.info('[PRINT-PAYSLIP] tnom:', data.tnom);
logger.info('[PRINT-PAYSLIP] period:', data.period);
// --- 1. Получаем данные из бэкенда ---
let payslipData = null;
try {
const backendUrl = appConfig.backendUrl || 'http://localhost:3001';
const url = `${backendUrl}/api/payslip/data/${data.tnom}?period=${data.period}`;
logger.info('[PRINT-PAYSLIP] Запрос к бэкенду:', url);
const response = await axios.get(url, { timeout: 10000 });
payslipData = response.data;
logger.info('[PRINT-PAYSLIP] Данные получены');
} catch (error) {
logger.warn('[PRINT-PAYSLIP] Не удалось получить данные из бэкенда:', error.message);
// Заглушка на случай, если бэкенд не отвечает
payslipData = {
fullName: 'Иванов Иван Иванович',
tabNumber: data.tnom || '00001',
department: 'Отдел разработки',
position: 'Инженер-программист',
daysWorked: '22',
month: 'Ноябрь',
year: '2025',
period: data.period || '112025',
accrued: [
{ code: '01', description: 'Оклад', amount: 2891.87 },
{ code: '02', description: 'Премия', amount: 0 }
],
totalAccrued: 2891.87,
deductions: [
{ code: '101', description: 'НДФЛ', amount: 376.36 },
{ code: '102', description: 'Профсоюз', amount: 85.50 }
],
totalDeductions: 461.86,
totalPaid: 2430.01,
bankTransfers: [
{ bank: 'Сбербанк', amount: 2430.01 }
]
};
}
// --- 2. Генерируем PDF в памяти (Buffer) ---
const pdfBuffer = await generatePayslipPDF(payslipData);
logger.info('[PRINT-PAYSLIP] PDF сгенерирован, размер:', pdfBuffer.length, 'байт');
if (pdfBuffer.length < 100) {
throw new Error('Сгенерированный PDF слишком маленький');
}
// --- 3. Преобразуем Buffer в base64 для передачи через IPC ---
const pdfBase64 = pdfBuffer.toString('base64');
logger.info('[PRINT-PAYSLIP] PDF сконвертирован в base64, длина:', pdfBase64.length);
// --- 4. Возвращаем base64 PDF ---
return {
success: true,
pdfBase64: pdfBase64,
fileName: `${data.tnom || 'payslip'}.pdf`
};
} catch (error) {
logger.error('[PRINT-PAYSLIP] ❌ Ошибка:', error.message);
logger.error('[PRINT-PAYSLIP] Стек:', error.stack);
return { success: false, error: error.message };
}
});
// ✅ 5. Обработчик получения списка принтеров
ipcMain.handle('get-printers', async () => {
return [
{
name: 'GP-80250 Series',
displayName: 'GP-80250 Series',
isDefault: true,
status: 'ready'
}
];
});
// ✅ 6. Обработчик получения конфига
ipcMain.handle('get-app-config', async (event) => {
return appConfig;
});
// ============================================
// 🚀 СОЗДАНИЕ ГЛАВНОГО ОКНА
// ============================================
function createWindow() {
const preloadPath = path.join(__dirname, 'preload.js');
try {
@@ -90,94 +617,6 @@ 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'
}
];
});
// ============================================
// 🚀 ЗАПУСК
// ============================================
@@ -226,8 +665,4 @@ app.on('activate', () => {
app.on('will-quit', () => {
globalShortcut.unregisterAll();
});
ipcMain.handle('get-app-config', async (event) => {
return appConfig;
});
Binary file not shown.
Binary file not shown.
Binary file not shown.
+113
View File
@@ -0,0 +1,113 @@
// pdfGenerator.js - АЛЬТЕРНАТИВНАЯ ВЕРСИЯ (для pdfmake 0.1.72)
const pdfmake = require('pdfmake');
// Для версии 0.1.72 используется другой способ
const fonts = {
Roboto: {
normal: 'C:/Windows/Fonts/arial.ttf',
bold: 'C:/Windows/Fonts/arialbd.ttf',
italics: 'C:/Windows/Fonts/ariali.ttf',
bolditalics: 'C:/Windows/Fonts/arialbi.ttf'
}
};
// Для старой версии pdfmake
const printer = new pdfmake(fonts);
function buildDocDefinition(data) {
return {
pageSize: { width: 80 * 2.83465, height: 297 * 2.83465 },
pageMargins: [5, 5, 5, 5],
content: [
{ text: 'РАСЧЕТНЫЙ ЛИСТОК', style: 'header' },
{ text: `${data.month || ''} ${data.year || ''} (${data.period || ''})`, style: 'subheader' },
{ text: ' ', style: 'divider' },
{ text: `ФИО: ${data.fullName || ''}`, style: 'row' },
{ text: `Таб.№: ${data.tabNumber || ''}`, style: 'row' },
{ text: `Подразделение: ${data.department || ''}`, style: 'row' },
{ text: `Должность: ${data.position || ''}`, style: 'row' },
{ text: `Отработано: ${data.daysWorked || ''}`, style: 'row' },
{ text: ' ', style: 'divider' },
{ text: 'НАЧИСЛЕНО', style: 'sectionTitle' },
...(data.accrued || []).map(item => ({
columns: [
{ text: `${item.code || ''} ${item.description || ''}`, width: '*', style: 'itemDesc' },
{ text: (item.amount || 0).toFixed(2), alignment: 'right', style: 'itemAmount' }
],
style: 'itemRow'
})),
{ text: `ИТОГО НАЧИСЛЕНО: ${(data.totalAccrued || 0).toFixed(2)}`, style: 'totalRow' },
{ text: ' ', style: 'divider' },
{ text: 'УДЕРЖАНО', style: 'sectionTitle' },
...(data.deductions || []).map(item => ({
columns: [
{ text: `${item.code || ''} ${item.description || ''}`, width: '*', style: 'itemDesc' },
{ text: (item.amount || 0).toFixed(2), alignment: 'right', style: 'itemAmount' }
],
style: 'itemRow'
})),
{ text: `ИТОГО УДЕРЖАНО: ${(data.totalDeductions || 0).toFixed(2)}`, style: 'totalRow' },
{ text: ' ', style: 'divider' },
{ text: `К ВЫПЛАТЕ: ${(data.totalPaid || 0).toFixed(2)}`, style: 'totalRow big' },
{ text: ' ', style: 'divider' },
...(data.bankTransfers && data.bankTransfers.length > 0 ? [
{ text: 'БАНКОВСКИЕ ПЕРЕВОДЫ', style: 'sectionTitle' },
...data.bankTransfers.map(bank => ({
columns: [
{ text: bank.bank || '', width: '*', style: 'itemDesc' },
{ text: (bank.amount || 0).toFixed(2), alignment: 'right', style: 'itemAmount' }
],
style: 'itemRow'
}))
] : []),
...(data.other && data.other.length > 0 ? [
{ text: 'ПРОЧЕЕ', style: 'sectionTitle' },
...data.other.map(item => ({
columns: [
{ text: `${item.code || ''} ${item.description || ''}`, width: '*', style: 'itemDesc' },
{ text: (item.amount || 0).toFixed(2), alignment: 'right', style: 'itemAmount' }
],
style: 'itemRow'
})),
{ text: `ИТОГО ПРОЧЕЕ: ${(data.totalOther || 0).toFixed(2)}`, style: 'totalRow' }
] : []),
{ text: ' ', style: 'divider' },
{ text: `Сформировано: ${new Date().toLocaleString('ru-RU')}`, style: 'footer' }
],
styles: {
header: { fontSize: 14, bold: true, alignment: 'center', margin: [0, 0, 0, 4] },
subheader: { fontSize: 12, alignment: 'center', margin: [0, 0, 0, 4] },
row: { fontSize: 10, margin: [0, 2, 0, 2] },
divider: { margin: [0, 4, 0, 4], border: [false, true, false, false] },
sectionTitle: { fontSize: 12, bold: true, margin: [0, 6, 0, 2] },
itemRow: { fontSize: 10, margin: [0, 1, 0, 1] },
itemDesc: { fontSize: 10 },
itemAmount: { fontSize: 10, alignment: 'right' },
totalRow: { fontSize: 12, bold: true, margin: [0, 4, 0, 0] },
'totalRow big': { fontSize: 14, bold: true, margin: [0, 8, 0, 0] },
footer: { fontSize: 8, alignment: 'center', color: '#666', margin: [0, 8, 0, 0] }
}
};
}
function generatePayslipPDF(data) {
const docDefinition = buildDocDefinition(data);
const pdfDoc = printer.createPdfKitDocument(docDefinition);
return new Promise((resolve, reject) => {
const chunks = [];
pdfDoc.on('data', chunk => chunks.push(chunk));
pdfDoc.on('end', () => resolve(Buffer.concat(chunks)));
pdfDoc.on('error', reject);
pdfDoc.end();
});
}
module.exports = { generatePayslipPDF };
+2
View File
@@ -19,6 +19,8 @@ contextBridge.exposeInMainWorld(
},
printPdf: (options) => ipcRenderer.invoke('print-pdf', options),
getPrinters: () => ipcRenderer.invoke('get-printers'),
// ✅ НОВАЯ ФУНКЦИЯ: печать формы расчетного листка
printPayslipForm: (data) => ipcRenderer.invoke('print-payslip-form', data),
}
);
+3
View File
@@ -20,6 +20,7 @@ import LaborSafety from './components/LaborSafety';
import Contacts from './components/Contacts';
import News from './components/News';
import Profile from './components/Profile';
import FoodMenu from './components/FoodMenu';
interface Employee {
id: string;
@@ -303,6 +304,8 @@ function App() {
return <News onBack={handleBackToMenu} />;
case 'profile':
return <Profile onBack={handleBackToMenu} employee={employee!} />;
case 'food':
return <FoodMenu onBack={() => setCurrentSection('main')} employee={employee} />;
default:
return <MainMenu onSectionSelect={handleSectionSelect} employee={employee!} />;
}
+22 -51
View File
@@ -37,7 +37,7 @@ const BottomBar: React.FC = () => {
const BACKEND_URL = 'http://localhost:3001';
// ============================================
// ============================================
// 📊 ЗАГРУЗКА КУРСОВ
// ============================================
useEffect(() => {
@@ -45,57 +45,29 @@ const BottomBar: React.FC = () => {
try {
setLoading(true);
// ✅ Сначала пробуем MySQL
try {
const mysqlResponse = await fetch(`${BACKEND_URL}/api/rates/mysql`);
if (mysqlResponse.ok) {
const data = await mysqlResponse.json();
if (data.currency && data.currency.length > 0) {
setCurrencyRates(data.currency);
setMetalRates(data.metals || []);
setDataSource('mysql');
setLastUpdate(new Date().toLocaleString('ru-RU'));
// Сохраняем в localStorage
localStorage.setItem('ratesData', JSON.stringify({
currency: data.currency,
metals: data.metals,
source: 'mysql',
lastUpdate: new Date().toISOString()
}));
setLoading(false);
return;
}
// ✅ Запрашиваем единый эндпоинт, который уже есть на бэкенде
const response = await fetch(`${BACKEND_URL}/api/rates`);
if (response.ok) {
const data = await response.json();
if (data.currency && data.currency.length > 0) {
setCurrencyRates(data.currency);
setMetalRates(data.metals || []);
setDataSource(data.source || 'default');
setLastUpdate(new Date().toLocaleString('ru-RU'));
// Сохраняем в localStorage для оффлайн-режима
localStorage.setItem('ratesData', JSON.stringify({
currency: data.currency,
metals: data.metals,
source: data.source,
lastUpdate: new Date().toISOString()
}));
setLoading(false);
return;
}
} catch (mysqlError) {
console.warn('MySQL недоступен, пробуем Excel:', mysqlError);
}
// ✅ Если MySQL не работает — пробуем Excel
try {
const excelResponse = await fetch(`${BACKEND_URL}/api/rates/excel`);
if (excelResponse.ok) {
const data = await excelResponse.json();
if (data.currency && data.currency.length > 0) {
setCurrencyRates(data.currency);
setMetalRates(data.metals || []);
setDataSource('excel');
setLastUpdate(new Date().toLocaleString('ru-RU'));
// Сохраняем в localStorage
localStorage.setItem('ratesData', JSON.stringify({
currency: data.currency,
metals: data.metals,
source: 'excel',
lastUpdate: new Date().toISOString()
}));
setLoading(false);
return;
}
}
} catch (excelError) {
console.warn('Excel недоступен:', excelError);
}
// ✅ Если ничего не работает — берём из localStorage
// Если не удалось — берём из localStorage
const stored = localStorage.getItem('ratesData');
if (stored) {
try {
@@ -114,7 +86,6 @@ const BottomBar: React.FC = () => {
}
setLoading(false);
} catch (error) {
console.warn('Ошибка загрузки курсов:', error);
setLoading(false);
+102 -83
View File
@@ -66,11 +66,7 @@ const EmployeeInfo: React.FC<EmployeeInfoProps> = ({
const periods = generatePeriods();
const monthNames = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'];
const getFullUrl = (url: string) => {
return url.startsWith('http') ? url : `http://localhost:3001${url}`;
};
'Июль', 'Август', 'Сентябрь', 'Окторябрь', 'Ноябрь', 'Декабрь'];
const checkPayslip = async () => {
if (!employee?.tnom) return;
@@ -100,6 +96,7 @@ const EmployeeInfo: React.FC<EmployeeInfoProps> = ({
checkPayslip();
}, [selectedPeriod, employee]);
// ✅ ФУНКЦИЯ: формируем PDF в памяти и открываем предпросмотр
const handlePreviewPdf = async () => {
if (!employee?.tnom) return;
@@ -109,6 +106,50 @@ const EmployeeInfo: React.FC<EmployeeInfoProps> = ({
setIsFullscreen(false);
try {
const electronAPI = (window as any).electronAPI;
console.log('🔍 [PREVIEW] electronAPI доступен?', !!electronAPI);
if (electronAPI && electronAPI.printPayslipForm) {
console.log('🔄 [PREVIEW] Формируем PDF через Electron...');
const result = await electronAPI.printPayslipForm({
tnom: employee.tnom,
period: selectedPeriod,
printerName: 'GP-80250 Series'
});
console.log('📄 [PREVIEW] Результат от Electron:', result);
if (result && result.success === false) {
throw new Error(result.error || 'Ошибка формирования PDF');
}
if (result && result.pdfBase64) {
// ✅ Сохраняем base64 для печати
(window as any).__pdfBase64 = result.pdfBase64;
// ✅ Преобразуем base64 в Blob URL для предпросмотра
const byteCharacters = atob(result.pdfBase64);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: 'application/pdf' });
const blobUrl = URL.createObjectURL(blob);
setPayslipUrl(blobUrl);
setPdfLoading(false);
console.log('✅ [PREVIEW] PDF загружен в Blob URL');
return;
} else {
throw new Error('PDF не создан');
}
}
// ✅ Fallback: используем старый метод через бэкенд
console.log('🔄 [PREVIEW] Используем fallback через бэкенд...');
const backendUrl = config.backendUrl || 'http://localhost:3001';
const response = await fetch(`${backendUrl}/api/payslip/url/${employee.tnom}?period=${selectedPeriod}`);
@@ -127,8 +168,8 @@ const EmployeeInfo: React.FC<EmployeeInfoProps> = ({
setPdfLoading(false);
}
} catch (error) {
console.error('Ошибка загрузки PDF:', error);
setPdfError('Не удалось загрузить расчетный листок');
console.error('❌ [PREVIEW] Ошибка формирования PDF:', error);
setPdfError(error instanceof Error ? error.message : 'Не удалось сформировать расчетный листок');
setPdfLoading(false);
}
};
@@ -138,63 +179,47 @@ const EmployeeInfo: React.FC<EmployeeInfoProps> = ({
setPdfLoading(false);
setPdfError(null);
setIsFullscreen(false);
// Очищаем URL, чтобы не было утечек памяти
if (payslipUrl && payslipUrl.startsWith('blob:')) {
URL.revokeObjectURL(payslipUrl);
}
setPayslipUrl(null);
};
const handlePrint = async () => {
// ✅ ФУНКЦИЯ ПЕЧАТИ (отправляет base64 напрямую в print-pdf)
const handlePrint = async () => {
try {
setIsPrinting(true);
setPrintStatus('sending');
setPrintMessage('Отправка документа на печать...');
const urlToPrint = payslipUrl ; // payslipUrl для Employee/Finance, fileUrl для Viewer
if (!urlToPrint) throw new Error('URL документа отсутствует');
const electronAPI = (window as any).electronAPI;
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 сам плюнет на дефолтный принтер
// ✅ Получаем base64 из памяти
const pdfBase64 = (window as any).__pdfBase64;
if (!pdfBase64) {
throw new Error('PDF не сформирован. Сначала откройте предпросмотр.');
}
if (electronAPI && electronAPI.printPdf) {
console.log('🖨️ [PRINT] Печатаем PDF из памяти (base64)');
// ✅ Отправляем base64 напрямую в print-pdf
const result = await electronAPI.printPdf({
pdfBase64: pdfBase64,
deviceName: 'GP-80250 Series'
});
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);
}
};
if (result && result.success) {
setPrintStatus('success');
setPrintMessage('✅ Отправлено на принтер');
} else {
throw new Error(result?.error || 'Ошибка печати');
}
} else {
throw new Error('Electron API не доступен');
}
} catch (error) {
@@ -400,23 +425,24 @@ const EmployeeInfo: React.FC<EmployeeInfoProps> = ({
</div>
) : null}
{/* ✅ ТОЛЬКО ОДНА КНОПКА - ПРЕДПРОСМОТР */}
<button
onClick={handlePreviewPdf}
disabled={!hasPayslip}
style={{
padding: '10px 25px',
padding: '12px 35px',
background: hasPayslip ? 'linear-gradient(135deg, #00b894, #00cec9)' : '#dfe6e9',
color: hasPayslip ? 'white' : '#b2bec3',
border: 'none',
borderRadius: '10px',
fontSize: '14px',
fontSize: '15px',
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',
gap: '10px',
}}
onMouseEnter={(e) => {
if (hasPayslip) {
@@ -480,6 +506,7 @@ const EmployeeInfo: React.FC<EmployeeInfoProps> = ({
)}
</AnimatePresence>
{/* ✅ МОДАЛКА ПРЕДПРОСМОТРА PDF */}
{showPdfPreview && (
<motion.div
initial={{ opacity: 0 }}
@@ -550,32 +577,22 @@ const EmployeeInfo: React.FC<EmployeeInfoProps> = ({
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<button
onClick={handlePrint}
disabled={isPrinting}
disabled={isPrinting || pdfLoading || !payslipUrl}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '8px 16px',
background: isPrinting ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.2)',
background: isPrinting || pdfLoading || !payslipUrl ? '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',
cursor: isPrinting || pdfLoading || !payslipUrl ? '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)';
}
opacity: isPrinting || pdfLoading || !payslipUrl ? 0.5 : 1,
}}
>
{isPrinting ? <FaSpinner style={{ animation: 'spin 1s linear infinite' }} /> : <FaPrint size={16} />}
@@ -595,7 +612,6 @@ const EmployeeInfo: React.FC<EmployeeInfoProps> = ({
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} />}
@@ -614,7 +630,6 @@ const EmployeeInfo: React.FC<EmployeeInfoProps> = ({
border: '1px solid rgba(255,255,255,0.3)',
borderRadius: '10px',
cursor: 'pointer',
transition: 'all 0.3s ease',
}}
>
<FaTimes size={18} />
@@ -650,7 +665,7 @@ const EmployeeInfo: React.FC<EmployeeInfoProps> = ({
animation: 'spin 1s linear infinite',
}} />
<span style={{ color: '#888', fontSize: '16px' }}>
Загрузка документа...
Формирование документа...
</span>
</div>
)}
@@ -658,20 +673,26 @@ const EmployeeInfo: React.FC<EmployeeInfoProps> = ({
{!pdfLoading && payslipUrl && !pdfError && (
<iframe
ref={iframeRef}
src={`${payslipUrl}#toolbar=0&navpanes=0&scrollbar=1&view=FitH`}
src={payslipUrl}
style={{
width: '100%',
height: '100%',
minHeight: '600px',
border: 'none',
borderRadius: '12px',
background: '#0a0a0a',
background: '#ffffff',
display: 'block',
boxShadow: '0 2px 12px rgba(0,0,0,0.3)',
}}
title="PDF Preview"
onError={() => {
console.error('❌ [IFRAME] Ошибка загрузки PDF:', payslipUrl);
setPdfError('Не удалось загрузить PDF. Возможно, файл повреждён или путь неверный.');
setPdfLoading(false);
}}
onLoad={() => {
console.log('✅ [IFRAME] PDF загружен успешно');
setPdfLoading(false);
setPdfError('Не удалось загрузить PDF');
}}
/>
)}
@@ -690,12 +711,11 @@ const EmployeeInfo: React.FC<EmployeeInfoProps> = ({
<div style={{ fontSize: '18px', color: '#e74c3c', fontWeight: 500 }}>
{pdfError}
</div>
<div style={{ fontSize: '12px', color: '#888', maxWidth: '400px', wordBreak: 'break-all' }}>
URL: {payslipUrl || 'не указан'}
</div>
<button
onClick={() => {
setPdfError(null);
setPdfLoading(true);
handlePreviewPdf();
}}
onClick={handlePreviewPdf}
style={{
padding: '10px 30px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
@@ -705,7 +725,6 @@ const EmployeeInfo: React.FC<EmployeeInfoProps> = ({
cursor: 'pointer',
fontSize: '14px',
fontWeight: 500,
transition: 'all 0.3s ease',
}}
>
🔄 Попробовать снова
+1
View File
@@ -199,6 +199,7 @@ const Finance: React.FC<FinanceProps> = ({ onBack, onViewPayslip, employee }) =>
style={{
padding: '10px 20px',
background: '#6c757d',
flex: '0 0 100px',
color: 'white',
border: 'none',
borderRadius: '8px',
+853
View File
@@ -0,0 +1,853 @@
import React, { useEffect, useMemo, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
FaArrowLeft, FaShoppingCart, FaPlus, FaMinus, FaTrashAlt,
FaWallet, FaPaperPlane, FaCheckCircle, FaSpinner, FaUtensils, FaTimes
} from 'react-icons/fa';
const API_BASE = 'http://localhost:3001';
const BOTTOM_BAR_HEIGHT = 70;
// ============================================================
// ТИПЫ
// ============================================================
interface FoodItem {
id: number;
name: string;
description?: string;
price: number;
imageUrl?: string;
weightGrams?: number;
}
interface Category {
id: number;
name: string;
emoji?: string;
items: FoodItem[];
}
interface FoodMenuProps {
onBack: () => void;
employee: any;
}
type View = 'menu' | 'success';
const CATEGORY_EMOJI_FALLBACK: Record<number, string> = {
1: '🍲', 2: '🥣', 3: '🥪', 4: '🥤', 5: '🍰',
};
const getInitials = (fullName: string) =>
fullName.split(' ').filter(Boolean).slice(0, 2)
.map((s) => s[0]?.toUpperCase()).join('');
// ============================================================
// ГЛАВНЫЙ КОМПОНЕНТ
// ============================================================
const FoodMenu: React.FC<FoodMenuProps> = ({ onBack, employee }) => {
const tnom = employee?.tnom || '';
const fullName = employee
? `${employee.famaly || ''} ${employee.ima || ''} ${employee.otch || ''}`.trim()
: 'Сотрудник';
const [company, setCompany] = useState('АзотСервис');
const [categories, setCategories] = useState<Category[]>([]);
const [activeCat, setActiveCat] = useState<number | null>(null);
const [balance, setBalance] = useState<number>(0);
const [cart, setCart] = useState<Record<number, number>>({});
const [expandedItem, setExpandedItem] = useState<number | null>(null);
const [imageErrors, setImageErrors] = useState<Record<number, boolean>>({});
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const [view, setView] = useState<View>('menu');
const [isCartOpen, setIsCartOpen] = useState(false);
const [successOrder, setSuccessOrder] = useState<{ id: number; total: number } | null>(null);
const [error, setError] = useState<string | null>(null);
// Состояния для "Мои заказы"
const [showMyOrders, setShowMyOrders] = useState(false);
const [myOrders, setMyOrders] = useState<any[]>([]);
const [loadingOrders, setLoadingOrders] = useState(false);
const [cancelConfirm, setCancelConfirm] = useState<{ id: number; total: number } | null>(null);
useEffect(() => {
(async () => {
setLoading(true);
try {
const [menuRes, balRes] = await Promise.all([
fetch(`${API_BASE}/api/food/menu`).then((r) => r.json()),
tnom ? fetch(`${API_BASE}/api/food/balance/${tnom}`).then((r) => r.json())
: Promise.resolve({ balance: 0 }),
]);
setCompany(menuRes.company || 'АзотСервис');
setCategories(menuRes.categories || []);
setActiveCat(menuRes.categories?.[0]?.id ?? null);
setBalance(Number(balRes.balance) || 0);
} catch (e) {
setError('Не удалось загрузить меню');
} finally {
setLoading(false);
}
})();
}, [tnom]);
const allItems = useMemo(() => {
const map: Record<number, FoodItem> = {};
categories.forEach((c) => c.items.forEach((i) => (map[i.id] = i)));
return map;
}, [categories]);
const cartCount = useMemo(
() => Object.values(cart).reduce((s, q) => s + q, 0), [cart]
);
const cartTotal = useMemo(
() => +Object.entries(cart).reduce((s, [id, qty]) => {
const it = allItems[Number(id)];
return it ? s + it.price * qty : s;
}, 0).toFixed(2),
[cart, allItems]
);
const changeQty = (itemId: number, delta: number) => {
setCart((prev) => {
const next = { ...prev };
const qty = (next[itemId] || 0) + delta;
if (qty <= 0) delete next[itemId];
else next[itemId] = qty;
return next;
});
};
const removeItem = (itemId: number) => {
setCart((prev) => {
const n = { ...prev };
delete n[itemId];
return n;
});
};
const activeItems = categories.find((c) => c.id === activeCat)?.items || [];
const submitOrder = async () => {
if (cartCount === 0) return;
setSending(true);
setError(null);
try {
const items = Object.entries(cart).map(([id, qty]) => ({
itemId: Number(id), quantity: qty,
}));
const res = await fetch(`${API_BASE}/api/food/orders`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tnom, items }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Ошибка отправки заказа');
setSending(false);
return;
}
setSuccessOrder({ id: data.orderId, total: data.total });
setBalance((b) => +(b - data.total).toFixed(2));
setCart({});
setIsCartOpen(false);
setView('success');
} catch (e: any) {
setError(e.message || 'Ошибка сети');
} finally {
setSending(false);
}
};
const handleDone = () => {
setSuccessOrder(null);
setView('menu');
};
// ============================================
// МОИ ЗАКАЗЫ
// ============================================
const fetchMyOrders = async () => {
if (!tnom) return;
setLoadingOrders(true);
try {
const res = await fetch(`${API_BASE}/api/food/my-orders/${tnom}`);
if (res.ok) {
const data = await res.json();
setMyOrders(data);
}
} catch (e) {
console.error(e);
} finally {
setLoadingOrders(false);
}
};
const handleOpenMyOrders = () => {
setShowMyOrders(true);
fetchMyOrders();
};
const handleCancelClick = (orderId: number, totalAmount: number) => {
setCancelConfirm({ id: orderId, total: totalAmount });
};
const confirmCancelOrder = async () => {
if (!cancelConfirm) return;
const { id, total } = cancelConfirm;
setCancelConfirm(null);
try {
const res = await fetch(`${API_BASE}/api/food/my-orders/${id}/cancel`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tnom }),
});
const data = await res.json();
if (res.ok) {
setBalance((b) => +(b + data.refund).toFixed(2));
fetchMyOrders();
} else {
alert(data.error || 'Ошибка отмены заказа');
}
} catch (e) {
alert('Ошибка сети при отмене заказа');
}
};
if (loading) {
return (
<div style={loadingStyle}>
<div style={spinnerStyle} />
<style>{keyframes}</style>
</div>
);
}
if (view === 'success' && successOrder) {
return (
<div style={successWrapperStyle}>
<motion.div
initial={{ scale: 0.85, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
style={successCardStyle}
>
<div style={{ background: '#E8F5E9', borderRadius: '50%', padding: 20, display: 'inline-block', marginBottom: 20 }}>
<FaCheckCircle style={{ fontSize: 80, color: '#4CAF50' }} />
</div>
<h1 style={{ margin: '0 0 12px', color: '#2E3B32', fontSize: 36, fontWeight: 800 }}>
Заказ оформлен!
</h1>
<p style={{ color: '#607D8B', fontSize: 20, margin: '8px 0' }}>
Номер заказа: <b style={{ color: '#2E3B32' }}>#{successOrder.id}</b>
</p>
<p style={{ color: '#607D8B', fontSize: 20, margin: '8px 0 32px' }}>
Сумма: <b style={{ color: '#2E3B32' }}>{successOrder.total.toFixed(2)} BYN</b>
</p>
<button onClick={handleDone} style={successBtnStyle}>
Готово
</button>
</motion.div>
</div>
);
}
const entries = Object.entries(cart);
const notEnough = cartTotal > balance;
return (
<div style={menuRootStyle}>
{/* ============ ШАПКА ============ */}
<div style={headerZoneStyle}>
{/* 1-я строка: Кнопка Назад (10%) + Название столовой + Мои заказы */}
<div style={headerRow1Style}>
<button onClick={onBack} style={btnRectBackStyle}>
<FaArrowLeft size={20} />
</button>
<div style={companyTitleStyle}>
<FaUtensils size={32} style={{ color: '#4CAF50' }} />
<span>{company}</span>
</div>
{/* Кнопка Мои заказы */}
<button
onClick={handleOpenMyOrders}
style={{
marginLeft: 'auto',
background: '#FFFFFF', color: '#4CAF50', border: '2px solid #4CAF50',
borderRadius: 12, padding: '10px 20px', fontSize: 16, fontWeight: 700,
cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.05)'
}}
>
<FaShoppingCart size={18} /> Мои заказы
</button>
</div>
{/* 2-я строка: Сотрудник + Баланс */}
<div style={headerRow2Style}>
<div style={headerCardStyle}>
<div style={avatarCircleStyle}>{getInitials(fullName) || '👤'}</div>
<div style={{ minWidth: 0 }}>
<div style={headerLabelStyle}>Сотрудник</div>
<div style={headerValueStyle} title={fullName}>{fullName}</div>
</div>
</div>
<div style={{
...headerCardStyle,
background: 'linear-gradient(135deg, #81C784 0%, #4CAF50 100%)',
border: 'none',
boxShadow: '0 8px 24px rgba(76,175,80,0.25)',
flex: '0 1 auto',
minWidth: 260,
}}>
<FaWallet size={28} style={{ color: '#fff' }} />
<div style={{ minWidth: 0 }}>
<div style={{ ...headerLabelStyle, color: 'rgba(255,255,255,0.9)' }}>Баланс</div>
<div style={{ ...headerValueStyle, color: '#fff', fontSize: 22 }}>
{balance.toFixed(2)} BYN
</div>
</div>
</div>
</div>
</div>
{/* ============ ОСНОВНАЯ ЗОНА ============ */}
<div style={mainZoneStyle}>
{/* Вертикальная колонка категорий */}
<div style={categoriesPanelStyle}>
<div style={categoriesTitleStyle}>КАТЕГОРИИ</div>
<div style={categoriesListStyle}>
{categories.map((c) => {
const isActive = c.id === activeCat;
return (
<button
key={c.id}
onClick={() => { setActiveCat(c.id); setExpandedItem(null); }}
style={{
...categoryBtnStyle,
background: isActive ? '#E2F0D9' : 'transparent',
color: isActive ? '#4CAF50' : '#546E7A',
fontWeight: isActive ? 800 : 600,
}}
>
<span style={{ fontSize: 32 }}>{c.emoji || '🍽️'}</span>
<span style={{ fontSize: 18 }}>{c.name}</span>
</button>
);
})}
</div>
</div>
{/* Сетка блюд */}
<div style={itemsPanelStyle}>
{activeItems.map((it) => {
const qty = cart[it.id] || 0;
const cat = categories.find((c) => c.id === activeCat);
const emoji = CATEGORY_EMOJI_FALLBACK[cat?.id || 0] || '🍽️';
const isExpanded = expandedItem === it.id || qty > 0;
return (
<div
key={it.id}
style={{
...itemCardStyle,
border: isExpanded ? '2px solid #81C784' : '2px solid transparent',
}}
onClick={() => {
if (qty === 0) {
changeQty(it.id, 1);
setExpandedItem(it.id);
}
}}
>
<div style={itemImgStyle}>
{it.imageUrl && !imageErrors[it.id] ? (
<img
src={`${API_BASE}${it.imageUrl}`}
alt={it.name}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={() => {
setImageErrors(prev => ({ ...prev, [it.id]: true }));
}}
/>
) : (
emoji
)}
</div>
<div style={itemInfoStyle}>
<div style={itemNameStyle}>{it.name}</div>
{it.description && <div style={itemDescStyle}>{it.description}</div>}
<div style={{ flex: 1 }} />
<div style={itemBottomStyle}>
<div style={itemPriceStyle}>
{it.price.toFixed(2)}
<span style={{ fontSize: 14, color: '#90A4AE', marginLeft: 4, fontWeight: 500 }}>BYN</span>
</div>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
style={qtyInlineStyle}
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => {
changeQty(it.id, -1);
if (qty <= 1) setExpandedItem(null);
}}
style={qtyItemBtnStyle}
>
<FaMinus size={16} />
</button>
<span style={qtyInlineNumStyle}>{qty}</span>
<button
onClick={() => changeQty(it.id, 1)}
style={qtyItemBtnStyle}
>
<FaPlus size={16} />
</button>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
);
})}
</div>
</div>
{/* ============ ПЛАВАЮЩАЯ ПЛАШКА КОРЗИНЫ ============ */}
<AnimatePresence>
{cartCount > 0 && (
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
style={cartBarStyle}
onClick={() => setIsCartOpen(true)}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<div style={{ background: 'rgba(255,255,255,0.2)', borderRadius: '50%', width: 48, height: 48, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<FaShoppingCart size={22} />
</div>
<span style={{ fontWeight: 600, fontSize: 20 }}>
{cartCount} поз.
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 20 }}>
<span style={{ fontWeight: 800, fontSize: 24 }}>
{cartTotal.toFixed(2)} BYN
</span>
<div style={{ background: '#fff', color: '#4CAF50', borderRadius: '50%', width: 48, height: 48, display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 'bold', fontSize: 24 }}>
→
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* ============ ВЫЕЗЖАЮЩАЯ КОРЗИНА СПРАВА ============ */}
<AnimatePresence>
{isCartOpen && (
<>
<motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
style={{ position: 'fixed', inset: 0, background: 'rgba(46,59,50,0.4)', backdropFilter: 'blur(4px)', zIndex: 200 }}
onClick={() => setIsCartOpen(false)}
/>
<motion.div
initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
style={{
position: 'fixed', top: 0, right: 0, bottom: 0, width: 540,
background: '#FFFFFF', boxShadow: '-10px 0 40px rgba(0,0,0,0.1)',
zIndex: 201, display: 'flex', flexDirection: 'column',
paddingBottom: BOTTOM_BAR_HEIGHT
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '24px', borderBottom: '1px solid #E0E6ED', background: '#E2F0D9' }}>
<h2 style={{ margin: 0, fontSize: 28, fontWeight: 800, color: '#2E3B32' }}>Ваш заказ</h2>
<button onClick={() => setIsCartOpen(false)} style={{ background: '#FFFFFF', border: 'none', width: 48, height: 48, borderRadius: '50%', cursor: 'pointer', color: '#4CAF50', boxShadow: '0 2px 10px rgba(0,0,0,0.05)' }}>
<FaTimes size={20} />
</button>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{entries.map(([idStr, qty]) => {
const id = Number(idStr);
const it = allItems[id];
if (!it) return null;
const cat = categories.find((c) => c.items.some((i) => i.id === id));
const emoji = CATEGORY_EMOJI_FALLBACK[cat?.id || 0] || '🍽️';
return (
<div key={id} style={cartItemStyle}>
<div style={cartItemImgStyle}>
{it.imageUrl && !imageErrors[it.id] ? (
<img
src={`${API_BASE}${it.imageUrl}`}
alt={it.name}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={() => {
setImageErrors(prev => ({ ...prev, [it.id]: true }));
}}
/>
) : (
emoji
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={cartItemNameStyle}>{it.name}</div>
<div style={cartItemPriceStyle}>{it.price.toFixed(2)} BYN</div>
</div>
<div style={qtyControlsStyle}>
<button onClick={() => changeQty(id, -1)} style={qtyBigBtnStyle}><FaMinus size={16} /></button>
<span style={qtyNumStyle}>{qty}</span>
<button onClick={() => changeQty(id, 1)} style={qtyBigBtnStyle}><FaPlus size={16} /></button>
</div>
<div style={{ fontSize: 20, fontWeight: 800, color: '#2E3B32', minWidth: 80, textAlign: 'right' }}>
{(it.price * qty).toFixed(2)}
</div>
<button onClick={() => removeItem(id)} style={removeBtnStyle}><FaTrashAlt size={20} /></button>
</div>
);
})}
</div>
<div style={{ padding: '24px', borderTop: '1px solid #E0E6ED', background: '#F7F9F6' }}>
{error && <div style={errorStyle}>{error}</div>}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<div style={{ fontSize: 20, fontWeight: 700, color: '#546E7A' }}>Итого:</div>
<div style={{ fontSize: 40, fontWeight: 800, color: '#2E3B32' }}>
{cartTotal.toFixed(2)} <span style={{ fontSize: 20, color: '#78909C' }}>BYN</span>
</div>
</div>
{notEnough && <div style={{ fontSize: 16, color: '#E74C3C', marginBottom: 16, fontWeight: 600, textAlign: 'center' }}>Недостаточно средств (баланс {balance.toFixed(2)})</div>}
<button
onClick={submitOrder}
disabled={sending || notEnough || entries.length === 0}
style={{
...submitBtnStyle,
background: sending || notEnough || entries.length === 0 ? '#B0BEC5' : 'linear-gradient(135deg, #81C784 0%, #4CAF50 100%)',
cursor: sending || notEnough || entries.length === 0 ? 'not-allowed' : 'pointer',
width: '100%',
}}
>
{sending ? <><FaSpinner size={24} style={{ animation: 'spin 1s linear infinite' }} /> Отправка…</> : <><FaPaperPlane size={24} /> Отправить заказ</>}
</button>
</div>
</motion.div>
</>
)}
</AnimatePresence>
{/* ============ ВЫЕЗЖАЮЩАЯ ПАНЕЛЬ "МОИ ЗАКАЗЫ" ============ */}
<AnimatePresence>
{showMyOrders && (
<>
<motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
style={{ position: 'fixed', inset: 0, background: 'rgba(46,59,50,0.4)', backdropFilter: 'blur(4px)', zIndex: 200 }}
onClick={() => setShowMyOrders(false)}
/>
<motion.div
initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
style={{
position: 'fixed', top: 0, right: 0, bottom: 0, width: 540,
background: '#FFFFFF', boxShadow: '-10px 0 40px rgba(0,0,0,0.1)',
zIndex: 201, display: 'flex', flexDirection: 'column',
paddingBottom: BOTTOM_BAR_HEIGHT
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '24px', borderBottom: '1px solid #E0E6ED', background: '#E2F0D9' }}>
<h2 style={{ margin: 0, fontSize: 28, fontWeight: 800, color: '#2E3B32' }}>Мои заказы</h2>
<button onClick={() => setShowMyOrders(false)} style={{ background: '#FFFFFF', border: 'none', width: 48, height: 48, borderRadius: '50%', cursor: 'pointer', color: '#4CAF50', boxShadow: '0 2px 10px rgba(0,0,0,0.05)' }}>
<FaTimes size={20} />
</button>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{loadingOrders ? (
<div style={{ textAlign: 'center', padding: 40, color: '#78909C' }}>Загрузка...</div>
) : myOrders.length === 0 ? (
<div style={{ textAlign: 'center', padding: 40, color: '#78909C', fontSize: 18 }}>У вас пока нет заказов</div>
) : (
myOrders.map((order) => {
let statusColor = '#78909C';
let statusText = order.status;
if (order.status === 'new') { statusColor = '#F39C12'; statusText = 'В обработке'; }
if (order.status === 'accepted') { statusColor = '#3498DB'; statusText = 'Готовится'; }
if (order.status === 'done') { statusColor = '#2ECC71'; statusText = 'Готов'; }
if (order.status === 'cancelled') { statusColor = '#E74C3C'; statusText = 'Отменен'; }
return (
<div key={order.id} style={{ background: '#F7F9F6', borderRadius: 16, padding: 20, border: '1px solid #E0E6ED' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<div style={{ fontWeight: 800, fontSize: 18, color: '#2E3B32' }}>Заказ #{order.id}</div>
<div style={{
padding: '4px 12px', borderRadius: 20, fontSize: 14, fontWeight: 700,
background: `${statusColor}20`, color: statusColor,
border: `1px solid ${statusColor}50`
}}>
{statusText}
</div>
</div>
<div style={{ fontSize: 14, color: '#78909C', marginBottom: 12 }}>
{new Date(order.createdAt).toLocaleString('ru-RU')}
</div>
<ul style={{ margin: 0, paddingLeft: 20, fontSize: 16, color: '#2E3B32', marginBottom: 12 }}>
{order.items.map((it: any, i: number) => (
<li key={i}>{it.name} <span style={{ color: '#78909C' }}>x{it.quantity}</span></li>
))}
</ul>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12, paddingTop: 12, borderTop: '1px solid #E0E6ED' }}>
<div style={{ fontSize: 20, fontWeight: 800, color: '#4CAF50' }}>
{order.totalAmount.toFixed(2)} BYN
</div>
{order.status === 'new' && (
<button
onClick={() => handleCancelClick(order.id, order.totalAmount)}
style={{
padding: '10px 20px',
background: '#FFEBEE',
flex: '0 0 100px',
color: '#E74C3C',
border: '2px solid #E74C3C',
borderRadius: 8,
cursor: 'pointer',
fontSize: 16,
fontWeight: 700,
display: 'flex',
alignItems: 'center',
gap: 6,
}}
>
<FaTimes size={16} /> Отменить
</button>
)}
</div>
</div>
);
})
)}
</div>
</motion.div>
</>
)}
</AnimatePresence>
{/* ============ МИНИ-МОДАЛКА ПОДТВЕРЖДЕНИЯ ОТМЕНЫ ============ */}
<AnimatePresence>
{cancelConfirm && (
<motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
style={{
position: 'fixed', inset: 0, background: 'rgba(46,59,50,0.5)', backdropFilter: 'blur(4px)',
zIndex: 300, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20
}}
>
<motion.div
initial={{ scale: 0.9, y: 20 }} animate={{ scale: 1, y: 0 }} exit={{ scale: 0.9, y: 20 }}
style={{
background: '#FFFFFF', borderRadius: 20, padding: '32px',
maxWidth: 460, width: '100%', textAlign: 'center',
boxShadow: '0 24px 80px rgba(0,0,0,0.2)'
}}
>
<div style={{
width: 64, height: 64, borderRadius: '50%',
background: '#FFEBEE', color: '#E74C3C',
display: 'flex', alignItems: 'center', justifyContent: 'center',
margin: '0 auto 16px', fontSize: 28
}}>
<FaTimes />
</div>
<h3 style={{ margin: '0 0 8px', fontSize: 22, fontWeight: 800, color: '#2E3B32' }}>
Отменить заказ?
</h3>
<p style={{ margin: '0 0 24px', color: '#607D8B', fontSize: 16 }}>
Заказ #{cancelConfirm.id} будет отменён.<br />
<b style={{ color: '#2E3B32' }}>{cancelConfirm.total.toFixed(2)} BYN</b> вернутся на баланс.
</p>
<div style={{ display: 'flex', gap: 12 }}>
<button
onClick={() => setCancelConfirm(null)}
style={{
flex: 1, padding: '14px', border: '2px solid #E0E6ED',
background: '#FFFFFF', color: '#546E7A', borderRadius: 12,
fontSize: 16, fontWeight: 700, cursor: 'pointer'
}}
>
Отмена
</button>
<button
onClick={confirmCancelOrder}
style={{
flex: 1, padding: '14px',
background: 'linear-gradient(135deg, #EF5350 0%, #E74C3C 100%)',
color: '#FFFFFF', border: 'none', borderRadius: 12,
fontSize: 16, fontWeight: 700, cursor: 'pointer',
boxShadow: '0 8px 24px rgba(231,76,60,0.3)'
}}
>
Да, отменить
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<style>{keyframes}</style>
</div>
);
};
// ============================================================
// СТИЛИ (FRESH MINT + КНОПКА 10%)
// ============================================================
const keyframes = `@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }`;
const loadingStyle: React.CSSProperties = { position: 'fixed', inset: 0, background: '#F7F9F6', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 50 };
const spinnerStyle: React.CSSProperties = { width: 60, height: 60, border: '5px solid #E2F0D9', borderTop: '5px solid #4CAF50', borderRadius: '50%', animation: 'spin 1s linear infinite' };
const menuRootStyle: React.CSSProperties = {
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
paddingTop: 90, paddingBottom: BOTTOM_BAR_HEIGHT,
background: '#F7F9F6', display: 'flex', flexDirection: 'column', overflow: 'hidden', boxSizing: 'border-box',
};
// Шапка
const headerZoneStyle: React.CSSProperties = {
padding: '16px 24px',
background: '#E2F0D9',
borderBottom: '1px solid #C8E6C9',
flexShrink: 0, zIndex: 10, boxShadow: '0 4px 16px rgba(76,175,80,0.05)',
display: 'flex', flexDirection: 'column', gap: 16,
};
// Первая строка шапки
const headerRow1Style: React.CSSProperties = {
display: 'flex', alignItems: 'center', gap: 20, justifyContent: 'flex-start'
};
const btnRectBackStyle: React.CSSProperties = {
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 8,
flex: '0 0 100px',
height: 52,
background: '#FFFFFF', color: '#4CAF50',
border: 'none', borderRadius: 12, cursor: 'pointer',
fontSize: 16, fontWeight: 800, flexShrink: 0,
boxShadow: '0 4px 12px rgba(0,0,0,0.05)', transition: 'all 0.2s',
};
const companyTitleStyle: React.CSSProperties = {
display: 'flex', alignItems: 'center', gap: 12,
fontSize: 36, fontWeight: 800, color: '#2E3B32',
letterSpacing: '-0.5px'
};
// Вторая строка шапки (Сотрудник + Баланс)
const headerRow2Style: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap' };
const headerCardStyle: React.CSSProperties = {
flex: '1 1 220px', display: 'flex', alignItems: 'center', gap: 14, padding: '12px 20px',
background: '#FFFFFF', borderRadius: 16, minWidth: 0, boxShadow: '0 2px 12px rgba(0,0,0,0.03)',
};
const headerLabelStyle: React.CSSProperties = { fontSize: 12, color: '#607D8B', textTransform: 'uppercase', letterSpacing: 0.5, fontWeight: 800 };
const headerValueStyle: React.CSSProperties = { fontSize: 18, fontWeight: 700, color: '#2E3B32', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', marginTop: 2 };
const avatarCircleStyle: React.CSSProperties = { width: 44, height: 44, borderRadius: '50%', background: '#E2F0D9', color: '#4CAF50', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 18, fontWeight: 800, flexShrink: 0 };
const mainZoneStyle: React.CSSProperties = { flex: 1, display: 'flex', minHeight: 0, overflow: 'hidden' };
// Категории
const categoriesPanelStyle: React.CSSProperties = {
width: 220, background: '#FFFFFF', borderRight: '1px solid #E0E6ED',
display: 'flex', flexDirection: 'column', padding: '16px 0', overflowY: 'auto', flexShrink: 0,
};
const categoriesTitleStyle: React.CSSProperties = { fontSize: 13, fontWeight: 800, color: '#607D8B', padding: '0 24px 16px', letterSpacing: 0.5 };
const categoriesListStyle: React.CSSProperties = { flex: 1, overflowY: 'auto' };
const categoryBtnStyle: React.CSSProperties = {
width: '100%', padding: '20px 24px', border: 'none', borderRadius: 0, cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 16, textAlign: 'left', transition: 'all 0.2s',
};
// Сетка блюд
const itemsPanelStyle: React.CSSProperties = {
flex: 1, display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
gap: 20, alignContent: 'start', overflowY: 'auto', padding: '24px', minHeight: 0,
};
const itemCardStyle: React.CSSProperties = {
background: '#FFFFFF', borderRadius: 20, overflow: 'hidden',
boxShadow: '0 4px 20px rgba(0,0,0,0.03)', display: 'flex', flexDirection: 'column', minWidth: 0, transition: 'all 0.2s',
};
const itemImgStyle: React.CSSProperties = {
height: 140, background: '#F7F9F6',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 72, overflow: 'hidden',
};
const itemInfoStyle: React.CSSProperties = { padding: '16px', flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 };
const itemNameStyle: React.CSSProperties = { fontSize: 17, fontWeight: 800, color: '#2E3B32', lineHeight: 1.3 };
const itemDescStyle: React.CSSProperties = {
fontSize: 13, color: '#78909C', marginTop: 6,
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden',
};
const itemBottomStyle: React.CSSProperties = { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 16, gap: 8, flexWrap: 'wrap' };
const itemPriceStyle: React.CSSProperties = { fontSize: 20, fontWeight: 800, color: '#4CAF50' };
const qtyInlineStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 4, background: '#F0F4F8', borderRadius: 999, padding: '4px' };
const qtyItemBtnStyle: React.CSSProperties = {
width: 40, height: 40, borderRadius: '50%', background: '#FFFFFF', border: 'none',
cursor: 'pointer', color: '#4CAF50', display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0, boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
};
const qtyInlineNumStyle: React.CSSProperties = { minWidth: 32, textAlign: 'center', fontWeight: 800, fontSize: 18, color: '#2E3B32' };
const cartBarStyle: React.CSSProperties = {
position: 'fixed', bottom: BOTTOM_BAR_HEIGHT + 12, left: '50%', transform: 'translateX(-50%)',
background: '#2E3B32', color: '#fff', borderRadius: 999, padding: '10px 16px 10px 12px',
display: 'flex', alignItems: 'center', gap: 32, boxShadow: '0 12px 40px rgba(46,59,50,0.3)',
cursor: 'pointer', zIndex: 100, minWidth: 380, justifyContent: 'space-between',
};
// Выезжающая корзина
const cartItemStyle: React.CSSProperties = {
background: '#FFFFFF', borderRadius: 16, padding: 16, display: 'flex', alignItems: 'center', gap: 16,
border: '1px solid #F0F4F8',
};
const cartItemImgStyle: React.CSSProperties = {
width: 60, height: 60, borderRadius: 12, background: '#F7F9F6',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 32, overflow: 'hidden', flexShrink: 0,
};
const cartItemNameStyle: React.CSSProperties = { fontSize: 16, fontWeight: 700, color: '#2E3B32', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' };
const cartItemPriceStyle: React.CSSProperties = { fontSize: 14, color: '#78909C', marginTop: 4, fontWeight: 500 };
const qtyControlsStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 8, background: '#F0F4F8', borderRadius: 999, padding: '4px 6px' };
const qtyBigBtnStyle: React.CSSProperties = {
width: 36, height: 36, borderRadius: '50%', background: '#FFFFFF', border: 'none',
cursor: 'pointer', color: '#2E3B32', display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
};
const qtyNumStyle: React.CSSProperties = { minWidth: 28, textAlign: 'center', fontWeight: 800, fontSize: 16, color: '#2E3B32' };
const removeBtnStyle: React.CSSProperties = { background: 'transparent', border: 'none', color: '#EF5350', cursor: 'pointer', padding: 8 };
const submitBtnStyle: React.CSSProperties = {
color: '#fff', border: 'none', borderRadius: 16, padding: '20px 40px',
fontSize: 22, fontWeight: 800, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 12,
boxShadow: '0 8px 24px rgba(76,175,80,0.3)', transition: 'all 0.2s',
};
const errorStyle: React.CSSProperties = { background: '#FFEBEE', color: '#C62828', padding: '12px 18px', borderRadius: 12, fontSize: 16, marginBottom: 16, fontWeight: 600, textAlign: 'center' };
// SUCCESS
const successWrapperStyle: React.CSSProperties = { position: 'fixed', inset: 0, background: 'rgba(46,59,50,0.7)', backdropFilter: 'blur(8px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 300, padding: 20 };
const successCardStyle: React.CSSProperties = { background: '#FFFFFF', borderRadius: 32, padding: '56px 64px', textAlign: 'center', maxWidth: 600, boxShadow: '0 24px 80px rgba(0,0,0,0.2)' };
const successBtnStyle: React.CSSProperties = { padding: '20px 64px', background: 'linear-gradient(135deg, #81C784 0%, #4CAF50 100%)', color: '#fff', border: 'none', borderRadius: 16, fontSize: 22, fontWeight: 800, cursor: 'pointer', boxShadow: '0 8px 24px rgba(76,175,80,0.3)' };
export default FoodMenu;
+2 -1
View File
@@ -19,7 +19,8 @@ const MainMenu: React.FC<MainMenuProps> = ({ onSectionSelect, employee }) => {
{ id: 'labor-safety', title: 'Охрана труда', emoji: '⛑️' },
{ id: 'contacts', title: 'Контакты', emoji: '📞' },
{ id: 'news', title: 'Новости', emoji: '📰' },
{ id: 'profile', title: 'Обо мне', emoji: '👤' },
{ id: 'profile', title: 'Лучший по профессии', emoji: '👤' },
{ id: 'food', title: 'Питание', emoji: '🍔' },
];
return (
@@ -0,0 +1,323 @@
import React, { useState, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { FaTimes, FaPrint, FaSpinner, FaCheckCircle, FaExclamationCircle } from 'react-icons/fa';
interface PayslipFormPreviewProps {
tnom: string;
period: string;
onClose: () => void;
}
const PayslipFormPreview: React.FC<PayslipFormPreviewProps> = ({ tnom, period, onClose }) => {
const [htmlContent, setHtmlContent] = useState<string>('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isPrinting, setIsPrinting] = useState(false);
const [printStatus, setPrintStatus] = useState<'idle' | 'success' | 'error' | 'sending'>('idle');
const [printMessage, setPrintMessage] = useState('');
const iframeRef = useRef<HTMLIFrameElement>(null);
const BACKEND_URL = 'http://localhost:3001';
useEffect(() => {
const loadForm = async () => {
try {
setLoading(true);
setError(null);
// ✅ Загружаем HTML-форму из бэкенда
const response = await fetch(`${BACKEND_URL}/api/payslip/form/${tnom}?period=${period}`);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Ошибка загрузки формы');
}
const html = await response.text();
setHtmlContent(html);
} catch (err) {
console.error('[PayslipFormPreview] Ошибка:', err);
setError(err instanceof Error ? err.message : 'Неизвестная ошибка');
} finally {
setLoading(false);
}
};
loadForm();
}, [tnom, period]);
const handlePrint = async () => {
try {
setIsPrinting(true);
setPrintStatus('sending');
setPrintMessage('Подготовка документа к печати...');
if (!htmlContent || htmlContent.length < 10) {
throw new Error('HTML контент пустой или слишком короткий');
}
const electronAPI = (window as any).electronAPI;
if (electronAPI && electronAPI.printPayslipForm) {
console.log('[Print] Печать через Electron API');
console.log('[Print] Длина HTML:', htmlContent.length);
const result = await electronAPI.printPayslipForm({
formHtml: htmlContent,
printerName: 'GP-80250 Series'
});
console.log('[Print] Результат:', result);
if (result && result.success === false) {
throw new Error(result.error || 'Ошибка печати');
}
setPrintStatus('success');
setPrintMessage(`✅ Документ отправлен на печать`);
} else {
console.log('[Print] Electron API не доступен');
setPrintStatus('error');
setPrintMessage('❌ Electron API не доступен');
}
} catch (error) {
console.error('[Print] Ошибка:', error);
setPrintStatus('error');
setPrintMessage('❌ Ошибка: ' + (error instanceof Error ? error.message : 'Неизвестная ошибка'));
} finally {
setIsPrinting(false);
setTimeout(() => {
setPrintStatus('idle');
setPrintMessage('');
}, 5000);
}
};
return (
<>
<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.6)',
backdropFilter: 'blur(8px)',
zIndex: 9999,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px',
}}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<motion.div
initial={{ scale: 0.9, y: 30 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.9, y: 30 }}
transition={{ type: 'spring', damping: 25 }}
style={{
background: '#fff',
borderRadius: '16px',
width: '100%',
maxWidth: '500px',
maxHeight: '90vh',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
}}
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,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<span style={{ fontSize: '20px' }}>📄</span>
<span style={{ color: '#fff', 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',
}}>
{tnom}
</span>
</div>
<button
onClick={onClose}
style={{
background: 'rgba(255,255,255,0.2)',
border: 'none',
borderRadius: '8px',
color: '#fff',
cursor: 'pointer',
padding: '4px 12px',
fontSize: '20px',
transition: 'all 0.3s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.3)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.2)';
}}
>
<FaTimes />
</button>
</div>
{/* Контент */}
<div style={{
flex: 1,
overflow: 'auto',
padding: '20px',
background: '#f5f6fa',
}}>
{loading ? (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '40px',
gap: '15px',
}}>
<FaSpinner style={{ animation: 'spin 1s linear infinite', fontSize: '32px', color: '#667eea' }} />
<span style={{ color: '#636e72' }}>Загрузка формы...</span>
</div>
) : error ? (
<div style={{
textAlign: 'center',
padding: '40px',
color: '#e74c3c',
}}>
<div style={{ fontSize: '48px', marginBottom: '10px' }}>❌</div>
<p>{error}</p>
</div>
) : (
<div style={{
background: '#fff',
borderRadius: '8px',
padding: '16px',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
overflow: 'auto',
maxHeight: '60vh',
}}>
{/* ✅ Показываем HTML-форму как есть */}
<div dangerouslySetInnerHTML={{ __html: htmlContent }} />
<iframe
ref={iframeRef}
srcDoc={htmlContent}
style={{ display: 'none' }}
title="Print Frame"
/>
</div>
)}
</div>
{/* Кнопка печати */}
<div style={{
padding: '16px 24px',
borderTop: '1px solid #e9ecef',
display: 'flex',
justifyContent: 'center',
flexShrink: 0,
}}>
<button
onClick={handlePrint}
disabled={isPrinting || !!error || !htmlContent}
style={{
padding: '12px 40px',
background: isPrinting || error || !htmlContent ? '#b2bec3' : 'linear-gradient(135deg, #00b894, #00cec9)',
color: '#fff',
border: 'none',
borderRadius: '10px',
cursor: isPrinting || error || !htmlContent ? 'not-allowed' : 'pointer',
fontSize: '16px',
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: '10px',
transition: 'all 0.3s ease',
boxShadow: isPrinting || error || !htmlContent ? 'none' : '0 4px 15px rgba(0, 206, 201, 0.3)',
opacity: isPrinting || error || !htmlContent ? 0.6 : 1,
}}
>
{isPrinting ? (
<>
<FaSpinner style={{ animation: 'spin 1s linear infinite' }} />
Печать...
</>
) : (
<>
<FaPrint />
Печать
</>
)}
</button>
</div>
</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 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -50 }}
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,
}}
>
{printStatus === 'sending' && <FaSpinner style={{ animation: 'spin 1s linear infinite' }} />}
{printStatus === 'success' && <FaCheckCircle />}
{printStatus === 'error' && <FaExclamationCircle />}
{printMessage}
</motion.div>
)}
</AnimatePresence>
</>
);
};
export default PayslipFormPreview;
@@ -0,0 +1,106 @@
import React, { useEffect, useRef } from 'react';
import { motion } from 'framer-motion';
interface PayslipFormPrintProps {
htmlContent: string;
onPrintComplete: () => void;
printerName?: string;
}
const PayslipFormPrint: React.FC<PayslipFormPrintProps> = ({
htmlContent,
onPrintComplete,
printerName = 'GP-80250 Series'
}) => {
const iframeRef = useRef<HTMLIFrameElement>(null);
useEffect(() => {
const printIframe = async () => {
try {
// ✅ Используем (window as any) для обхода типов
const electronAPI = (window as any).electronAPI;
if (electronAPI && electronAPI.printPayslipForm) {
console.log('[Print] Печать через Electron API');
await electronAPI.printPayslipForm({
formHtml: htmlContent,
printerName: printerName
});
console.log('[Print] Форма отправлена на печать');
} else {
console.log('[Print] Electron API не доступен, используем fallback');
const iframe = iframeRef.current;
if (iframe) {
iframe.onload = () => {
setTimeout(() => {
iframe.contentWindow?.print();
}, 500);
};
}
}
} catch (error) {
console.error('[PayslipFormPrint] Ошибка:', error);
} finally {
setTimeout(onPrintComplete, 3000);
}
};
printIframe();
}, [htmlContent, printerName, onPrintComplete]);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 0.5 }}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
zIndex: 9999,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div style={{
background: '#fff',
padding: '40px',
borderRadius: '16px',
textAlign: 'center',
maxWidth: '400px',
}}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🖨️</div>
<h3 style={{ margin: '0 0 8px 0', color: '#2d3436' }}>Печать...</h3>
<p style={{ margin: 0, color: '#636e72', fontSize: '14px' }}>
Документ отправляется на печать
</p>
<div style={{
width: '40px',
height: '40px',
margin: '20px auto 0',
border: '4px solid #f3f3f3',
borderTop: '4px solid #667eea',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
}} />
</div>
<style>{`
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
<iframe
ref={iframeRef}
srcDoc={htmlContent}
style={{ display: 'none' }}
title="Print Frame"
/>
</motion.div>
);
};
export default PayslipFormPrint;
File diff suppressed because it is too large Load Diff
+241 -283
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
import { FaChevronLeft, FaChevronRight, FaIdCard, FaKeyboard } from 'react-icons/fa';
interface VideoBackgroundProps {
onComplete: () => void;
@@ -19,20 +19,21 @@ interface Slide {
updated_at?: string;
}
const VideoBackground: React.FC<VideoBackgroundProps> = ({
onComplete,
videoSrc = '/assets/videos/flag.mp4'
const ADMIN_SERVER_URL = 'http://localhost:3005';
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 [showHint, setShowHint] = useState(false);
const autoPlayInterval = useRef<NodeJS.Timeout | null>(null);
const ADMIN_SERVER_URL = 'http://localhost:3005';
// ✅ Загрузка слайдов с сервера модератора
// ---------- Загрузка слайдов ----------
useEffect(() => {
const fetchSlides = async () => {
try {
@@ -40,151 +41,120 @@ const VideoBackground: React.FC<VideoBackgroundProps> = ({
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))
const sorted = Array.isArray(data)
? data
.filter((s: Slide) => s.is_active !== 0)
.sort((a: Slide, b: Slide) => (a.order || 0) - (b.order || 0))
: [];
setSlides(sortedSlides);
setSlides(sorted.length ? sorted : getDefaultSlides());
} else {
console.warn('Не удалось загрузить слайды, используем тестовые');
setSlides(getDefaultSlides());
}
} catch (error) {
console.warn('Ошибка загрузки слайдов:', error);
} catch {
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
},
];
};
const getDefaultSlides = (): Slide[] => [
{ 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);
setTimeout(onComplete, 5000);
videoRef.current.play().catch(() => {
// если видео не проигрывается (нет файла) — ничего не делаем,
// пользователь сам нажмёт кнопку входа
});
}
}, [onComplete]);
}, []);
// ✅ Автопрокрутка слайдов
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);
if (slides.length <= 1) return;
setCurrentIndex((p) => (p + 1) % slides.length);
};
const goToPrev = () => {
setCurrentIndex((prev) => (prev - 1 + slides.length) % slides.length);
if (slides.length <= 1) return;
setCurrentIndex((p) => (p - 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);
if (autoPlayInterval.current) clearInterval(autoPlayInterval.current);
if (slides.length > 1 && isAutoPlay) {
autoPlayInterval.current = setInterval(goToNext, 5000);
}
};
// ---------- Автопрокрутка ----------
useEffect(() => {
if (slides.length <= 1 || !isAutoPlay) return;
autoPlayInterval.current = setInterval(goToNext, 5000);
return () => {
if (autoPlayInterval.current) clearInterval(autoPlayInterval.current);
};
}, [slides.length, isAutoPlay, currentIndex]);
// ---------- Клавиатура ----------
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') { e.preventDefault(); goToPrev(); }
else if (e.key === 'ArrowRight') { e.preventDefault(); goToNext(); }
else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onComplete(); }
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onComplete, slides.length]);
// ---------- Ховер по краям — пауза автопрокрутки ----------
const handleMouseEnter = () => {
setIsAutoPlay(false);
if (autoPlayInterval.current) {
clearInterval(autoPlayInterval.current);
}
if (autoPlayInterval.current) clearInterval(autoPlayInterval.current);
};
const handleMouseLeave = () => {
setIsAutoPlay(true);
resetAutoPlay();
};
const handleClick = () => {
// ---------- Переход к вводу ID ----------
const handleEnterClick = (e: React.MouseEvent) => {
e.stopPropagation();
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',
position: 'fixed', inset: 0, zIndex: 9999,
background: '#000', display: 'flex',
alignItems: 'center', justifyContent: 'center',
}}>
<div style={{
width: '60px',
height: '60px',
width: 60, height: 60,
border: '4px solid #2d2d2d',
borderTop: '4px solid #667eea',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
}} />
<style>{`@keyframes spin { from{transform:rotate(0)} to{transform:rotate(360deg)} }`}</style>
</div>
);
}
const hasSlides = slides.length > 0;
const current = hasSlides ? slides[currentIndex] : null;
return (
<motion.div
@@ -193,63 +163,36 @@ const VideoBackground: React.FC<VideoBackgroundProps> = ({
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 9999,
position: 'fixed', inset: 0, zIndex: 9999,
background: '#000',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
display: 'flex', alignItems: 'center', justifyContent: 'center',
overflow: 'hidden',
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
>
{/* ✅ ВИДЕО (фон) */}
{/* ============ ВИДЕО ФОН ============ */}
<video
ref={videoRef}
src={videoSrc}
autoPlay
muted
playsInline
loop
autoPlay muted playsInline loop
style={{
position: 'absolute',
width: '100%',
height: '100%',
objectFit: 'cover',
zIndex: 0,
position: 'absolute', width: '100%', height: '100%',
objectFit: 'cover', zIndex: 0,
}}
/>
{/* ✅ ТЕМНЫЙ ОВЕРЛЕЙ */}
{/* ============ ТЁМНЫЙ ОВЕРЛЕЙ ============ */}
<div style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.35)',
zIndex: 1,
position: 'absolute', inset: 0,
background: 'rgba(0, 0, 0, 0.45)', zIndex: 1,
}} />
{/* ✅ КАРУСЕЛЬ ИЗОБРАЖЕНИЙ */}
{/* ============ КАРУСЕЛЬ ============ */}
{hasSlides && (
<div style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'absolute', inset: 0, zIndex: 2,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<AnimatePresence mode="wait">
<motion.div
@@ -259,9 +202,9 @@ const VideoBackground: React.FC<VideoBackgroundProps> = ({
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.5 }}
style={{
width: '80%',
width: '78%',
maxWidth: '900px',
maxHeight: '70vh',
maxHeight: '65vh',
borderRadius: '20px',
overflow: 'hidden',
boxShadow: '0 30px 80px rgba(0,0,0,0.5)',
@@ -271,191 +214,154 @@ const VideoBackground: React.FC<VideoBackgroundProps> = ({
}}
>
<img
src={`${ADMIN_SERVER_URL}${slides[currentIndex].image_url}`}
alt={slides[currentIndex].title || 'Слайд'}
src={
current!.image_url.startsWith('http')
? current!.image_url
: `${ADMIN_SERVER_URL}${current!.image_url}`
}
alt={current!.title || 'Слайд'}
crossOrigin="anonymous"
style={{
width: '100%',
height: '100%',
maxHeight: '70vh',
maxHeight: '65vh',
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';
(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) && (
{(current!.title || current!.description) && (
<div style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
position: 'absolute', left: 0, right: 0, bottom: 0,
padding: '30px 40px 40px',
background: 'linear-gradient(transparent, rgba(0,0,0,0.7))',
color: 'white',
background: 'linear-gradient(transparent, rgba(0,0,0,0.75))',
color: '#fff',
}}>
{slides[currentIndex].title && (
{current!.title && (
<h2 style={{
margin: 0,
fontSize: '28px',
margin: 0, fontSize: '28px',
fontWeight: 600,
textShadow: '0 2px 10px rgba(0,0,0,0.3)',
}}>
{slides[currentIndex].title}
{current!.title}
</h2>
)}
{slides[currentIndex].description && (
{current!.description && (
<p style={{
margin: '8px 0 0',
fontSize: '16px',
opacity: 0.8,
margin: '8px 0 0', fontSize: '16px',
opacity: 0.85,
textShadow: '0 2px 8px rgba(0,0,0,0.3)',
}}>
{slides[currentIndex].description}
{current!.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>
)}
{/* ✅ ПОДСКАЗКА */}
{/* ============ КНОПКИ НАВИГАЦИИ ============ */}
{slides.length > 1 && (
<>
<NavArrow direction="left" onClick={goToPrev} />
<NavArrow direction="right" onClick={goToNext} />
</>
)}
{/* ============ ИНДИКАТОРЫ ============ */}
{slides.length > 1 && (
<div style={{
position: 'absolute',
bottom: '120px',
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
gap: '10px',
zIndex: 10,
}}>
{slides.map((_, index) => (
<button
key={index}
onClick={(e) => { e.stopPropagation(); goToSlide(index); }}
aria-label={`Слайд ${index + 1}`}
style={{
width: index === currentIndex ? '28px' : '10px',
height: '10px',
borderRadius: '5px',
background: index === currentIndex
? 'rgba(255,255,255,0.9)'
: 'rgba(255,255,255,0.35)',
border: 'none',
cursor: 'pointer',
transition: 'all 0.3s ease',
padding: 0,
}}
/>
))}
</div>
)}
{/* ============ КНОПКА ВХОДА ============ */}
<div style={{
position: 'absolute',
bottom: '20px',
left: 0,
right: 0,
textAlign: 'center',
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',
bottom: '40px',
left: '50%',
transform: 'translateX(-50%)',
zIndex: 20,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '10px',
}}>
{hasSlides ? 'Нажмите для входа · Для просмотра используйте стрелки' : 'Нажмите для продолжения'}
<motion.button
onClick={handleEnterClick}
whileHover={{ scale: 1.04 }}
whileTap={{ scale: 0.97 }}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '16px 34px',
border: 'none',
borderRadius: '999px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: '#fff',
fontSize: '17px',
fontWeight: 600,
cursor: 'pointer',
boxShadow: '0 10px 30px rgba(102, 126, 234, 0.45)',
letterSpacing: '0.3px',
}}
>
<FaIdCard style={{ fontSize: 20 }} />
Ввод табельного номера
</motion.button>
{/* Подсказка «Enter» (появляется при наведении) */}
<div
onMouseEnter={() => setShowHint(true)}
onMouseLeave={() => setShowHint(false)}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
color: 'rgba(255,255,255,0.5)',
fontSize: 12,
transition: 'opacity 0.2s',
opacity: showHint ? 1 : 0.55,
}}
>
<FaKeyboard /> или нажмите <b style={{ margin: '0 4px' }}>Enter</b>
</div>
</div>
{/* ============ СТИЛИ ============ */}
<style>{`
@keyframes spin {
from { transform: rotate(0deg); }
@@ -466,4 +372,56 @@ const VideoBackground: React.FC<VideoBackgroundProps> = ({
);
};
// ============================================================
// Кнопка-стрелка (вынесена отдельно, чтобы не дублировать стили)
// ============================================================
const NavArrow: React.FC<{
direction: 'left' | 'right';
onClick: () => void;
}> = ({ direction, onClick }) => {
const isLeft = direction === 'left';
return (
// ── Позиционирование — на обычном div, БЕЗ framer-motion ──
<div
style={{
position: 'absolute',
top: '50%',
[isLeft ? 'left' : 'right']: '25px',
transform: 'translateY(-50%)',
zIndex: 10,
}}
>
{/* ── Анимация — только scale, без translate ── */}
<motion.button
onClick={(e) => { e.stopPropagation(); onClick(); }}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
aria-label={isLeft ? 'Предыдущий слайд' : 'Следующий слайд'}
style={{
width: 56,
height: 56,
borderRadius: '50%',
background: 'rgba(255,255,255,0.12)',
backdropFilter: 'blur(10px)',
WebkitBackdropFilter: 'blur(10px)',
border: '1px solid rgba(255,255,255,0.2)',
color: '#fff',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 22,
transition: 'background 0.2s ease',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,0.25)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,0.12)')}
>
{isLeft ? <FaChevronLeft /> : <FaChevronRight />}
</motion.button>
</div>
);
};
export default VideoBackground;
+18
View File
@@ -0,0 +1,18 @@
// src/types/electron.d.ts
interface ElectronAPI {
getAppConfig: () => Promise<any>;
onCardDetected: (callback: (uid: string) => void) => void;
removeCardDetectedListeners: () => void;
printPdf: (options: { pdfUrl: string; deviceName?: string }) => Promise<void>;
getPrinters: () => Promise<Array<{ name: string; displayName: string; isDefault: boolean; status: string }>>;
printPayslipForm: (data: { formHtml: string; printerName?: string }) => Promise<void>;
}
declare global {
interface Window {
electronAPI?: ElectronAPI;
}
}
export {};
+59
View File
@@ -0,0 +1,59 @@
// ============================================
// 📄 ВСПОМОГАТЕЛЬ ДЛЯ ПЕЧАТИ
// ============================================
import { ipcRenderer } from 'electron';
/**
* Печать формы расчетного листка через основной процесс
*/
export async function printPayslipForm(html: string, printerName?: string) {
try {
// ✅ ПЫТАЕМСЯ НАПРЯМУЮ ПЕЧАТЬ HTML
try {
const result = await ipcRenderer.invoke('print-html-direct', {
html,
printerName: printerName || 'GP-80250 Series',
customCss: `
.header { font-size: 10pt !important; }
.item-row { font-size: 7pt !important; }
.total-row { font-size: 9pt !important; }
.info-row { font-size: 7pt !important; }
.footer { font-size: 6pt !important; }
`
});
if (result) {
console.log('[PRINT] Успешно напечатано через прямой HTML');
return result;
}
} catch (e) {
console.warn('[PRINT] Прямая печать не удалась, пробуем через PDF:', e);
}
// ✅ ЕСЛИ НЕ ПОЛУЧИЛОСЬ - ПЕЧАТАЕМ ЧЕРЕЗ PDF
const result = await ipcRenderer.invoke('print-payslip-form', {
formHtml: html,
printerName: printerName || 'GP-80250 Series'
});
console.log('[PRINT] Успешно напечатано через PDF');
return result;
} catch (error) {
console.error('[PRINT] Ошибка печати:', error);
throw error;
}
}
/**
* Получение списка принтеров
*/
export async function getPrinters() {
try {
return await ipcRenderer.invoke('get-printers');
} catch (error) {
console.error('[PRINT] Ошибка получения принтеров:', error);
return [{ name: 'GP-80250 Series', displayName: 'GP-80250 Series' }];
}
}