print_add

This commit is contained in:
2026-09-01 11:47:15 +03:00
parent 1fe0d67731
commit 70a20665ae
38 changed files with 5494 additions and 889 deletions
+82 -82
View File
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
import 'quill/dist/quill.snow.css';
import '../App.css';
import DOMPurify from 'dompurify';
import SlidesManager from './SlidesManager';
function Editor() {
const [isLoggedIn, setIsLoggedIn] = useState(false);
@@ -15,7 +16,6 @@ function Editor() {
const [editContent, setEditContent] = useState('');
const [editPreviewContent, setEditPreviewContent] = useState('');
const [showConfirmModal, setShowConfirmModal] = useState(false);
//const [csrfToken, setCsrfToken] = useState('');
const [notify, setNotify] = useState({ message: '', type: '', show: false });
const editorRef = useRef(null);
@@ -30,17 +30,6 @@ function Editor() {
}, 2200);
}, []);
// Fetch CSRF token
//useEffect(() => {
// fetch('/moders/csrf-token')
// .then(res => res.json())
// .then(data => {
// setCsrfToken(data.csrfToken);
//})
//.catch(() => {});
//}, []);
// Check session on mount
useEffect(() => {
fetch('/moders/check-session', { credentials: 'include' })
@@ -55,9 +44,9 @@ function Editor() {
.catch(() => setIsLoggedIn(false));
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const authenticatedFetch = useCallback((url, options = {}) => {
return fetch(url, options);
}, []);
const authenticatedFetch = useCallback((url, options = {}) => {
return fetch(url, options);
}, []);
const loadBlocks = useCallback(async (tab) => {
try {
@@ -105,7 +94,7 @@ const authenticatedFetch = useCallback((url, options = {}) => {
const handleTabChange = (tab) => {
setActiveTab(tab);
if (isLoggedIn) {
if (isLoggedIn && tab !== 'slides') {
loadBlocks(tab);
}
};
@@ -138,41 +127,35 @@ const authenticatedFetch = useCallback((url, options = {}) => {
};
const handleSaveBlock = async (e) => {
e.preventDefault();
const content = DOMPurify.sanitize(editContent.trim());
if (!content || content === '<p><br></p>') return;
e.preventDefault();
const content = DOMPurify.sanitize(editContent.trim());
if (!content || content === '<p><br></p>') return;
const url = editingBlockId
? `/moders/content/${activeTab}/${editingBlockId}`
: `/moders/content/${activeTab}`;
const url = editingBlockId
? `/moders/content/${activeTab}/${editingBlockId}`
: `/moders/content/${activeTab}`;
const method = editingBlockId ? 'PUT' : 'POST';
const method = editingBlockId ? 'PUT' : 'POST';
console.log('➡️ Отправка:', method, url, { type: 'text', content });
try {
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'text', content })
});
try {
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'text', content })
});
const data = await response.json();
console.log('⬅️ Ответ сервера:', response.status, data);
if (response.ok) {
showNotify(editingBlockId ? 'Блок обновлён' : 'Блок добавлен', 'success');
setShowEditModal(false);
loadBlocks(activeTab);
} else {
showNotify(`Ошибка: ${data.message || response.status}`, 'error');
if (response.ok) {
showNotify(editingBlockId ? 'Блок обновлён' : 'Блок добавлен', 'success');
setShowEditModal(false);
loadBlocks(activeTab);
} else {
const data = await response.json();
showNotify(`Ошибка: ${data.message || response.status}`, 'error');
}
} catch (e) {
showNotify('Ошибка сохранения', 'error');
}
} catch (e) {
console.error('❌ Ошибка при сохранении:', e);
showNotify('Ошибка сохранения', 'error');
}
};
};
const handleTryCloseEditModal = () => {
if (editContent !== originalContentRef.current) {
@@ -194,7 +177,6 @@ const authenticatedFetch = useCallback((url, options = {}) => {
// Initialize Quill when modal opens
useEffect(() => {
if (showEditModal && editorRef.current && !quillRef.current) {
// Dynamically import Quill
import('quill').then((QuillModule) => {
const Quill = QuillModule.default || QuillModule;
if (editorRef.current) {
@@ -225,51 +207,68 @@ const authenticatedFetch = useCallback((url, options = {}) => {
});
}
// Cleanup Quill when modal closes
if (!showEditModal && quillRef.current) {
quillRef.current = null;
}
}, [showEditModal, editContent]);
// Если не авторизован — показываем форму входа
if (!isLoggedIn) {
return (
<div className="container">
<h1>Панель модератора</h1>
<div id="login-section">
<h2>Вход для модератора</h2>
<form id="moder-login-form" onSubmit={handleLogin}>
<div className="form-group">
<label htmlFor="username">Логин:</label>
<input type="text" id="username" name="username" required value={username} onChange={(e) => setUsername(e.target.value)} />
</div>
<div className="form-group">
<label htmlFor="password">Пароль:</label>
<input type="password" id="password" name="password" required value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
<button type="submit">Войти</button>
{loginError && <p id="login-error" style={{ color: 'red' }}>{loginError}</p>}
</form>
</div>
</div>
);
}
// Авторизован — показываем редактор
return (
<>
<div id="notify" className={notify.show ? `show ${notify.type}` : ''}>{notify.message}</div>
<div className="container">
<h1>Панель модератора</h1>
{!isLoggedIn ? (
<div id="login-section">
<h2>Вход для модератора</h2>
<form id="moder-login-form" onSubmit={handleLogin}>
<div className="form-group">
<label htmlFor="username">Логин:</label>
<input type="text" id="username" name="username" required value={username} onChange={(e) => setUsername(e.target.value)} />
</div>
<div className="form-group">
<label htmlFor="password">Пароль:</label>
<input type="password" id="password" name="password" required value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
<button type="submit">Войти</button>
{loginError && <p id="login-error" style={{ color: 'red' }}>{loginError}</p>}
</form>
</div>
<button id="logout-btn" style={{ float: 'right' }} onClick={handleLogout}>Выйти из системы</button>
<h2>Редактирование вкладок</h2>
<div className="tab-controls">
<button className={`tab-btn ${activeTab === 'info' ? 'active' : ''}`} onClick={() => handleTabChange('info')} data-tab="info">
Общая информация
</button>
<button className={`tab-btn ${activeTab === 'events' ? 'active' : ''}`} onClick={() => handleTabChange('events')} data-tab="events">
📅 Мероприятия
</button>
<button className={`tab-btn ${activeTab === 'help' ? 'active' : ''}`} onClick={() => handleTabChange('help')} data-tab="help">
💰 Материальная помощь
</button>
<button className={`tab-btn ${activeTab === 'housing' ? 'active' : ''}`} onClick={() => handleTabChange('housing')} data-tab="housing">
🏠 Жилищные условия
</button>
{/* ✅ НОВАЯ ВКЛАДКА СЛАЙДЫ */}
<button className={`tab-btn ${activeTab === 'slides' ? 'active' : ''}`} onClick={() => setActiveTab('slides')} data-tab="slides">
📸 Слайды
</button>
</div>
{/* Рендеринг контента вкладок */}
{activeTab === 'slides' ? (
<SlidesManager />
) : (
<div id="editor-section">
<button id="logout-btn" style={{ float: 'right' }} onClick={handleLogout}>Выйти из системы</button>
<h2>Редактирование вкладок</h2>
<div className="tab-controls">
<button className={`tab-btn ${activeTab === 'info' ? 'active' : ''}`} onClick={() => handleTabChange('info')} data-tab="info">
Общая информация
</button>
<button className={`tab-btn ${activeTab === 'events' ? 'active' : ''}`} onClick={() => handleTabChange('events')} data-tab="events">
📅 Мероприятия
</button>
<button className={`tab-btn ${activeTab === 'help' ? 'active' : ''}`} onClick={() => handleTabChange('help')} data-tab="help">
💰 Материальная помощь
</button>
<button className={`tab-btn ${activeTab === 'housing' ? 'active' : ''}`} onClick={() => handleTabChange('housing')} data-tab="housing">
🏠 Жилищные условия
</button>
</div>
<>
<div style={{ margin: '18px 0' }}>
<button id="add-block-btn" onClick={handleAddBlock}>Добавить блок</button>
</div>
@@ -283,9 +282,10 @@ const authenticatedFetch = useCallback((url, options = {}) => {
</div>
))}
</div>
</div>
</>
)}
{/* Модалка редактирования блоков */}
{showEditModal && (
<div id="edit-modal" className="modal" style={{ display: 'block' }} onClick={(e) => {
if (e.target === e.currentTarget) handleTryCloseEditModal();
@@ -0,0 +1,395 @@
// src/components/SlidesManager.jsx
import React, { useState, useEffect, useCallback } from 'react';
import DOMPurify from 'dompurify';
function SlidesManager() {
const [slides, setSlides] = useState([]);
const [loading, setLoading] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [editingSlide, setEditingSlide] = useState(null);
const [formData, setFormData] = useState({
image_url: '',
title: '',
description: '',
link: '',
order: 0,
is_active: 1
});
const [uploading, setUploading] = useState(false);
const [notify, setNotify] = useState({ message: '', type: '', show: false });
const showNotify = useCallback((message, type = 'success') => {
setNotify({ message, type, show: true });
setTimeout(() => {
setNotify({ message: '', type: '', show: false });
}, 2500);
}, []);
// Загрузка слайдов
const loadSlides = useCallback(async () => {
setLoading(true);
try {
const res = await fetch('/moders/content/slides/all', {
credentials: 'include'
});
if (res.ok) {
const data = await res.json();
setSlides(Array.isArray(data) ? data : []);
} else {
showNotify('Ошибка загрузки слайдов', 'error');
}
} catch (e) {
showNotify('Ошибка загрузки слайдов', 'error');
} finally {
setLoading(false);
}
}, [showNotify]);
useEffect(() => {
loadSlides();
}, [loadSlides]);
// Открыть модалку для добавления
const handleAddSlide = () => {
setEditingSlide(null);
setFormData({
image_url: '',
title: '',
description: '',
link: '',
order: slides.length,
is_active: 1
});
setShowEditModal(true);
};
// Открыть модалку для редактирования
const handleEditSlide = (slide) => {
setEditingSlide(slide);
setFormData({
image_url: slide.image_url || '',
title: slide.title || '',
description: slide.description || '',
link: slide.link || '',
order: slide.order || 0,
is_active: slide.is_active !== undefined ? slide.is_active : 1
});
setShowEditModal(true);
};
// Сохранить слайд
// В функции handleSaveSlide
const handleSaveSlide = async (e) => {
e.preventDefault();
// ✅ ЛОГИ ДЛЯ ОТЛАДКИ
console.log('========================================');
console.log('📤 ОТПРАВКА ДАННЫХ НА СЕРВЕР');
console.log('📤 editingSlide:', editingSlide);
console.log('📤 formData:', JSON.stringify(formData, null, 2));
console.log('========================================');
if (!formData.image_url) {
showNotify('URL изображения обязателен', 'error');
return;
}
const url = editingSlide
? `/moders/content/slides/${editingSlide.id}`
: '/moders/content/slides';
const method = editingSlide ? 'PUT' : 'POST';
try {
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(formData)
});
const data = await res.json();
console.log('📥 ОТВЕТ СЕРВЕРА:', res.status, data);
if (res.ok) {
showNotify(editingSlide ? 'Слайд обновлён' : 'Слайд добавлен', 'success');
setShowEditModal(false);
loadSlides();
} else {
showNotify(data.message || `Ошибка: ${res.status}`, 'error');
}
} catch (e) {
console.error('❌ Ошибка:', e);
showNotify('Ошибка сохранения', 'error');
}
};
// Удалить слайд
const handleDeleteSlide = async (id) => {
if (!window.confirm('Удалить этот слайд?')) return;
try {
const res = await fetch(`/moders/content/slides/${id}`, {
method: 'DELETE',
credentials: 'include'
});
if (res.ok) {
showNotify('Слайд удалён', 'success');
loadSlides();
} else {
showNotify('Ошибка удаления', 'error');
}
} catch (e) {
showNotify('Ошибка удаления', 'error');
}
};
// Загрузка изображения
const handleUploadImage = async (file) => {
if (!file) return;
const formData = new FormData();
formData.append('image', file);
setUploading(true);
try {
const res = await fetch('/moders/content/slides/upload', {
method: 'POST',
credentials: 'include',
body: formData
});
if (res.ok) {
const data = await res.json();
setFormData(prev => ({ ...prev, image_url: data.path }));
showNotify('Изображение загружено', 'success');
} else {
showNotify('Ошибка загрузки изображения', 'error');
}
} catch (e) {
showNotify('Ошибка загрузки изображения', 'error');
} finally {
setUploading(false);
}
};
return (
<div id="slides-manager">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<h2>📸 Слайды карусели</h2>
<button id="add-slide-btn" onClick={handleAddSlide}>+ Добавить слайд</button>
</div>
{/* Уведомления */}
{notify.show && (
<div className={`notify ${notify.type}`} style={{
padding: '10px 16px',
marginBottom: '15px',
borderRadius: '8px',
background: notify.type === 'success' ? '#d4edda' : '#f8d7da',
color: notify.type === 'success' ? '#155724' : '#721c24',
border: `1px solid ${notify.type === 'success' ? '#c3e6cb' : '#f5c6cb'}`
}}>
{notify.message}
</div>
)}
{/* Список слайдов */}
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>Загрузка...</div>
) : slides.length === 0 ? (
<p style={{ color: '#999', textAlign: 'center', padding: '40px' }}>Нет слайдов. Добавьте первый!</p>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))', gap: '20px' }}>
{slides.map(slide => (
<div key={slide.id} style={{
background: '#fff',
borderRadius: '12px',
overflow: 'hidden',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
border: `2px solid ${slide.is_active ? '#28a745' : '#dc3545'}`,
position: 'relative'
}}>
<img
src={slide.image_url}
alt={slide.title || 'Слайд'}
style={{ width: '100%', height: '150px', objectFit: 'cover' }}
onError={(e) => {
e.target.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="%23ddd"/%3E%3Ctext x="50%25" y="50%25" text-anchor="middle" dy=".3em" fill="%23999" font-size="14"%3E%D0%9D%D0%B5%D1%82%20%D0%B8%D0%B7%D0%BE%D0%B1%D1%80%D0%B0%D0%B6%D0%B5%D0%BD%D0%B8%D1%8F%3C/text%3E%3C/svg%3E';
}}
/>
<div style={{ padding: '12px 16px' }}>
<h4 style={{ margin: '0 0 4px 0', fontSize: '16px' }}>{slide.title || 'Без названия'}</h4>
<p style={{ margin: '0 0 8px 0', fontSize: '13px', color: '#666', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{slide.description || ''}
</p>
<div style={{ display: 'flex', gap: '8px', fontSize: '12px', color: '#999', marginBottom: '10px' }}>
<span>Порядок: {slide.order}</span>
<span>•</span>
<span style={{ color: slide.is_active ? '#28a745' : '#dc3545' }}>
{slide.is_active ? 'Активен' : 'Неактивен'}
</span>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn-small" onClick={() => handleEditSlide(slide)}>✏️</button>
<button className="btn-small" onClick={() => handleDeleteSlide(slide.id)}>🗑️</button>
</div>
</div>
</div>
))}
</div>
)}
{/* Модалка редактирования */}
{showEditModal && (
<div className="modal" style={{
display: 'block',
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
backdropFilter: 'blur(4px)',
zIndex: 2000,
padding: '20px',
overflowY: 'auto'
}} onClick={(e) => {
if (e.target === e.currentTarget) setShowEditModal(false);
}}>
<div className="modal-content" style={{
maxWidth: '700px',
margin: '40px auto',
background: '#fff',
borderRadius: '16px',
padding: '30px',
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
position: 'relative'
}}>
<button className="close" style={{
position: 'absolute',
top: '12px',
right: '20px',
fontSize: '28px',
cursor: 'pointer',
background: 'none',
border: 'none',
color: '#999'
}} onClick={() => setShowEditModal(false)}>&times;</button>
<h3 style={{ marginBottom: '20px' }}>
{editingSlide ? 'Редактировать слайд' : 'Добавить слайд'}
</h3>
<form onSubmit={handleSaveSlide}>
<div className="form-group" style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 500 }}>
Изображение URL или загрузить:
</label>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
<input
type="text"
value={formData.image_url}
onChange={(e) => setFormData(prev => ({ ...prev, image_url: e.target.value }))}
placeholder="https://example.com/image.jpg"
style={{ flex: 1, padding: '8px 12px', borderRadius: '8px', border: '1px solid #ddd' }}
/>
<label style={{
padding: '8px 16px',
background: '#6c757d',
color: '#fff',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '13px'
}}>
{uploading ? 'Загрузка...' : '📤 Загрузить'}
<input
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleUploadImage(file);
}}
/>
</label>
</div>
</div>
<div className="form-group" style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 500 }}>Заголовок:</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
placeholder="Заголовок слайда"
style={{ width: '100%', padding: '8px 12px', borderRadius: '8px', border: '1px solid #ddd' }}
/>
</div>
<div className="form-group" style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 500 }}>Описание:</label>
<textarea
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
placeholder="Описание слайда"
rows="3"
style={{ width: '100%', padding: '8px 12px', borderRadius: '8px', border: '1px solid #ddd' }}
/>
</div>
<div className="form-group" style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 500 }}>Ссылка (опционально):</label>
<input
type="text"
value={formData.link}
onChange={(e) => setFormData(prev => ({ ...prev, link: e.target.value }))}
placeholder="https://example.com"
style={{ width: '100%', padding: '8px 12px', borderRadius: '8px', border: '1px solid #ddd' }}
/>
</div>
<div style={{ display: 'flex', gap: '20px', marginBottom: '15px' }}>
<div className="form-group" style={{ flex: 1 }}>
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 500 }}>Порядок:</label>
<input
type="number"
value={formData.order}
onChange={(e) => setFormData(prev => ({ ...prev, order: parseInt(e.target.value) || 0 }))}
min="0"
style={{ width: '100%', padding: '8px 12px', borderRadius: '8px', border: '1px solid #ddd' }}
/>
</div>
<div className="form-group" style={{ flex: 1 }}>
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 500 }}>Статус:</label>
<select
value={formData.is_active}
onChange={(e) => setFormData(prev => ({ ...prev, is_active: parseInt(e.target.value) }))}
style={{ width: '100%', padding: '8px 12px', borderRadius: '8px', border: '1px solid #ddd' }}
>
<option value="1">Активен</option>
<option value="0">Неактивен</option>
</select>
</div>
</div>
<button type="submit" style={{
padding: '10px 30px',
background: '#6c5ce7',
color: '#fff',
border: 'none',
borderRadius: '10px',
cursor: 'pointer',
fontSize: '15px',
fontWeight: 500
}}>
{editingSlide ? 'Обновить' : 'Добавить'}
</button>
</form>
</div>
</div>
)}
</div>
);
}
export default SlidesManager;
+319
View File
@@ -2017,3 +2017,322 @@
[2026-08-18T13:21:48.177Z] [INFO] Login successful
[2026-08-18T13:21:48.283Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
[2026-08-18T13:21:48.296Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
[2026-08-19T06:49:14.829Z] [INFO] Server running on http://localhost:3005
[2026-08-19T06:49:14.837Z] [INFO] Card logs initialized successfully
[2026-08-19T08:16:23.702Z] [INFO] Server running on http://localhost:3005
[2026-08-19T08:16:23.706Z] [INFO] Card logs initialized successfully
[2026-08-19T09:32:26.775Z] [INFO] Server running on http://localhost:3005
[2026-08-19T09:32:26.778Z] [INFO] Card logs initialized successfully
[2026-08-19T09:54:13.538Z] [INFO] Server running on http://localhost:3005
[2026-08-19T09:54:13.541Z] [INFO] Card logs initialized successfully
[2026-08-19T10:03:57.687Z] [INFO] Server running on http://localhost:3005
[2026-08-19T10:03:57.691Z] [INFO] Card logs initialized successfully
[2026-08-19T13:16:59.418Z] [INFO] Login successful
[2026-08-19T13:16:59.575Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
[2026-08-19T13:16:59.591Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
[2026-08-19T13:17:34.211Z] [INFO] [LOGIN] Попытка входа: vova
[2026-08-19T13:17:34.212Z] [INFO] [LOGIN] Проверка подключения к базе...
[2026-08-19T13:17:34.217Z] [INFO] [LOGIN] Результат поиска пользователя: найден
[2026-08-19T13:17:34.217Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
[2026-08-19T13:17:34.291Z] [INFO] [LOGIN] Результат сравнения пароля: true
[2026-08-19T13:17:34.292Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
[2026-08-19T13:17:34.293Z] [INFO] [LOGIN] Ответ отправлен
[2026-08-22T10:02:00.127Z] [INFO] Server running on http://localhost:3005
[2026-08-22T10:02:00.130Z] [INFO] Card logs initialized successfully
[2026-08-28T13:16:54.329Z] [INFO] Server running on http://localhost:3005
[2026-08-28T13:16:54.345Z] [INFO] Card logs initialized successfully
[2026-08-28T13:17:44.305Z] [WARN] Неверный пароль
[2026-08-28T13:17:47.842Z] [WARN] Неверный пароль
[2026-08-28T13:17:49.122Z] [WARN] Неверный пароль
[2026-08-28T13:18:24.580Z] [INFO] Login successful
[2026-08-28T13:18:24.735Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
[2026-08-28T13:18:24.756Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
[2026-08-28T13:18:42.365Z] [INFO] [LOGIN] Попытка входа: vova
[2026-08-28T13:18:42.366Z] [INFO] [LOGIN] Проверка подключения к базе...
[2026-08-28T13:18:42.380Z] [INFO] [LOGIN] Результат поиска пользователя: найден
[2026-08-28T13:18:42.381Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
[2026-08-28T13:18:42.462Z] [INFO] [LOGIN] Результат сравнения пароля: true
[2026-08-28T13:18:42.463Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
[2026-08-28T13:18:42.464Z] [INFO] [LOGIN] Ответ отправлен
[2026-08-28T13:21:07.055Z] [INFO] Server running on http://localhost:3005
[2026-08-28T13:21:07.058Z] [INFO] Card logs initialized successfully
[2026-08-28T13:21:24.349Z] [ERROR] MulterError: Unexpected field
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
at Multipart.emit (node:events:509:28)
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
at writeOrBuffer (node:internal/streams/writable:570:12)
[2026-08-28T13:21:28.269Z] [ERROR] MulterError: Unexpected field
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
at Multipart.emit (node:events:509:28)
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
at writeOrBuffer (node:internal/streams/writable:570:12)
[2026-08-28T13:22:13.764Z] [ERROR] MulterError: Unexpected field
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
at Multipart.emit (node:events:509:28)
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
at writeOrBuffer (node:internal/streams/writable:570:12)
[2026-08-28T13:25:47.640Z] [INFO] Server running on http://localhost:3005
[2026-08-28T13:25:47.643Z] [INFO] Card logs initialized successfully
[2026-08-28T13:25:57.085Z] [ERROR] MulterError: Unexpected field
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
at Multipart.emit (node:events:509:28)
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
at writeOrBuffer (node:internal/streams/writable:570:12)
[2026-08-28T13:28:40.679Z] [ERROR] MulterError: Unexpected field
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
at Multipart.emit (node:events:509:28)
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
at writeOrBuffer (node:internal/streams/writable:570:12)
[2026-08-28T13:35:09.914Z] [ERROR] MulterError: Unexpected field
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
at Multipart.emit (node:events:509:28)
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
at writeOrBuffer (node:internal/streams/writable:570:12)
[2026-08-28T13:36:00.169Z] [INFO] Server running on http://localhost:3005
[2026-08-28T13:36:00.172Z] [INFO] Card logs initialized successfully
[2026-08-28T13:37:58.077Z] [INFO] Server running on http://localhost:3005
[2026-08-28T13:37:58.080Z] [INFO] Card logs initialized successfully
[2026-08-28T13:38:24.214Z] [ERROR] MulterError: Unexpected field
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
at Multipart.emit (node:events:509:28)
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
at writeOrBuffer (node:internal/streams/writable:570:12)
[2026-08-28T13:38:54.054Z] [ERROR] MulterError: Unexpected field
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
at Multipart.emit (node:events:509:28)
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
at writeOrBuffer (node:internal/streams/writable:570:12)
[2026-08-28T13:38:57.735Z] [ERROR] MulterError: Unexpected field
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
at Multipart.emit (node:events:509:28)
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
at writeOrBuffer (node:internal/streams/writable:570:12)
[2026-08-28T13:40:58.165Z] [INFO] Server running on http://localhost:3005
[2026-08-28T13:40:58.168Z] [INFO] Card logs initialized successfully
[2026-08-28T13:41:09.217Z] [INFO] [LOGIN] Попытка входа: vova
[2026-08-28T13:41:09.218Z] [INFO] [LOGIN] Проверка подключения к базе...
[2026-08-28T13:41:09.220Z] [INFO] [LOGIN] Результат поиска пользователя: найден
[2026-08-28T13:41:09.220Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
[2026-08-28T13:41:09.298Z] [INFO] [LOGIN] Результат сравнения пароля: true
[2026-08-28T13:41:09.299Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
[2026-08-28T13:41:09.301Z] [INFO] [LOGIN] Ответ отправлен
[2026-08-28T13:41:18.242Z] [ERROR] MulterError: Unexpected field
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
at Multipart.emit (node:events:509:28)
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
at writeOrBuffer (node:internal/streams/writable:570:12)
[2026-08-28T13:44:33.109Z] [INFO] Server running on http://localhost:3005
[2026-08-28T13:44:33.112Z] [INFO] Card logs initialized successfully
[2026-08-28T13:44:45.327Z] [ERROR] MulterError: Unexpected field
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
at Multipart.emit (node:events:509:28)
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
at writeOrBuffer (node:internal/streams/writable:570:12)
[2026-08-28T13:47:57.993Z] [INFO] Server running on http://localhost:3005
[2026-08-28T13:47:57.997Z] [INFO] Card logs initialized successfully
[2026-08-28T13:52:17.446Z] [INFO] Server running on http://localhost:3005
[2026-08-28T13:52:17.449Z] [INFO] Card logs initialized successfully
[2026-08-28T13:53:18.856Z] [INFO] Server running on http://localhost:3005
[2026-08-28T13:53:18.859Z] [INFO] Card logs initialized successfully
[2026-08-28T13:53:30.533Z] [INFO] [LOGIN] Попытка входа: vova
[2026-08-28T13:53:30.534Z] [INFO] [LOGIN] Проверка подключения к базе...
[2026-08-28T13:53:30.546Z] [INFO] [LOGIN] Результат поиска пользователя: найден
[2026-08-28T13:53:30.547Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
[2026-08-28T13:53:30.646Z] [INFO] [LOGIN] Результат сравнения пароля: false
[2026-08-28T13:53:30.646Z] [WARN] [LOGIN] Неверный пароль для пользователя: vova
[2026-08-28T13:53:36.962Z] [INFO] [LOGIN] Попытка входа: vova
[2026-08-28T13:53:36.963Z] [INFO] [LOGIN] Проверка подключения к базе...
[2026-08-28T13:53:36.965Z] [INFO] [LOGIN] Результат поиска пользователя: найден
[2026-08-28T13:53:36.965Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
[2026-08-28T13:53:37.043Z] [INFO] [LOGIN] Результат сравнения пароля: true
[2026-08-28T13:53:37.043Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
[2026-08-28T13:53:37.045Z] [INFO] [LOGIN] Ответ отправлен
[2026-08-28T13:54:12.715Z] [INFO] [LOGIN] Попытка входа: vova
[2026-08-28T13:54:12.715Z] [INFO] [LOGIN] Проверка подключения к базе...
[2026-08-28T13:54:12.717Z] [INFO] [LOGIN] Результат поиска пользователя: найден
[2026-08-28T13:54:12.718Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
[2026-08-28T13:54:12.803Z] [INFO] [LOGIN] Результат сравнения пароля: true
[2026-08-28T13:54:12.804Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
[2026-08-28T13:54:12.805Z] [INFO] [LOGIN] Ответ отправлен
[2026-08-28T13:54:29.593Z] [ERROR] MulterError: Unexpected field
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
at Multipart.emit (node:events:509:28)
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
at writeOrBuffer (node:internal/streams/writable:570:12)
[2026-08-28T13:55:32.725Z] [INFO] Server running on http://localhost:3005
[2026-08-28T13:55:32.728Z] [INFO] Card logs initialized successfully
[2026-08-28T13:55:44.526Z] [ERROR] MulterError: Unexpected field
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
at Multipart.emit (node:events:509:28)
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
at writeOrBuffer (node:internal/streams/writable:570:12)
[2026-08-28T13:57:30.453Z] [INFO] Server running on http://localhost:3005
[2026-08-28T13:57:30.456Z] [INFO] Card logs initialized successfully
[2026-08-28T13:57:37.890Z] [INFO] [LOGIN] Попытка входа: vova
[2026-08-28T13:57:37.891Z] [INFO] [LOGIN] Проверка подключения к базе...
[2026-08-28T13:57:37.893Z] [INFO] [LOGIN] Результат поиска пользователя: найден
[2026-08-28T13:57:37.893Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
[2026-08-28T13:57:37.973Z] [INFO] [LOGIN] Результат сравнения пароля: true
[2026-08-28T13:57:37.973Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
[2026-08-28T13:57:37.975Z] [INFO] [LOGIN] Ответ отправлен
[2026-08-28T13:59:04.163Z] [INFO] Server running on http://localhost:3005
[2026-08-28T13:59:04.166Z] [INFO] Card logs initialized successfully
[2026-08-28T13:59:18.113Z] [INFO] [LOGIN] Попытка входа: vova
[2026-08-28T13:59:18.113Z] [INFO] [LOGIN] Проверка подключения к базе...
[2026-08-28T13:59:18.128Z] [INFO] [LOGIN] Результат поиска пользователя: найден
[2026-08-28T13:59:18.129Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
[2026-08-28T13:59:18.259Z] [INFO] [LOGIN] Результат сравнения пароля: true
[2026-08-28T13:59:18.260Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
[2026-08-28T13:59:18.262Z] [INFO] [LOGIN] Ответ отправлен
[2026-08-28T14:02:13.224Z] [INFO] Server running on http://localhost:3005
[2026-08-28T14:02:13.226Z] [INFO] Card logs initialized successfully
[2026-08-28T14:02:25.923Z] [INFO] [LOGIN] Попытка входа: vova
[2026-08-28T14:02:25.924Z] [INFO] [LOGIN] Проверка подключения к базе...
[2026-08-28T14:02:25.940Z] [INFO] [LOGIN] Результат поиска пользователя: найден
[2026-08-28T14:02:25.941Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
[2026-08-28T14:02:26.078Z] [INFO] [LOGIN] Результат сравнения пароля: true
[2026-08-28T14:02:26.087Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
[2026-08-28T14:02:26.089Z] [INFO] [LOGIN] Ответ отправлен
[2026-08-28T14:03:37.800Z] [ERROR] MulterError: Unexpected field
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
at Multipart.emit (node:events:509:28)
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
at writeOrBuffer (node:internal/streams/writable:570:12)
[2026-08-28T14:05:00.439Z] [INFO] Server running on http://localhost:3005
[2026-08-28T14:05:00.442Z] [INFO] Card logs initialized successfully
[2026-08-28T14:05:47.706Z] [INFO] [LOGIN] Попытка входа: vova
[2026-08-28T14:05:47.707Z] [INFO] [LOGIN] Проверка подключения к базе...
[2026-08-28T14:05:47.709Z] [INFO] [LOGIN] Результат поиска пользователя: найден
[2026-08-28T14:05:47.710Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
[2026-08-28T14:05:47.796Z] [INFO] [LOGIN] Результат сравнения пароля: true
[2026-08-28T14:05:47.797Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
[2026-08-28T14:05:47.798Z] [INFO] [LOGIN] Ответ отправлен
[2026-08-28T14:06:31.202Z] [INFO] [LOGIN] Попытка входа: grivova
[2026-08-28T14:06:31.202Z] [INFO] [LOGIN] Проверка подключения к базе...
[2026-08-28T14:06:31.204Z] [INFO] [LOGIN] Результат поиска пользователя: не найден
[2026-08-28T14:06:31.204Z] [WARN] [LOGIN] Пользователь не найден: grivova
[2026-08-28T14:06:43.404Z] [INFO] [LOGIN] Попытка входа: asutp
[2026-08-28T14:06:43.406Z] [INFO] [LOGIN] Проверка подключения к базе...
[2026-08-28T14:06:43.407Z] [INFO] [LOGIN] Результат поиска пользователя: найден
[2026-08-28T14:06:43.408Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
[2026-08-28T14:06:43.489Z] [INFO] [LOGIN] Результат сравнения пароля: true
[2026-08-28T14:06:43.490Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":5,"username":"asutp"}
[2026-08-28T14:06:43.491Z] [INFO] [LOGIN] Ответ отправлен
[2026-08-28T14:07:57.016Z] [INFO] Server running on http://localhost:3005
[2026-08-28T14:07:57.018Z] [INFO] Card logs initialized successfully
[2026-08-28T14:08:15.541Z] [INFO] [LOGIN] Попытка входа: vova
[2026-08-28T14:08:15.543Z] [INFO] [LOGIN] Проверка подключения к базе...
[2026-08-28T14:08:15.546Z] [INFO] [LOGIN] Результат поиска пользователя: найден
[2026-08-28T14:08:15.547Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
[2026-08-28T14:08:15.629Z] [INFO] [LOGIN] Результат сравнения пароля: true
[2026-08-28T14:08:15.630Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
[2026-08-28T14:08:15.631Z] [INFO] [LOGIN] Ответ отправлен
[2026-08-28T14:09:51.626Z] [INFO] Server running on http://localhost:3005
[2026-08-28T14:09:51.629Z] [INFO] Card logs initialized successfully
[2026-08-31T05:58:23.110Z] [INFO] Server running on http://localhost:3005
[2026-08-31T05:58:23.117Z] [INFO] Card logs initialized successfully
[2026-08-31T11:41:26.146Z] [INFO] Server running on http://localhost:3005
[2026-08-31T11:41:26.163Z] [INFO] Card logs initialized successfully
[2026-08-31T11:41:58.530Z] [INFO] [LOGIN] Попытка входа: vova
[2026-08-31T11:41:58.531Z] [INFO] [LOGIN] Проверка подключения к базе...
[2026-08-31T11:41:58.554Z] [INFO] [LOGIN] Результат поиска пользователя: найден
[2026-08-31T11:41:58.562Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
[2026-08-31T11:41:58.657Z] [INFO] [LOGIN] Результат сравнения пароля: true
[2026-08-31T11:41:58.661Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
[2026-08-31T11:41:58.668Z] [INFO] [LOGIN] Ответ отправлен
[2026-08-31T12:06:00.544Z] [INFO] Server running on http://localhost:3005
[2026-08-31T12:06:00.547Z] [INFO] Card logs initialized successfully
[2026-08-31T12:18:40.420Z] [INFO] Server running on http://localhost:3005
[2026-08-31T12:18:40.423Z] [INFO] Card logs initialized successfully
[2026-08-31T12:21:29.695Z] [INFO] Server running on http://localhost:3005
[2026-08-31T12:21:29.697Z] [INFO] Card logs initialized successfully
[2026-08-31T12:23:29.133Z] [INFO] Server running on http://localhost:3005
[2026-08-31T12:23:29.137Z] [INFO] Card logs initialized successfully
[2026-08-31T12:27:21.641Z] [INFO] Server running on http://localhost:3005
[2026-08-31T12:27:21.644Z] [INFO] Card logs initialized successfully
[2026-08-31T12:30:33.180Z] [INFO] Server running on http://localhost:3005
[2026-08-31T12:30:33.183Z] [INFO] Card logs initialized successfully
+179 -63
View File
@@ -9,93 +9,211 @@ const fs = require('fs');
const uploadDir = path.join(__dirname, '../../uploads');
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, uploadDir),
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
const name = Date.now() + '-' + Math.round(Math.random() * 1e6) + ext;
cb(null, name);
}
destination: (req, file, cb) => cb(null, uploadDir),
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
const name = Date.now() + '-' + Math.round(Math.random() * 1e6) + ext;
cb(null, name);
}
});
const ALLOWED_MIME_TYPES = [
'image/jpeg', 'image/png',
'video/mp4', 'video/quicktime'
'image/jpeg', 'image/png',
'video/mp4', 'video/quicktime'
];
const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20 МБ
const upload = multer({
storage,
limits: { fileSize: MAX_FILE_SIZE },
fileFilter: (req, file, cb) => {
if (ALLOWED_MIME_TYPES.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Недопустимый тип файла'));
storage,
limits: { fileSize: MAX_FILE_SIZE },
fileFilter: (req, file, cb) => {
if (ALLOWED_MIME_TYPES.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Недопустимый тип файла'));
}
}
}
});
// ============================================
// 📸 СЛАЙДЫ ДЛЯ КАРУСЕЛИ (В САМОМ НАЧАЛЕ)
// ============================================
// Получить все активные слайды
router.get('/slides', async (req, res) => {
console.log('📸 GET /slides');
try {
const [rows] = await db.query(
'SELECT * FROM slides WHERE is_active = 1 ORDER BY `order` ASC'
);
res.json(rows);
} catch (err) {
console.error('❌ Ошибка получения слайдов:', err);
res.status(500).json({ message: 'Ошибка сервера', error: err.message });
}
});
// Получить все слайды (для админки)
router.get('/slides/all', async (req, res) => {
console.log('📸 GET /slides/all');
try {
const [rows] = await db.query(
'SELECT * FROM slides ORDER BY `order` ASC'
);
res.json(rows);
} catch (err) {
console.error('❌ Ошибка получения всех слайдов:', err);
res.status(500).json({ message: 'Ошибка сервера', error: err.message });
}
});
// Загрузка изображения для слайда
router.post('/slides/upload', upload.single('image'), (req, res) => {
console.log('📸 POST /slides/upload');
console.log('📁 req.file:', req.file);
if (!req.file) {
return res.status(400).json({ message: 'Файл не загружен' });
}
const relPath = '/uploads/' + req.file.filename;
console.log('✅ Изображение загружено:', relPath);
res.json({ path: relPath });
});
// Создать слайд
router.post('/slides', async (req, res) => {
console.log('📸 POST /slides');
const { image_url, title, description, link, order, is_active } = req.body;
if (!image_url) {
return res.status(400).json({ message: 'image_url обязателен' });
}
try {
const [result] = await db.query(
`INSERT INTO slides (image_url, title, description, link, \`order\`, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())`,
[image_url, title || null, description || null, link || null, order || 0, is_active !== undefined ? is_active : 1]
);
console.log('✅ Слайд создан, ID:', result.insertId);
res.json({ id: result.insertId, message: 'Слайд создан' });
} catch (err) {
console.error('❌ Ошибка создания слайда:', err);
res.status(500).json({ message: 'Ошибка сервера', error: err.message });
}
});
// Обновить слайд
router.put('/slides/:id', async (req, res) => {
console.log('📸 PUT /slides/' + req.params.id);
const { id } = req.params;
const { image_url, title, description, link, order, is_active } = req.body;
if (!image_url) {
return res.status(400).json({ message: 'image_url обязателен' });
}
try {
await db.query(
`UPDATE slides
SET image_url = ?,
title = ?,
description = ?,
link = ?,
\`order\` = ?,
is_active = ?,
updated_at = NOW()
WHERE id = ?`,
[image_url, title || null, description || null, link || null, order || 0, is_active !== undefined ? is_active : 1, id]
);
console.log('✅ Слайд обновлён, ID:', id);
res.json({ message: 'Слайд обновлён' });
} catch (err) {
console.error('❌ Ошибка обновления слайда:', err);
res.status(500).json({ message: 'Ошибка сервера', error: err.message });
}
});
// Удалить слайд
router.delete('/slides/:id', async (req, res) => {
console.log('📸 DELETE /slides/' + req.params.id);
const { id } = req.params;
try {
await db.query('DELETE FROM slides WHERE id = ?', [id]);
console.log('✅ Слайд удалён, ID:', id);
res.json({ message: 'Слайд удалён' });
} catch (err) {
console.error('❌ Ошибка удаления слайда:', err);
res.status(500).json({ message: 'Ошибка сервера', error: err.message });
}
});
// ============================================
// 📚 ОСТАЛЬНЫЕ МАРШРУТЫ (НИЖЕ)
// ============================================
// Получить все блоки для вкладки
router.get('/:tab', async (req, res) => {
const { tab } = req.params;
if (!['info', 'union', 'events', 'help', 'housing'].includes(tab)) return res.status(400).json({ message: 'Некорректная вкладка' });
try {
const [rows] = await db.query('SELECT * FROM content_blocks WHERE tab = ? ORDER BY created_at ASC', [tab]);
res.json(rows);
} catch (err) {
res.status(500).json({ message: 'Ошибка сервера' });
}
const { tab } = req.params;
if (!['info', 'union', 'events', 'help', 'housing'].includes(tab)) return res.status(400).json({ message: 'Некорректная вкладка' });
try {
const [rows] = await db.query('SELECT * FROM content_blocks WHERE tab = ? ORDER BY created_at ASC', [tab]);
res.json(rows);
} catch (err) {
res.status(500).json({ message: 'Ошибка сервера' });
}
});
// Загрузка файла (image/video)
router.post('/:tab/upload', requireModerator, upload.single('file'), (req, res) => {
if (!req.file) return res.status(400).json({ message: 'Файл не загружен' });
const relPath = '/uploads/' + req.file.filename;
res.json({ path: relPath });
if (!req.file) return res.status(400).json({ message: 'Файл не загружен' });
const relPath = '/uploads/' + req.file.filename;
res.json({ path: relPath });
});
// Добавить новый блок
router.post('/:tab', requireModerator, async (req, res) => {
const { tab } = req.params;
const { content } = req.body;
if (!['info', 'union', 'events', 'help', 'housing'].includes(tab)) return res.status(400).json({ message: 'Некорректная вкладка' });
try {
const [result] = await db.query(
'INSERT INTO content_blocks (tab, type, content, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW())',
[tab, 'text', content, req.session.userId]
);
res.json({ id: result.insertId });
} catch (err) {
res.status(500).json({ message: 'Ошибка сервера' });
}
const { tab } = req.params;
const { content } = req.body;
if (!['info', 'union', 'events', 'help', 'housing'].includes(tab)) return res.status(400).json({ message: 'Некорректная вкладка' });
try {
const [result] = await db.query(
'INSERT INTO content_blocks (tab, type, content, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW())',
[tab, 'text', content, req.session.userId]
);
res.json({ id: result.insertId });
} catch (err) {
res.status(500).json({ message: 'Ошибка сервера' });
}
});
// Обновить блок
router.put('/:tab/:id', requireModerator, async (req, res) => {
const { tab, id } = req.params;
const { content } = req.body;
if (!['info', 'union', 'events', 'help', 'housing'].includes(tab)) return res.status(400).json({ message: 'Некорректная вкладка' });
try {
await db.query(
'UPDATE content_blocks SET content = ?, updated_at = NOW() WHERE id = ? AND tab = ?',
[content, id, tab]
);
res.json({ message: 'Обновлено' });
} catch (err) {
res.status(500).json({ message: 'Ошибка сервера' });
}
const { tab, id } = req.params;
const { content } = req.body;
if (!['info', 'union', 'events', 'help', 'housing'].includes(tab)) return res.status(400).json({ message: 'Некорректная вкладка' });
try {
await db.query(
'UPDATE content_blocks SET content = ?, updated_at = NOW() WHERE id = ? AND tab = ?',
[content, id, tab]
);
res.json({ message: 'Обновлено' });
} catch (err) {
res.status(500).json({ message: 'Ошибка сервера' });
}
});
// Удалить блок
router.delete('/:tab/:id', requireModerator, async (req, res) => {
const { tab, id } = req.params;
if (!['info', 'union', 'events', 'help', 'housing'].includes(tab)) return res.status(400).json({ message: 'Некорректная вкладка' });
try {
await db.query('DELETE FROM content_blocks WHERE id = ? AND tab = ?', [id, tab]);
res.json({ message: 'Удалено' });
} catch (err) {
res.status(500).json({ message: 'Ошибка сервера' });
}
const { tab, id } = req.params;
if (!['info', 'union', 'events', 'help', 'housing'].includes(tab)) return res.status(400).json({ message: 'Некорректная вкладка' });
try {
await db.query('DELETE FROM content_blocks WHERE id = ? AND tab = ?', [id, tab]);
res.json({ message: 'Удалено' });
} catch (err) {
res.status(500).json({ message: 'Ошибка сервера' });
}
});
router.get('/section/:section', async (req, res) => {
const { section } = req.params;
if (!['events', 'help', 'housing'].includes(section)) {
@@ -106,7 +224,6 @@ router.get('/section/:section', async (req, res) => {
'SELECT * FROM content_blocks WHERE tab = ? ORDER BY created_at ASC',
[section]
);
// Если есть блоки — возвращаем их, иначе пустой массив
res.json(rows);
} catch (err) {
console.error(err);
@@ -114,7 +231,7 @@ router.get('/section/:section', async (req, res) => {
}
});
// Сохранить контент раздела (очищаем старые и вставляем новый)
// Сохранить контент раздела
router.post('/section/:section', requireModerator, async (req, res) => {
const { section } = req.params;
const { content } = req.body;
@@ -124,10 +241,8 @@ router.post('/section/:section', requireModerator, async (req, res) => {
}
try {
// Удаляем старые блоки для этого раздела
await db.query('DELETE FROM content_blocks WHERE tab = ?', [section]);
// Вставляем новый блок
const [result] = await db.query(
'INSERT INTO content_blocks (tab, type, content, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW())',
[section, 'text', content, req.session.userId]
@@ -143,4 +258,5 @@ router.post('/section/:section', requireModerator, async (req, res) => {
res.status(500).json({ message: 'Ошибка сервера' });
}
});
module.exports = router;
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 804 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 804 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+9 -4
View File
@@ -1,5 +1,10 @@
{
"payslipsPath": "\\\\PC-20010\\payslips2",
"payslipsPath": "./payslips",
"networkCredentials": {
"username": "",
"password": "",
"domain": "WORKGROUP"
},
"server": {
"host": "localhost",
"port": "3001"
@@ -51,9 +56,9 @@
}
],
"payslips": [
"Петров_ПП_2026-08.pdf",
"Сидорова_АС_2026-08.pdf",
"Иванов_ИИ_2026-08.pdf"
"001.pdf",
"002.pdf",
"003.pdf"
]
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+117
View File
@@ -0,0 +1,117 @@
const express = require('express');
const path = require('path');
const fs = require('fs').promises;
const { logger } = require('../config/logger');
const { parsePayslipExcel } = require('../services/excelParser');
const { generatePayslipForm } = require('../services/formGenerator');
const router = express.Router();
/**
* GET /api/payslip/form/:tnom
* Получить HTML-форму расчетного листка
*/
router.get('/form/:tnom', async (req, res) => {
try {
const { tnom } = req.params;
const { period } = req.query;
logger.info(`[PayslipForm] Запрос формы для ${tnom}, период: ${period || 'не указан'}`);
// 1. Находим Excel-файл
const configPath = path.join(__dirname, '../../config.json');
const config = JSON.parse(await fs.readFile(configPath, 'utf8'));
let payslipBasePath = config.payslipsPath || './payslips';
if (!path.isAbsolute(payslipBasePath)) {
payslipBasePath = path.join(__dirname, '../..', payslipBasePath);
}
let excelPath;
if (period) {
excelPath = path.join(payslipBasePath, period, `${tnom}.xlsx`);
} else {
excelPath = path.join(payslipBasePath, `${tnom}.xlsx`);
}
logger.info(`[PayslipForm] Путь к Excel: ${excelPath}`);
// 2. Проверяем существование файла
try {
await fs.access(excelPath);
} catch (err) {
logger.warn(`[PayslipForm] Excel не найден: ${excelPath}`);
return res.status(404).json({
message: 'Excel-файл расчетного листка не найден',
path: excelPath
});
}
// 3. Парсим Excel
const data = parsePayslipExcel(excelPath);
logger.info(`[PayslipForm] Данные извлечены:`, {
fullName: data.fullName,
tabNumber: data.tabNumber,
totalAccrued: data.totalAccrued,
totalPaid: data.totalPaid
});
// 4. Генерируем HTML-форму
const html = generatePayslipForm(data);
// 5. Отправляем HTML
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(html);
} catch (error) {
logger.error('[PayslipForm] Ошибка:', error.message);
res.status(500).json({
message: 'Ошибка при формировании расчетного листка',
error: error.message
});
}
});
/**
* GET /api/payslip/form-data/:tnom
* Получить данные из Excel в JSON (для отладки)
*/
router.get('/form-data/:tnom', async (req, res) => {
try {
const { tnom } = req.params;
const { period } = req.query;
// Находим Excel-файл
const configPath = path.join(__dirname, '../../config.json');
const config = JSON.parse(await fs.readFile(configPath, 'utf8'));
let payslipBasePath = config.payslipsPath || './payslips';
if (!path.isAbsolute(payslipBasePath)) {
payslipBasePath = path.join(__dirname, '../..', payslipBasePath);
}
let excelPath;
if (period) {
excelPath = path.join(payslipBasePath, period, `${tnom}.xlsx`);
} else {
excelPath = path.join(payslipBasePath, `${tnom}.xlsx`);
}
// Проверяем существование
try {
await fs.access(excelPath);
} catch (err) {
return res.status(404).json({ message: 'Excel не найден' });
}
// Парсим и возвращаем JSON
const data = parsePayslipExcel(excelPath);
res.json(data);
} catch (error) {
logger.error('[PayslipForm] Ошибка получения данных:', error.message);
res.status(500).json({ error: error.message });
}
});
module.exports = router;
+299 -106
View File
@@ -27,10 +27,11 @@ async function readJson(filePath) {
}
async function pathExists(filePath) {
console.log('🔍 Проверка пути:', filePath);
try {
await fsPromises.access(filePath);
return true;
} catch {
} catch (err) {
return false;
}
}
@@ -69,7 +70,9 @@ async function connectToNetworkShare(config) {
// ============================================
const app = express();
app.use(cors());
app.use(express.json());
app.use(function(req, res, next) {
@@ -81,7 +84,31 @@ app.use(function(req, res, next) {
next();
});
app.use(helmet());
// ✅ ИСПРАВЛЕННАЯ НАСТРОЙКА HELMET (разрешаем iframe)
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
// РАЗРЕШАЕМ встраивание в iframe с localhost:3000
frameAncestors: ["'self'", "http://localhost:3000", "http://127.0.0.1:3000"],
// Для PDF файлов
frameSrc: ["'self'", "blob:"],
// Другие необходимые директивы
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "blob:"],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'self'", "blob:"],
mediaSrc: ["'self'"],
},
},
// Отключаем лишние политики для разработки
crossOriginEmbedderPolicy: false,
crossOriginOpenerPolicy: false,
crossOriginResourcePolicy: { policy: "cross-origin" },
}));
app.use('/api/', limiter);
// ============================================
@@ -342,6 +369,269 @@ app.post('/api/rates/update-from-nbrb', async function(req, res) {
}
});
// ============================================
// 📄 РАСЧЕТНЫЕ ЛИСТКИ (ОБНОВЛЕННЫЕ ЭНДПОИНТЫ)
// ============================================
// ✅ НОВАЯ ФУНКЦИЯ: получает путь к файлу с учетом периода (месяца)
async function getPayslipFilePath(tnom, period = null) {
const configPath = path.join(__dirname, '../config.json');
const config = await readJson(configPath);
let payslipBasePath = config.payslipsPath;
if (!path.isAbsolute(payslipBasePath)) {
payslipBasePath = path.join(__dirname, '..', payslipBasePath);
}
let filePath;
if (period) {
// ✅ Если передан период — ищем в подпапке
filePath = path.join(payslipBasePath, period, tnom + '.pdf');
console.log(`📁 Поиск в подпапке: ${period} -> ${filePath}`);
} else {
// ✅ Если период не передан — ищем в корне (для совместимости)
filePath = path.join(payslipBasePath, tnom + '.pdf');
console.log(`📁 Поиск в корне: ${filePath}`);
}
return filePath;
}
// ✅ ОБНОВЛЕННАЯ ПРОВЕРКА наличия расчетного листка (с period)
app.get('/api/payslip/check/:tnom', async function(req, res) {
try {
var tnom = req.params.tnom;
var period = req.query.period || null; // ✅ Берем period из query-параметров
console.log('==========================================');
console.log(`📁 [PAYSLIP CHECK] Табельный номер: ${tnom}, Период: ${period || 'не указан'}`);
const filePath = await getPayslipFilePath(tnom, period);
const exists = await pathExists(filePath);
console.log(`📁 exists: ${exists}`);
console.log('==========================================');
res.json({
exists: exists,
tnom: tnom,
period: period,
path: filePath,
fileName: tnom + '.pdf'
});
} catch (err) {
logger.error('Ошибка проверки расчетного листка:', err);
res.status(500).json({ error: err.message });
}
});
// ✅ ОБНОВЛЕННОЕ ПОЛУЧЕНИЕ URL (с period)
app.get('/api/payslip/url/:tnom', async function(req, res) {
try {
var tnom = req.params.tnom;
var period = req.query.period || null; // ✅ Берем period из query-параметров
console.log('==========================================');
console.log(`📁 [PAYSLIP URL] Табельный номер: ${tnom}, Период: ${period || 'не указан'}`);
const filePath = await getPayslipFilePath(tnom, period);
console.log(`📁 Полный путь к файлу: ${filePath}`);
const exists = await pathExists(filePath);
console.log(`📁 exists: ${exists}`);
console.log('==========================================');
if (exists) {
// ✅ Формируем URL для получения файла
let fileUrl = `/api/payslip/tnom/${tnom}`;
if (period) {
fileUrl += `?period=${period}`; // ✅ Добавляем period в query
}
logger.info(`[PAYSLIP URL] Файл найден для ${tnom} (период: ${period || 'корень'})`);
res.json({
exists: true,
url: fileUrl,
tnom: tnom,
period: period,
fileName: tnom + '.pdf'
});
} else {
logger.warn(`[PAYSLIP URL] Файл не найден для ${tnom} (период: ${period || 'корень'})`);
res.json({
exists: false,
message: 'Расчетный листок не найден',
tnom: tnom,
period: period
});
}
} catch (err) {
logger.error('Ошибка получения URL расчетного листка:', err);
res.status(500).json({ error: err.message });
}
});
// ✅ ОБНОВЛЕННАЯ ОТДАЧА РАСЧЕТНОГО ЛИСТКА (с period)
app.get('/api/payslip/tnom/:tnom', async function(req, res) {
try {
var tnom = req.params.tnom;
var period = req.query.period || null; // ✅ Берем period из query-параметров
console.log('==========================================');
console.log(`📁 [PAYSLIP] Запрос к /api/payslip/tnom/${tnom}, Период: ${period || 'не указан'}`);
const filePath = await getPayslipFilePath(tnom, period);
console.log(`📁 Полный путь к файлу: ${filePath}`);
// ✅ ЯВНО проверяем существование файла
try {
await fsPromises.access(filePath);
console.log('✅ Файл найден, начинаем отправку...');
} catch (err) {
console.log('❌ Файл НЕ найден:', err.message);
return res.status(404).json({
message: 'Расчетный листок не найден',
tnom: tnom,
period: period,
path: filePath,
error: err.message
});
}
// ✅ Добавляем правильные заголовки для PDF
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'inline; filename="' + tnom + '.pdf"');
// ✅ Отправляем файл с обработкой ошибок
res.sendFile(filePath, function(err) {
if (err) {
console.error('❌ Ошибка при отправке файла:', err);
if (!res.headersSent) {
return res.status(500).json({
message: 'Ошибка при отправке файла',
error: err.message
});
}
} else {
console.log('✅ Файл успешно отправлен');
}
});
} catch (err) {
console.error('❌ Ошибка получения расчетного листка:', err);
res.status(500).json({ error: err.message });
}
});
// ============================================
// 🗄️ СТАРЫЕ ЭНДПОИНТЫ РАСЧЕТНЫХ ЛИСТКОВ (для обратной совместимости)
// ============================================
app.get('/api/payslip/:period/:fileName', async function(req, res) {
try {
var period = req.params.period;
var fileName = req.params.fileName;
var configPath = path.join(__dirname, '../config.json');
var config = await readJson(configPath);
var payslipBasePath = config.payslipsPath;
logger.info('Payslip request - Period: ' + period + ', File: ' + fileName);
logger.info('Base path from config: ' + payslipBasePath);
var networkConnected = await connectToNetworkShare(config);
logger.info('Network share connection for payslip: ' + networkConnected);
var finalPayslipPath;
if (path.isAbsolute(payslipBasePath)) {
finalPayslipPath = path.join(payslipBasePath, period, fileName);
} else {
var projectRoot = path.resolve(__dirname, '../../');
finalPayslipPath = path.join(projectRoot, payslipBasePath, period, fileName);
}
logger.info('Final payslip path: ' + finalPayslipPath);
try {
var basePathExists = await pathExists(payslipBasePath);
logger.info('Base path exists: ' + basePathExists);
if (basePathExists) {
var basePathStats = await fsPromises.stat(payslipBasePath);
logger.info('Base path stats: ' + JSON.stringify({
isDirectory: basePathStats.isDirectory(),
permissions: basePathStats.mode,
size: basePathStats.size
}));
var basePathContents = await fsPromises.readdir(payslipBasePath);
logger.info('Base path contents: ' + JSON.stringify(basePathContents));
var periodPath = path.join(payslipBasePath, period);
var periodPathExists = await pathExists(periodPath);
logger.info('Period path exists: ' + periodPathExists + ' (' + periodPath + ')');
if (periodPathExists) {
var periodPathContents = await fsPromises.readdir(periodPath);
logger.info('Period path contents: ' + JSON.stringify(periodPathContents));
}
}
} catch (basePathError) {
logger.error('Error checking base path', {
error: basePathError.message,
stack: basePathError.stack
});
}
if (await pathExists(finalPayslipPath)) {
logger.info('Payslip found, sending file: ' + finalPayslipPath);
res.sendFile(finalPayslipPath);
} else {
logger.warn('Payslip not found: ' + finalPayslipPath);
res.status(404).json({
message: 'Расчетный листок не найден',
path: finalPayslipPath,
period: period,
fileName: fileName,
basePath: payslipBasePath,
networkConnected: networkConnected
});
}
} catch (err) {
logger.error('Error getting payslip', { error: err.message, stack: err.stack });
res.status(500).json({ message: 'Ошибка сервера' });
}
});
app.get('/api/payslip/:fileName', async function(req, res) {
try {
var fileName = req.params.fileName;
var configPath = path.join(__dirname, '../config.json');
var config = await readJson(configPath);
var payslipBasePath = config.payslipsPath;
var finalPayslipPath;
if (path.isAbsolute(payslipBasePath)) {
finalPayslipPath = path.join(payslipBasePath, fileName);
} else {
var projectRoot = path.resolve(__dirname, '../../');
finalPayslipPath = path.join(projectRoot, payslipBasePath, fileName);
}
logger.info('Attempting to find payslip at resolved path: ' + finalPayslipPath);
if (await pathExists(finalPayslipPath)) {
res.sendFile(finalPayslipPath);
} else {
logger.warn('Payslip not found: ' + finalPayslipPath);
res.status(404).json({ message: 'Расчетный листок не найден' });
}
} catch (err) {
logger.error('Error getting payslip', { error: err.message, stack: err.stack });
res.status(500).json({ message: 'Ошибка сервера' });
}
});
// ============================================
// 🗄️ ДРУГИЕ ЭНДПОИНТЫ
// ============================================
@@ -459,110 +749,6 @@ app.get('/api/test-network', async function(req, res) {
}
});
app.get('/api/payslip/:period/:fileName', async function(req, res) {
try {
var period = req.params.period;
var fileName = req.params.fileName;
var configPath = path.join(__dirname, '../config.json');
var config = await readJson(configPath);
var payslipBasePath = config.payslipsPath;
logger.info('Payslip request - Period: ' + period + ', File: ' + fileName);
logger.info('Base path from config: ' + payslipBasePath);
var networkConnected = await connectToNetworkShare(config);
logger.info('Network share connection for payslip: ' + networkConnected);
var finalPayslipPath;
if (path.isAbsolute(payslipBasePath)) {
finalPayslipPath = path.join(payslipBasePath, period, fileName);
} else {
var projectRoot = path.resolve(__dirname, '../../');
finalPayslipPath = path.join(projectRoot, payslipBasePath, period, fileName);
}
logger.info('Final payslip path: ' + finalPayslipPath);
try {
var basePathExists = await pathExists(payslipBasePath);
logger.info('Base path exists: ' + basePathExists);
if (basePathExists) {
var basePathStats = await fsPromises.stat(payslipBasePath);
logger.info('Base path stats: ' + JSON.stringify({
isDirectory: basePathStats.isDirectory(),
permissions: basePathStats.mode,
size: basePathStats.size
}));
var basePathContents = await fsPromises.readdir(payslipBasePath);
logger.info('Base path contents: ' + JSON.stringify(basePathContents));
var periodPath = path.join(payslipBasePath, period);
var periodPathExists = await pathExists(periodPath);
logger.info('Period path exists: ' + periodPathExists + ' (' + periodPath + ')');
if (periodPathExists) {
var periodPathContents = await fsPromises.readdir(periodPath);
logger.info('Period path contents: ' + JSON.stringify(periodPathContents));
}
}
} catch (basePathError) {
logger.error('Error checking base path', {
error: basePathError.message,
stack: basePathError.stack
});
}
if (await pathExists(finalPayslipPath)) {
logger.info('Payslip found, sending file: ' + finalPayslipPath);
res.sendFile(finalPayslipPath);
} else {
logger.warn('Payslip not found: ' + finalPayslipPath);
res.status(404).json({
message: 'Расчетный листок не найден',
path: finalPayslipPath,
period: period,
fileName: fileName,
basePath: payslipBasePath,
networkConnected: networkConnected
});
}
} catch (err) {
logger.error('Error getting payslip', { error: err.message, stack: err.stack });
res.status(500).json({ message: 'Ошибка сервера' });
}
});
app.get('/api/payslip/:fileName', async function(req, res) {
try {
var fileName = req.params.fileName;
var configPath = path.join(__dirname, '../config.json');
var config = await readJson(configPath);
var payslipBasePath = config.payslipsPath;
var finalPayslipPath;
if (path.isAbsolute(payslipBasePath)) {
finalPayslipPath = path.join(payslipBasePath, fileName);
} else {
var projectRoot = path.resolve(__dirname, '../../');
finalPayslipPath = path.join(projectRoot, payslipBasePath, fileName);
}
logger.info('Attempting to find payslip at resolved path: ' + finalPayslipPath);
if (await pathExists(finalPayslipPath)) {
res.sendFile(finalPayslipPath);
} else {
logger.warn('Payslip not found: ' + finalPayslipPath);
res.status(404).json({ message: 'Расчетный листок не найден' });
}
} catch (err) {
logger.error('Error getting payslip', { error: err.message, stack: err.stack });
res.status(500).json({ message: 'Ошибка сервера' });
}
});
app.get('/api/db-status', async function(req, res) {
try {
var db = require('./config/db');
@@ -583,6 +769,13 @@ app.get('/api/time', function(req, res) {
});
});
// ============================================
// 📄 РАСЧЕТНЫЙ ЛИСТОК (ФОРМА ДЛЯ ПЕЧАТИ)
// ============================================
const payslipFormRoutes = require('./routes/payslipForm');
app.use('/api/payslip', payslipFormRoutes);
// ============================================
// 🚀 ЗАПУСК СЕРВЕРА
// ============================================
+192
View File
@@ -0,0 +1,192 @@
const XLSX = require('xlsx');
const fs = require('fs');
const { logger } = require('../config/logger');
/**
* Парсит Excel-файл расчетного листка
* @param {string} filePath - путь к Excel-файлу
* @returns {Object} структурированные данные
*/
function parsePayslipExcel(filePath) {
try {
logger.info('[ExcelParser] Начинаем парсинг:', filePath);
if (!fs.existsSync(filePath)) {
throw new Error(`Файл не найден: ${filePath}`);
}
// Читаем Excel
const workbook = XLSX.readFile(filePath);
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const data = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
logger.info('[ExcelParser] Загружено строк:', data.length);
// Инициализируем структуру данных
const result = {
fullName: '',
department: '',
position: '',
tabNumber: '',
daysWorked: '',
month: '',
year: '',
accrued: [],
deductions: [],
other: [],
totalAccrued: 0,
totalDeductions: 0,
totalPaid: 0,
totalOther: 0,
bankTransfers: [],
advance: '',
accountNumber: ''
};
// Парсим построчно
data.forEach((row, index) => {
const colA = String(row[0] || '').trim();
const colB = String(row[1] || '').trim();
const colC = String(row[2] || '').trim();
const colD = String(row[3] || '').trim();
const colE = String(row[4] || '').trim();
const colF = String(row[5] || '').trim();
const colG = String(row[6] || '').trim();
// Строка 2: ФИО и Табельный номер (индекс 1)
if (index === 1) {
if (colB) result.fullName = colB;
if (colF) result.tabNumber = colF.replace('Таб.номер', '').trim();
}
// Строка 3: Подразделение
if (index === 2 && colB) {
result.department = colB;
}
// Строка 4: Должность
if (index === 3 && colB) {
result.position = colB;
}
// Строка 5: Дни отработано
if (index === 4 && colD) {
result.daysWorked = colD.replace('Отработано дней:', '').trim();
}
// Строка 0: Месяц и год (заголовок)
if (index === 0 && colC) {
const monthMatch = colC.match(/за\s+(\w+)\s+(\d{4})/);
if (monthMatch) {
result.month = monthMatch[1];
result.year = monthMatch[2];
}
}
// Строка 11: Лицевой счет
if (index === 10 && colB && colB.includes('Лиц счет')) {
result.accountNumber = colB.replace('Лиц счет =', '').trim();
}
// Парсим начисления (строки 14-17, индекс 13-16)
if (index >= 13 && index <= 16 && colB && colG) {
const code = colA || '';
const description = colB;
const period = colC || '';
const days = colD || '';
const hours = colE || '';
const amount = parseFloat(colG.replace(',', '.')) || 0;
if (description && !description.includes('ВСЕГО')) {
result.accrued.push({
code,
description,
period,
days,
hours,
amount
});
}
}
// Строка 17: ВСЕГО НАЧИСЛЕНО (индекс 17)
if (index === 17 && colG) {
result.totalAccrued = parseFloat(colG.replace(',', '.')) || 0;
}
// Парсим удержания (строки 19-22, индекс 18-21)
if (index >= 18 && index <= 21 && colB && colG) {
const code = colA || '';
const description = colB;
const period = colC || '';
const days = colD || '';
const hours = colE || '';
const amount = parseFloat(colG.replace(',', '.')) || 0;
if (description && !description.includes('ВСЕГО')) {
result.deductions.push({
code,
description,
period,
days,
hours,
amount
});
}
}
// Строка 22: ВСЕГО УДЕРЖАНО
if (index === 22 && colG) {
result.totalDeductions = parseFloat(colG.replace(',', '.')) || 0;
}
// Строка 23: Аванс
if (index === 23 && colB && colB.includes('Аванс')) {
result.advance = colF || '';
}
// Строки 24-25: Банковские переводы
if (index >= 24 && index <= 25 && colB && colC) {
const bankName = colB.replace('Отчислено в банк', '').trim();
const amount = parseFloat(colC.replace(',', '.')) || 0;
if (bankName && amount > 0) {
result.bankTransfers.push({ bank: bankName, amount });
}
}
// Строка 26: ВСЕГО ВЫПЛАЧЕНО
if (index === 26 && colG) {
result.totalPaid = parseFloat(colG.replace(',', '.')) || 0;
}
// Строка 28: Прочее
if (index >= 28 && index <= 29 && colB && colG) {
const code = colA || '';
const description = colB;
const amount = parseFloat(colG.replace(',', '.')) || 0;
if (description && !description.includes('ВСЕГО')) {
result.other.push({
code,
description,
amount
});
}
}
// Строка 30: ВСЕГО ПРОЧЕЕ
if (index === 30 && colG) {
result.totalOther = parseFloat(colG.replace(',', '.')) || 0;
}
});
logger.info('[ExcelParser] Парсинг завершен успешно');
return result;
} catch (error) {
logger.error('[ExcelParser] Ошибка парсинга:', error.message);
throw error;
}
}
module.exports = { parsePayslipExcel };
+258
View File
@@ -0,0 +1,258 @@
const { logger } = require('../config/logger');
/**
* Генерирует HTML-форму расчетного листка для термопринтера
* @param {Object} data - данные из Excel
* @returns {string} HTML-код формы
*/
function generatePayslipForm(data) {
try {
logger.info('[FormGenerator] Генерация HTML-формы');
const monthNames = {
'Январь': '01', 'Февраль': '02', 'Март': '03', 'Апрель': '04',
'Май': '05', 'Июнь': '06', 'Июль': '07', 'Август': '08',
'Сентябрь': '09', 'Октябрь': '10', 'Ноябрь': '11', 'Декабрь': '12'
};
const monthNum = monthNames[data.month] || '00';
const period = `${monthNum}${data.year || '2026'}`;
// Формируем HTML для термопринтера (80мм ширина)
let html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Расчетный листок</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 80mm;
margin: 0 auto;
padding: 8px 4px;
font-family: 'Courier New', Courier, monospace;
font-size: 11px;
color: #000;
background: #fff;
}
.header {
text-align: center;
font-size: 14px;
font-weight: bold;
padding-bottom: 4px;
border-bottom: 1px dashed #000;
margin-bottom: 4px;
}
.sub-header {
text-align: center;
font-size: 12px;
padding-bottom: 4px;
border-bottom: 1px dashed #000;
margin-bottom: 4px;
}
.row {
display: flex;
justify-content: space-between;
padding: 2px 0;
font-size: 11px;
}
.row-label {
font-weight: bold;
}
.row-value {
text-align: right;
}
.divider {
border-top: 1px dashed #000;
margin: 4px 0;
padding-top: 4px;
}
.total-row {
display: flex;
justify-content: space-between;
padding: 3px 0;
font-weight: bold;
font-size: 12px;
border-top: 1px solid #000;
margin-top: 2px;
}
.section-title {
font-weight: bold;
font-size: 12px;
padding: 4px 0 2px 0;
border-bottom: 1px dotted #000;
margin-bottom: 2px;
}
.item-row {
display: flex;
justify-content: space-between;
padding: 1px 0;
font-size: 10px;
padding-left: 4px;
}
.item-desc {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-amount {
text-align: right;
min-width: 50px;
}
.footer {
text-align: center;
font-size: 9px;
color: #666;
border-top: 1px dashed #000;
margin-top: 6px;
padding-top: 4px;
}
.bank-row {
display: flex;
justify-content: space-between;
padding: 1px 0;
font-size: 10px;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 1px 0;
font-size: 10px;
}
@media print {
body { width: 100%; }
}
</style>
</head>
<body>
<div class="header">
📄 РАСЧЕТНЫЙ ЛИСТОК
</div>
<div class="sub-header">
${data.month || ''} ${data.year || ''} (${period})
</div>
<!-- Информация о сотруднике -->
<div class="info-row">
<span>ФИО:</span>
<span>${data.fullName || ''}</span>
</div>
<div class="info-row">
<span>Таб.№:</span>
<span>${data.tabNumber || ''}</span>
</div>
<div class="info-row">
<span>Подразделение:</span>
<span style="font-size:9px;max-width:120px;overflow:hidden;">${data.department || ''}</span>
</div>
<div class="info-row">
<span>Должность:</span>
<span style="font-size:9px;max-width:120px;overflow:hidden;">${data.position || ''}</span>
</div>
<div class="info-row">
<span>Отработано:</span>
<span>${data.daysWorked || ''}</span>
</div>
<div class="divider"></div>
<!-- Начисления -->
<div class="section-title">📈 НАЧИСЛЕНО</div>
${data.accrued.map(item => `
<div class="item-row">
<span class="item-desc">${item.code || ''} ${item.description || ''}</span>
<span class="item-amount">${item.amount.toFixed(2)}</span>
</div>
`).join('')}
<div class="total-row">
<span>ИТОГО НАЧИСЛЕНО:</span>
<span>${data.totalAccrued.toFixed(2)}</span>
</div>
<div class="divider"></div>
<!-- Удержания -->
<div class="section-title">📉 УДЕРЖАНО</div>
${data.deductions.map(item => `
<div class="item-row">
<span class="item-desc">${item.code || ''} ${item.description || ''}</span>
<span class="item-amount">${item.amount.toFixed(2)}</span>
</div>
`).join('')}
<div class="total-row">
<span>ИТОГО УДЕРЖАНО:</span>
<span>${data.totalDeductions.toFixed(2)}</span>
</div>
<div class="divider"></div>
<!-- Итоги -->
<div class="total-row" style="border-top:2px solid #000;font-size:14px;">
<span>💰 К ВЫПЛАТЕ:</span>
<span>${data.totalPaid.toFixed(2)}</span>
</div>
${data.advance ? `
<div class="row">
<span>Аванс:</span>
<span>${data.advance}</span>
</div>
` : ''}
<!-- Банковские переводы -->
${data.bankTransfers.length > 0 ? `
<div class="divider"></div>
<div class="section-title">🏦 БАНКОВСКИЕ ПЕРЕВОДЫ</div>
${data.bankTransfers.map(bank => `
<div class="bank-row">
<span>${bank.bank || ''}</span>
<span>${bank.amount.toFixed(2)}</span>
</div>
`).join('')}
` : ''}
<!-- Лицевой счет -->
${data.accountNumber ? `
<div class="divider"></div>
<div class="info-row" style="font-size:9px;color:#666;">
<span>Лицевой счет:</span>
<span style="font-size:8px;">${data.accountNumber}</span>
</div>
` : ''}
<!-- Прочее -->
${data.other.length > 0 ? `
<div class="divider"></div>
<div class="section-title">📌 ПРОЧЕЕ</div>
${data.other.map(item => `
<div class="item-row">
<span class="item-desc">${item.code || ''} ${item.description || ''}</span>
<span class="item-amount">${item.amount.toFixed(2)}</span>
</div>
`).join('')}
<div class="row">
<span>ИТОГО ПРОЧЕЕ:</span>
<span>${data.totalOther.toFixed(2)}</span>
</div>
` : ''}
<div class="footer">
Сформировано: ${new Date().toLocaleString('ru-RU')}
<br>
Документ действителен при предъявлении
</div>
</body>
</html>`;
logger.info('[FormGenerator] HTML-форма сгенерирована');
return html;
} catch (error) {
logger.error('[FormGenerator] Ошибка генерации:', error.message);
throw error;
}
}
module.exports = { generatePayslipForm };
Binary file not shown.
+939 -8
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -9,20 +9,25 @@
"@testing-library/user-event": "^13.5.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"axios": "^1.19.0",
"axios": "^1.20.0",
"date-fns": "^2.30.0",
"dompurify": "^3.4.13",
"dotenv": "^17.4.2",
"electron-is-dev": "^2.0.0",
"framer-motion": "^13.1.0",
"pdf-lib": "^1.17.1",
"pdf-to-printer": "^5.8.1",
"pdfjs-dist": "^3.11.174",
"pdfkit": "^0.20.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-icons": "^4.11.0",
"react-pdf": "^8.0.0",
"react-zoom-pan-pinch": "^3.3.0",
"sharp": "^0.35.4",
"typescript": "^5.8.0",
"web-vitals": "^2.1.4"
"web-vitals": "^2.1.4",
"xlsx": "^0.18.5"
},
"scripts": {
"start": "vite",
Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

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