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 'quill/dist/quill.snow.css';
import '../App.css'; import '../App.css';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import SlidesManager from './SlidesManager';
function Editor() { function Editor() {
const [isLoggedIn, setIsLoggedIn] = useState(false); const [isLoggedIn, setIsLoggedIn] = useState(false);
@@ -15,7 +16,6 @@ function Editor() {
const [editContent, setEditContent] = useState(''); const [editContent, setEditContent] = useState('');
const [editPreviewContent, setEditPreviewContent] = useState(''); const [editPreviewContent, setEditPreviewContent] = useState('');
const [showConfirmModal, setShowConfirmModal] = useState(false); const [showConfirmModal, setShowConfirmModal] = useState(false);
//const [csrfToken, setCsrfToken] = useState('');
const [notify, setNotify] = useState({ message: '', type: '', show: false }); const [notify, setNotify] = useState({ message: '', type: '', show: false });
const editorRef = useRef(null); const editorRef = useRef(null);
@@ -29,17 +29,6 @@ function Editor() {
setNotify({ message: '', type: '', show: false }); setNotify({ message: '', type: '', show: false });
}, 2200); }, 2200);
}, []); }, []);
// Fetch CSRF token
//useEffect(() => {
// fetch('/moders/csrf-token')
// .then(res => res.json())
// .then(data => {
// setCsrfToken(data.csrfToken);
//})
//.catch(() => {});
//}, []);
// Check session on mount // Check session on mount
useEffect(() => { useEffect(() => {
@@ -55,9 +44,9 @@ function Editor() {
.catch(() => setIsLoggedIn(false)); .catch(() => setIsLoggedIn(false));
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
const authenticatedFetch = useCallback((url, options = {}) => { const authenticatedFetch = useCallback((url, options = {}) => {
return fetch(url, options); return fetch(url, options);
}, []); }, []);
const loadBlocks = useCallback(async (tab) => { const loadBlocks = useCallback(async (tab) => {
try { try {
@@ -105,7 +94,7 @@ const authenticatedFetch = useCallback((url, options = {}) => {
const handleTabChange = (tab) => { const handleTabChange = (tab) => {
setActiveTab(tab); setActiveTab(tab);
if (isLoggedIn) { if (isLoggedIn && tab !== 'slides') {
loadBlocks(tab); loadBlocks(tab);
} }
}; };
@@ -138,41 +127,35 @@ const authenticatedFetch = useCallback((url, options = {}) => {
}; };
const handleSaveBlock = async (e) => { const handleSaveBlock = async (e) => {
e.preventDefault(); e.preventDefault();
const content = DOMPurify.sanitize(editContent.trim()); const content = DOMPurify.sanitize(editContent.trim());
if (!content || content === '<p><br></p>') return; if (!content || content === '<p><br></p>') return;
const url = editingBlockId const url = editingBlockId
? `/moders/content/${activeTab}/${editingBlockId}` ? `/moders/content/${activeTab}/${editingBlockId}`
: `/moders/content/${activeTab}`; : `/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 { if (response.ok) {
const response = await fetch(url, { showNotify(editingBlockId ? 'Блок обновлён' : 'Блок добавлен', 'success');
method, setShowEditModal(false);
headers: { 'Content-Type': 'application/json' }, loadBlocks(activeTab);
body: JSON.stringify({ type: 'text', content }) } else {
}); const data = await response.json();
showNotify(`Ошибка: ${data.message || response.status}`, 'error');
const data = await response.json(); }
} catch (e) {
console.log('⬅️ Ответ сервера:', response.status, data); showNotify('Ошибка сохранения', 'error');
if (response.ok) {
showNotify(editingBlockId ? 'Блок обновлён' : 'Блок добавлен', 'success');
setShowEditModal(false);
loadBlocks(activeTab);
} else {
showNotify(`Ошибка: ${data.message || response.status}`, 'error');
} }
} catch (e) { };
console.error('❌ Ошибка при сохранении:', e);
showNotify('Ошибка сохранения', 'error');
}
};
const handleTryCloseEditModal = () => { const handleTryCloseEditModal = () => {
if (editContent !== originalContentRef.current) { if (editContent !== originalContentRef.current) {
@@ -194,7 +177,6 @@ const authenticatedFetch = useCallback((url, options = {}) => {
// Initialize Quill when modal opens // Initialize Quill when modal opens
useEffect(() => { useEffect(() => {
if (showEditModal && editorRef.current && !quillRef.current) { if (showEditModal && editorRef.current && !quillRef.current) {
// Dynamically import Quill
import('quill').then((QuillModule) => { import('quill').then((QuillModule) => {
const Quill = QuillModule.default || QuillModule; const Quill = QuillModule.default || QuillModule;
if (editorRef.current) { if (editorRef.current) {
@@ -225,51 +207,68 @@ const authenticatedFetch = useCallback((url, options = {}) => {
}); });
} }
// Cleanup Quill when modal closes
if (!showEditModal && quillRef.current) { if (!showEditModal && quillRef.current) {
quillRef.current = null; quillRef.current = null;
} }
}, [showEditModal, editContent]); }, [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 ( return (
<> <>
<div id="notify" className={notify.show ? `show ${notify.type}` : ''}>{notify.message}</div> <div id="notify" className={notify.show ? `show ${notify.type}` : ''}>{notify.message}</div>
<div className="container"> <div className="container">
<h1>Панель модератора</h1> <h1>Панель модератора</h1>
{!isLoggedIn ? ( <button id="logout-btn" style={{ float: 'right' }} onClick={handleLogout}>Выйти из системы</button>
<div id="login-section"> <h2>Редактирование вкладок</h2>
<h2>Вход для модератора</h2>
<form id="moder-login-form" onSubmit={handleLogin}> <div className="tab-controls">
<div className="form-group"> <button className={`tab-btn ${activeTab === 'info' ? 'active' : ''}`} onClick={() => handleTabChange('info')} data-tab="info">
<label htmlFor="username">Логин:</label> Общая информация
<input type="text" id="username" name="username" required value={username} onChange={(e) => setUsername(e.target.value)} /> </button>
</div> <button className={`tab-btn ${activeTab === 'events' ? 'active' : ''}`} onClick={() => handleTabChange('events')} data-tab="events">
<div className="form-group"> 📅 Мероприятия
<label htmlFor="password">Пароль:</label> </button>
<input type="password" id="password" name="password" required value={password} onChange={(e) => setPassword(e.target.value)} /> <button className={`tab-btn ${activeTab === 'help' ? 'active' : ''}`} onClick={() => handleTabChange('help')} data-tab="help">
</div> 💰 Материальная помощь
<button type="submit">Войти</button> </button>
{loginError && <p id="login-error" style={{ color: 'red' }}>{loginError}</p>} <button className={`tab-btn ${activeTab === 'housing' ? 'active' : ''}`} onClick={() => handleTabChange('housing')} data-tab="housing">
</form> 🏠 Жилищные условия
</div> </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' }}> <div style={{ margin: '18px 0' }}>
<button id="add-block-btn" onClick={handleAddBlock}>Добавить блок</button> <button id="add-block-btn" onClick={handleAddBlock}>Добавить блок</button>
</div> </div>
@@ -283,9 +282,10 @@ const authenticatedFetch = useCallback((url, options = {}) => {
</div> </div>
))} ))}
</div> </div>
</div> </>
)} )}
{/* Модалка редактирования блоков */}
{showEditModal && ( {showEditModal && (
<div id="edit-modal" className="modal" style={{ display: 'block' }} onClick={(e) => { <div id="edit-modal" className="modal" style={{ display: 'block' }} onClick={(e) => {
if (e.target === e.currentTarget) handleTryCloseEditModal(); 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.177Z] [INFO] Login successful
[2026-08-18T13:21:48.283Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content [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-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
+180 -64
View File
@@ -9,93 +9,211 @@ const fs = require('fs');
const uploadDir = path.join(__dirname, '../../uploads'); const uploadDir = path.join(__dirname, '../../uploads');
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true }); if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, uploadDir), destination: (req, file, cb) => cb(null, uploadDir),
filename: (req, file, cb) => { filename: (req, file, cb) => {
const ext = path.extname(file.originalname); const ext = path.extname(file.originalname);
const name = Date.now() + '-' + Math.round(Math.random() * 1e6) + ext; const name = Date.now() + '-' + Math.round(Math.random() * 1e6) + ext;
cb(null, name); cb(null, name);
} }
}); });
const ALLOWED_MIME_TYPES = [ const ALLOWED_MIME_TYPES = [
'image/jpeg', 'image/png', 'image/jpeg', 'image/png',
'video/mp4', 'video/quicktime' 'video/mp4', 'video/quicktime'
]; ];
const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20 МБ const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20 МБ
const upload = multer({ const upload = multer({
storage, storage,
limits: { fileSize: MAX_FILE_SIZE }, limits: { fileSize: MAX_FILE_SIZE },
fileFilter: (req, file, cb) => { fileFilter: (req, file, cb) => {
if (ALLOWED_MIME_TYPES.includes(file.mimetype)) { if (ALLOWED_MIME_TYPES.includes(file.mimetype)) {
cb(null, true); cb(null, true);
} else { } else {
cb(new Error('Недопустимый тип файла')); 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) => { router.get('/:tab', async (req, res) => {
const { tab } = req.params; const { tab } = req.params;
if (!['info', 'union', 'events', 'help', 'housing'].includes(tab)) return res.status(400).json({ message: 'Некорректная вкладка' }); if (!['info', 'union', 'events', 'help', 'housing'].includes(tab)) return res.status(400).json({ message: 'Некорректная вкладка' });
try { try {
const [rows] = await db.query('SELECT * FROM content_blocks WHERE tab = ? ORDER BY created_at ASC', [tab]); const [rows] = await db.query('SELECT * FROM content_blocks WHERE tab = ? ORDER BY created_at ASC', [tab]);
res.json(rows); res.json(rows);
} catch (err) { } catch (err) {
res.status(500).json({ message: 'Ошибка сервера' }); res.status(500).json({ message: 'Ошибка сервера' });
} }
}); });
// Загрузка файла (image/video) // Загрузка файла (image/video)
router.post('/:tab/upload', requireModerator, upload.single('file'), (req, res) => { router.post('/:tab/upload', requireModerator, upload.single('file'), (req, res) => {
if (!req.file) return res.status(400).json({ message: 'Файл не загружен' }); if (!req.file) return res.status(400).json({ message: 'Файл не загружен' });
const relPath = '/uploads/' + req.file.filename; const relPath = '/uploads/' + req.file.filename;
res.json({ path: relPath }); res.json({ path: relPath });
}); });
// Добавить новый блок // Добавить новый блок
router.post('/:tab', requireModerator, async (req, res) => { router.post('/:tab', requireModerator, async (req, res) => {
const { tab } = req.params; const { tab } = req.params;
const { content } = req.body; const { content } = req.body;
if (!['info', 'union', 'events', 'help', 'housing'].includes(tab)) return res.status(400).json({ message: 'Некорректная вкладка' }); if (!['info', 'union', 'events', 'help', 'housing'].includes(tab)) return res.status(400).json({ message: 'Некорректная вкладка' });
try { try {
const [result] = await db.query( const [result] = await db.query(
'INSERT INTO content_blocks (tab, type, content, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW())', 'INSERT INTO content_blocks (tab, type, content, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW())',
[tab, 'text', content, req.session.userId] [tab, 'text', content, req.session.userId]
); );
res.json({ id: result.insertId }); res.json({ id: result.insertId });
} catch (err) { } catch (err) {
res.status(500).json({ message: 'Ошибка сервера' }); res.status(500).json({ message: 'Ошибка сервера' });
} }
}); });
// Обновить блок // Обновить блок
router.put('/:tab/:id', requireModerator, async (req, res) => { router.put('/:tab/:id', requireModerator, async (req, res) => {
const { tab, id } = req.params; const { tab, id } = req.params;
const { content } = req.body; const { content } = req.body;
if (!['info', 'union', 'events', 'help', 'housing'].includes(tab)) return res.status(400).json({ message: 'Некорректная вкладка' }); if (!['info', 'union', 'events', 'help', 'housing'].includes(tab)) return res.status(400).json({ message: 'Некорректная вкладка' });
try { try {
await db.query( await db.query(
'UPDATE content_blocks SET content = ?, updated_at = NOW() WHERE id = ? AND tab = ?', 'UPDATE content_blocks SET content = ?, updated_at = NOW() WHERE id = ? AND tab = ?',
[content, id, tab] [content, id, tab]
); );
res.json({ message: 'Обновлено' }); res.json({ message: 'Обновлено' });
} catch (err) { } catch (err) {
res.status(500).json({ message: 'Ошибка сервера' }); res.status(500).json({ message: 'Ошибка сервера' });
} }
}); });
// Удалить блок // Удалить блок
router.delete('/:tab/:id', requireModerator, async (req, res) => { router.delete('/:tab/:id', requireModerator, async (req, res) => {
const { tab, id } = req.params; const { tab, id } = req.params;
if (!['info', 'union', 'events', 'help', 'housing'].includes(tab)) return res.status(400).json({ message: 'Некорректная вкладка' }); if (!['info', 'union', 'events', 'help', 'housing'].includes(tab)) return res.status(400).json({ message: 'Некорректная вкладка' });
try { try {
await db.query('DELETE FROM content_blocks WHERE id = ? AND tab = ?', [id, tab]); await db.query('DELETE FROM content_blocks WHERE id = ? AND tab = ?', [id, tab]);
res.json({ message: 'Удалено' }); res.json({ message: 'Удалено' });
} catch (err) { } catch (err) {
res.status(500).json({ message: 'Ошибка сервера' }); res.status(500).json({ message: 'Ошибка сервера' });
} }
}); });
router.get('/section/:section', async (req, res) => { router.get('/section/:section', async (req, res) => {
const { section } = req.params; const { section } = req.params;
if (!['events', 'help', 'housing'].includes(section)) { 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', 'SELECT * FROM content_blocks WHERE tab = ? ORDER BY created_at ASC',
[section] [section]
); );
// Если есть блоки — возвращаем их, иначе пустой массив
res.json(rows); res.json(rows);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -114,7 +231,7 @@ router.get('/section/:section', async (req, res) => {
} }
}); });
// Сохранить контент раздела (очищаем старые и вставляем новый) // Сохранить контент раздела
router.post('/section/:section', requireModerator, async (req, res) => { router.post('/section/:section', requireModerator, async (req, res) => {
const { section } = req.params; const { section } = req.params;
const { content } = req.body; const { content } = req.body;
@@ -124,10 +241,8 @@ router.post('/section/:section', requireModerator, async (req, res) => {
} }
try { try {
// Удаляем старые блоки для этого раздела
await db.query('DELETE FROM content_blocks WHERE tab = ?', [section]); await db.query('DELETE FROM content_blocks WHERE tab = ?', [section]);
// Вставляем новый блок
const [result] = await db.query( const [result] = await db.query(
'INSERT INTO content_blocks (tab, type, content, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW())', 'INSERT INTO content_blocks (tab, type, content, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW())',
[section, 'text', content, req.session.userId] [section, 'text', content, req.session.userId]
@@ -143,4 +258,5 @@ router.post('/section/:section', requireModerator, async (req, res) => {
res.status(500).json({ message: 'Ошибка сервера' }); res.status(500).json({ message: 'Ошибка сервера' });
} }
}); });
module.exports = router;
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": { "server": {
"host": "localhost", "host": "localhost",
"port": "3001" "port": "3001"
@@ -51,9 +56,9 @@
} }
], ],
"payslips": [ "payslips": [
"Петров_ПП_2026-08.pdf", "001.pdf",
"Сидорова_АС_2026-08.pdf", "002.pdf",
"Иванов_ИИ_2026-08.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) { async function pathExists(filePath) {
console.log('🔍 Проверка пути:', filePath);
try { try {
await fsPromises.access(filePath); await fsPromises.access(filePath);
return true; return true;
} catch { } catch (err) {
return false; return false;
} }
} }
@@ -69,7 +70,9 @@ async function connectToNetworkShare(config) {
// ============================================ // ============================================
const app = express(); const app = express();
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json());
app.use(function(req, res, next) { app.use(function(req, res, next) {
@@ -81,7 +84,31 @@ app.use(function(req, res, next) {
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); 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) { app.get('/api/db-status', async function(req, res) {
try { try {
var db = require('./config/db'); 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", "@testing-library/user-event": "^13.5.0",
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0", "@types/react-dom": "^19.2.0",
"axios": "^1.19.0", "axios": "^1.20.0",
"date-fns": "^2.30.0", "date-fns": "^2.30.0",
"dompurify": "^3.4.13", "dompurify": "^3.4.13",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"electron-is-dev": "^2.0.0", "electron-is-dev": "^2.0.0",
"framer-motion": "^13.1.0", "framer-motion": "^13.1.0",
"pdf-lib": "^1.17.1",
"pdf-to-printer": "^5.8.1",
"pdfjs-dist": "^3.11.174", "pdfjs-dist": "^3.11.174",
"pdfkit": "^0.20.2",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-icons": "^4.11.0", "react-icons": "^4.11.0",
"react-pdf": "^8.0.0", "react-pdf": "^8.0.0",
"react-zoom-pan-pinch": "^3.3.0", "react-zoom-pan-pinch": "^3.3.0",
"sharp": "^0.35.4",
"typescript": "^5.8.0", "typescript": "^5.8.0",
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4",
"xlsx": "^0.18.5"
}, },
"scripts": { "scripts": {
"start": "vite", "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')}) require('dotenv').config({path: path.join(__dirname, 'config.env')})
const isDev = require('electron-is-dev'); const isDev = require('electron-is-dev');
const fs = require('fs'); const fs = require('fs');
const COM_PORT = process.env.COM_PORT;
const COM_SPEED = process.env.COM_SPEED;
const axios = require('axios'); 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 config = require('../src/config.json')
const { print } = require('pdf-to-printer');
protocol.registerSchemesAsPrivileged([ protocol.registerSchemesAsPrivileged([
{ {
scheme: 'app', scheme: 'app',
@@ -64,13 +64,12 @@ function createWindow() {
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
kiosk: true, kiosk: true,
titleBarStyle:'hidden', titleBarStyle: 'hidden',
alwaysOnTop: false, alwaysOnTop: false,
webPreferences: { webPreferences: {
preload: preloadPath, preload: preloadPath,
contextIsolation: true, contextIsolation: true,
nodeIntegration: false 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(() => { app.whenReady().then(() => {
createWindow(); createWindow();
global.allowMinimize = false; global.allowMinimize = false;
@@ -112,14 +203,13 @@ app.whenReady().then(() => {
} }
}, 10); }, 10);
}); });
globalShortcut.register('Control+Alt+Enter', () => { globalShortcut.register('Control+Alt+Enter', () => {
global.allowClose = true; global.allowClose = true;
app.quit(); app.quit();
}); });
globalShortcut.register('F11', () => { globalShortcut.register('F11', () => {});
});
}); });
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
@@ -138,12 +228,6 @@ app.on('will-quit', () => {
globalShortcut.unregisterAll(); globalShortcut.unregisterAll();
}); });
//считыватель
ipcMain.handle('get-app-config', async (event) => { ipcMain.handle('get-app-config', async (event) => {
console.log('[IPC] Renderer requested app config. Sending:', appConfig); return appConfig;
return appConfig;
});
app.whenReady().then(() => {
}); });
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'); 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( contextBridge.exposeInMainWorld(
'electronAPI', 'electronAPI',
{ {
@@ -8,12 +14,13 @@ contextBridge.exposeInMainWorld(
onCardDetected: (callback) => { onCardDetected: (callback) => {
ipcRenderer.on('card-detected', (event, uid) => callback(uid)); ipcRenderer.on('card-detected', (event, uid) => callback(uid));
}, },
removeCardDetectedListeners: () => { removeCardDetectedListeners: () => {
ipcRenderer.removeAllListeners('card-detected'); 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')); window.dispatchEvent(new Event('electronApiReady'));
+11 -12
View File
@@ -45,6 +45,7 @@ function App() {
const [currentSection, setCurrentSection] = useState<string | null>(null); const [currentSection, setCurrentSection] = useState<string | null>(null);
const [showPayslip, setShowPayslip] = useState(false); const [showPayslip, setShowPayslip] = useState(false);
const [selectedPeriod, setSelectedPeriod] = useState(''); const [selectedPeriod, setSelectedPeriod] = useState('');
const [payslipFileUrl, setPayslipFileUrl] = useState<string>('');
// 📊 Состояния для таймера // 📊 Состояния для таймера
const [showInactiveWarning, setShowInactiveWarning] = useState(false); const [showInactiveWarning, setShowInactiveWarning] = useState(false);
@@ -64,6 +65,7 @@ function App() {
setCurrentSection(null); setCurrentSection(null);
setShowPayslip(false); setShowPayslip(false);
setSelectedPeriod(''); setSelectedPeriod('');
setPayslipFileUrl('');
setShowInactiveWarning(false); setShowInactiveWarning(false);
setLoginError(''); setLoginError('');
@@ -138,6 +140,7 @@ function App() {
setCurrentSection(null); setCurrentSection(null);
setShowPayslip(false); setShowPayslip(false);
setSelectedPeriod(''); setSelectedPeriod('');
setPayslipFileUrl('');
handleUserActivity(); handleUserActivity();
}; };
@@ -146,8 +149,8 @@ function App() {
handleUserActivity(); handleUserActivity();
}; };
const handleViewPayslip = (period: string) => { const handleViewPayslip = (fileUrl: string) => {
setSelectedPeriod(period); setPayslipFileUrl(fileUrl);
setShowPayslip(true); setShowPayslip(true);
handleUserActivity(); handleUserActivity();
}; };
@@ -155,14 +158,10 @@ function App() {
const handlePayslipBack = () => { const handlePayslipBack = () => {
setShowPayslip(false); setShowPayslip(false);
setSelectedPeriod(''); setSelectedPeriod('');
setPayslipFileUrl('');
handleUserActivity(); 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) { if (showPayslip) {
return ( return (
<PayslipViewer <PayslipViewer
fileUrl={employee && selectedPeriod ? getPayslipUrl(employee, selectedPeriod) : "/payslips/test.pdf"} fileUrl={payslipFileUrl || "/payslips/test.pdf"}
onBack={handlePayslipBack} onBack={handlePayslipBack}
/> />
); );
@@ -316,7 +315,7 @@ function App() {
// 🚀 Рендер приложения // 🚀 Рендер приложения
// ============================================ // ============================================
const showTopBar = !showVideo && !showPayslip; const showTopBar = !showVideo && !showPayslip;
const topBarHeight = 70; // высота виджета const topBarHeight = 70;
return ( return (
<div <div
@@ -331,9 +330,9 @@ function App() {
{/* ⚠️ Предупреждение о неактивности */} {/* ⚠️ Предупреждение о неактивности */}
{renderWarning()} {renderWarning()}
{/* 🌤️ ВЕРХНИЙ ВИДЖЕТ: ПОГОДА + ВРЕМЯ + ВЫХОД */} {/* 🌤️ ВЕРХНИЙ ВИДЖЕТ: ПОГОДА + ВРЕМЯ + ПРИВЕТСТВИЕ + ВЫХОД */}
{showTopBar && ( {showTopBar && (
<WeatherWidget onLogout={handleBack} /> <WeatherWidget onLogout={handleBack} employee={employee || undefined} />
)} )}
{/* 📄 Основной контент с отступом сверху */} {/* 📄 Основной контент с отступом сверху */}
@@ -348,7 +347,7 @@ function App() {
{renderContent()} {renderContent()}
</div> </div>
{/* 📊 НИЖНЯЯ ПАНЕЛЬ: КУРСЫ ВАЛЮТ */} {/* 📊 НИЖНЯЯ ПАНЕЛЬ: ТОЛЬКО КУРСЫ ВАЛЮТ (БЕЗ ПРИВЕТСТВИЯ) */}
{showTopBar && ( {showTopBar && (
<BottomBar /> <BottomBar />
)} )}
+156 -161
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback, memo } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { FaArrowLeft, FaSearch, FaArrowUp, FaArrowDown } from 'react-icons/fa'; import { FaArrowLeft, FaSearch, FaArrowUp, FaArrowDown } from 'react-icons/fa';
import MapViewer from './MapViewer'; import MapViewer from './MapViewer';
@@ -75,7 +75,7 @@ function parseHandbookBlocks(html: string) {
} }
// ============================================ // ============================================
// 📚 Функции рендеринга с подсветкой и поиском // 📚 Функции рендеринга
// ============================================ // ============================================
const ADMIN_SERVER_URL = config.adminServerUrl || 'http://localhost:3005'; 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"`); .replace(/<video([^>]+)src=["'](\/uploads\/[^"']+)["']/g, `<video$1src="${ADMIN_SERVER_URL}$2"`);
} }
function stripHtml(html: string): string { // ✅ Простая подсветка через replace (без DOM-обхода)
const tmp = document.createElement('div'); function highlightText(html: string, searchTerm: string): string {
tmp.innerHTML = html; if (!searchTerm.trim()) return html;
return tmp.textContent || tmp.innerText || '';
}
// ✅ Подсветка текста внутри HTML с сохранением разметки
function highlightTextInHtml(html: string, searchTerm: string): { html: string; count: number } {
if (!searchTerm.trim()) return { html, count: 0 };
const term = searchTerm.trim(); const term = searchTerm.trim();
const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(${escapedTerm})`, 'gi'); const regex = new RegExp(`(${escapedTerm})`, 'gi');
const temp = document.createElement('div'); return html.replace(/>([^<]*)</g, (match: string, text: string) => {
temp.innerHTML = html; const highlighted = text.replace(regex, (m: string) =>
let matchCount = 0; `<mark style="background: #ffdd57; color: #1a3c34; padding: 0 4px; border-radius: 3px; font-weight: 600;">${m}</mark>`
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 `>${highlighted}<`;
return true; });
}
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: any[]; blocks,
searchTerm: string; searchTerm,
onMatchCount
}: {
blocks: any[];
searchTerm: string;
onMatchCount: (count: number) => void; onMatchCount: (count: number) => void;
}> = ({ blocks, searchTerm, onMatchCount }) => { }) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [renderedHtml, setRenderedHtml] = useState<{ html: string; count: number }[]>([]); const [renderedBlocks, setRenderedBlocks] = useState<any[]>([]);
const [totalMatches, setTotalMatches] = useState(0); const [totalMatches, setTotalMatches] = useState(0);
const hasSearch = searchTerm.trim().length > 0; const [isSearching, setIsSearching] = useState(false);
const processingRef = useRef(false);
// ✅ Обработка блоков с задержкой через requestAnimationFrame
useEffect(() => { useEffect(() => {
if (!blocks.length) return; 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') { setIsSearching(true);
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;
}
let tableHtml = `<table class="handbook-table">`; // ✅ Используем requestAnimationFrame для отложенной обработки
tableHtml += ` requestAnimationFrame(() => {
<thead> // ✅ Еще одна задержка через setTimeout для накопления изменений
<tr> const timeoutId = setTimeout(() => {
<th>Описание</th> const processed = blocks.map(block => {
<th>АТС предприятия</th> if (typeof block === 'string') {
<th>Городская АТС</th> const content = patchMediaSrc(block);
</tr> if (searchTerm.trim()) {
</thead> return highlightText(content, searchTerm);
<tbody>`; }
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 tableHtml = `<table class="handbook-table">`;
let descHtml = row.desc; tableHtml += `
let internalHtml = row.internal; <thead>
let cityHtml = row.city; <tr>
let rowHasMatch = false; <th>Описание</th>
<th>АТС предприятия</th>
<th>Городская АТС</th>
</tr>
</thead>
<tbody>`;
if (hasSearch && term) { rows.forEach((row: { desc: string; internal: string; city: string }) => {
const descResult = highlightTextInHtml(row.desc, searchTerm); let descHtml = row.desc;
descHtml = descResult.html; let internalHtml = row.internal;
if (descResult.count > 0) rowHasMatch = true; let cityHtml = row.city;
const internalResult = highlightTextInHtml(row.internal, searchTerm);
internalHtml = internalResult.html; if (term) {
if (internalResult.count > 0) rowHasMatch = true; descHtml = highlightText(row.desc, searchTerm);
const cityResult = highlightTextInHtml(row.city, searchTerm); internalHtml = highlightText(row.internal, searchTerm);
cityHtml = cityResult.html; cityHtml = highlightText(row.city, searchTerm);
if (cityResult.count > 0) rowHasMatch = true; }
total += descResult.count + internalResult.count + cityResult.count;
tableHtml += `
<tr>
<td>${descHtml}</td>
<td>${internalHtml}</td>
<td>${cityHtml}</td>
</tr>`;
});
tableHtml += `</tbody></table>`;
return tableHtml;
} }
return null;
}).filter(Boolean);
tableHtml += ` setRenderedBlocks(processed);
<tr> setIsSearching(false);
<td>${descHtml}</td> processingRef.current = false;
<td>${internalHtml}</td>
<td>${cityHtml}</td>
</tr>`;
});
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]); }, [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 ( return (
<div ref={containerRef} className="handbook-content"> <div ref={containerRef} className="handbook-content">
{renderedHtml.map((item, idx) => ( {isSearching && (
item.html ? <div key={idx} style={{ marginBottom: 16 }} dangerouslySetInnerHTML={{ __html: item.html }} /> : null <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> </div>
); );
}; });
// ============================================ // ============================================
// 🏠 Основной компонент Contacts // 🏠 Основной компонент Contacts
@@ -279,6 +258,10 @@ const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
const [currentMatchIndex, setCurrentMatchIndex] = useState(0); const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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'; const MAP_FILE_URL = '/assets/maps/enterprise-map.tiff';
@@ -288,6 +271,26 @@ const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
} }
}, [activeTab]); }, [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 () => { const loadHandbook = async () => {
setLoadingHandbook(true); setLoadingHandbook(true);
setErrorHandbook(null); setErrorHandbook(null);
@@ -329,10 +332,10 @@ const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
const container = contentRef.current; const container = contentRef.current;
if (!container) return; if (!container) return;
const marks = container.querySelectorAll('mark[data-match-index]'); const marks = container.querySelectorAll('mark');
if (marks.length === 0) return; 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; if (!targetMark) return;
const containerRect = container.getBoundingClientRect(); const containerRect = container.getBoundingClientRect();
@@ -629,7 +632,6 @@ const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
</div> </div>
</div> </div>
{/* ✅ БЕЛЫЙ ФОН, ТЁМНЫЙ ТЕКСТ */}
<div <div
ref={contentRef} ref={contentRef}
style={{ style={{
@@ -676,10 +678,8 @@ const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
) : blocks.length > 0 ? ( ) : blocks.length > 0 ? (
<HandbookRenderer <HandbookRenderer
blocks={blocks} blocks={blocks}
searchTerm={searchTerm} searchTerm={debouncedSearchTerm}
onMatchCount={(count) => { onMatchCount={setMatchCount}
setMatchCount(count);
}}
/> />
) : ( ) : (
<p style={{ textAlign: 'center', color: '#b2bec3' }}>Нет данных для отображения</p> <p style={{ textAlign: 'center', color: '#b2bec3' }}>Нет данных для отображения</p>
@@ -744,7 +744,6 @@ const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
)} )}
</div> </div>
{/* ✅ ТЁМНЫЙ ТЕКСТ ДЛЯ СПРАВОЧНИКА */}
<style> <style>
{` {`
@keyframes spin { @keyframes spin {
@@ -800,10 +799,6 @@ const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
padding: 0 4px; padding: 0 4px;
border-radius: 3px; border-radius: 3px;
font-weight: 600; font-weight: 600;
transition: outline 0.3s ease;
}
mark[data-match-index] {
cursor: pointer;
} }
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: 8px;
+688 -45
View File
@@ -1,4 +1,7 @@
import React, { useState } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { FaArrowLeft, FaSpinner, FaEye, FaPrint, FaTimes, FaExpand, FaCompress, FaChevronUp, FaChevronDown, FaCheckCircle, FaExclamationCircle } from 'react-icons/fa';
import config from '../config.json';
interface Employee { interface Employee {
id: string; id: string;
@@ -31,7 +34,22 @@ const EmployeeInfo: React.FC<EmployeeInfoProps> = ({
return `${month}${year}`; 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 generatePeriods = () => {
const periods = []; const periods = [];
const now = new Date(); const now = new Date();
@@ -50,52 +68,677 @@ const EmployeeInfo: React.FC<EmployeeInfoProps> = ({
const monthNames = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', const monthNames = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь']; 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'];
return ( const getFullUrl = (url: string) => {
<div className="employee-info"> return url.startsWith('http') ? url : `http://localhost:3001${url}`;
<div className="top-bar"> };
<button className="back-button" onClick={onBack}>
← Назад const checkPayslip = async () => {
</button> if (!employee?.tnom) return;
</div>
try {
setCheckingPayslip(true);
const backendUrl = config.backendUrl || 'http://localhost:3001';
const response = await fetch(`${backendUrl}/api/payslip/url/${employee.tnom}?period=${selectedPeriod}`);
if (response.ok) {
const data = await response.json();
setHasPayslip(data.exists);
if (data.exists && data.url) {
setPayslipUrl(data.url);
}
} else {
setHasPayslip(false);
}
} catch (error) {
console.error('Ошибка проверки расчетного листка:', error);
setHasPayslip(false);
} finally {
setCheckingPayslip(false);
}
};
useEffect(() => {
checkPayslip();
}, [selectedPeriod, employee]);
const handlePreviewPdf = async () => {
if (!employee?.tnom) return;
setShowPdfPreview(true);
setPdfLoading(true);
setPdfError(null);
setIsFullscreen(false);
try {
const backendUrl = config.backendUrl || 'http://localhost:3001';
const response = await fetch(`${backendUrl}/api/payslip/url/${employee.tnom}?period=${selectedPeriod}`);
<div className="employee-header"> if (response.ok) {
<div className="employee-photo"> const data = await response.json();
<div className="photo-placeholder">Фото</div> if (data.exists && data.url) {
</div> const fullUrl = data.url.startsWith('http') ? data.url : `${backendUrl}${data.url}`;
<div className="employee-details"> setPayslipUrl(fullUrl);
<h2>{`${employee.famaly} ${employee.ima} ${employee.otch}`}</h2> setPdfLoading(false);
<p>Табельный номер: {employee.tnom}</p> } else {
{employee.category?.trim() && <p>Категория: {employee.category}</p>} setPdfError('Расчетный листок не найден');
{employee.departmentName && ( setPdfLoading(false);
<p>Подразделение: {employee.departmentName}</p> }
)} } else {
<p>Организация: {employee.organization}</p> setPdfError('Ошибка при проверке расчетного листка');
{employee.profession && <p>Профессия: {employee.profession}</p>} setPdfLoading(false);
</div> }
</div> } catch (error) {
console.error('Ошибка загрузки PDF:', error);
setPdfError('Не удалось загрузить расчетный листок');
setPdfLoading(false);
}
};
const handleClosePreview = () => {
setShowPdfPreview(false);
setPdfLoading(false);
setPdfError(null);
setIsFullscreen(false);
};
const handlePrint = async () => {
try {
setIsPrinting(true);
setPrintStatus('sending');
setPrintMessage('Отправка документа на печать...');
<div className="payslip-section"> const urlToPrint = payslipUrl ; // payslipUrl для Employee/Finance, fileUrl для Viewer
<h3>Расчётный лист</h3> if (!urlToPrint) throw new Error('URL документа отсутствует');
<div className="period-selector">
<label htmlFor="period-select">Выберите период:</label> const fullUrl = urlToPrint.startsWith('http') ? urlToPrint : `http://localhost:3001${urlToPrint}`;
<select // 🚀 ВАРИАНТ 1: Мы в десктопе (Electron)
id="period-select" if ((window as any).electronAPI && (window as any).electronAPI.printPdf) {
value={selectedPeriod} console.log('🖨️ [PRINT] Печатаем тихо через Electron API');
onChange={(e) => setSelectedPeriod(e.target.value)} await (window as any).electronAPI.printPdf({
> pdfUrl: fullUrl,
{periods.map(period => ( deviceName: '' // <-- Оставляем пустым! Electron сам плюнет на дефолтный принтер
<option key={period} value={period}> });
{monthNames[parseInt(period.substring(0, 2)) - 1]} {period.substring(2)}
</option>
))}
</select>
</div>
<button onClick={() => onViewPayslip(selectedPeriod)}> setPrintStatus('success');
Посмотреть расчётный лист setPrintMessage('✅ Отправлено на принтер');
</button> }
</div> // 🌐 ВАРИАНТ 2: Мы в браузере (Chrome/Edge)
</div> else {
console.log('🖨️ [PRINT] Electron API нет. Обходим CORS и печатаем через браузер');
// Скачиваем PDF как Blob, чтобы сделать его "своим" для браузера
const response = await fetch(fullUrl);
if (!response.ok) throw new Error('Не удалось скачать PDF с сервера');
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
// Создаем невидимый iframe
const printIframe = document.createElement('iframe');
printIframe.style.display = 'none';
printIframe.src = blobUrl;
document.body.appendChild(printIframe);
printIframe.onload = () => {
try {
printIframe.contentWindow?.focus();
printIframe.contentWindow?.print();
setPrintStatus('success');
setPrintMessage('✅ Документ отправлен на печать');
} catch (e) {
console.error('Ошибка iframe печати:', e);
throw new Error('Браузер заблокировал печать');
} finally {
// Чистим за собой мусор через 5 секунд
setTimeout(() => {
document.body.removeChild(printIframe);
URL.revokeObjectURL(blobUrl);
}, 5000);
}
};
}
} catch (error) {
console.error('❌ [PRINT] Ошибка печати:', error);
setPrintStatus('error');
setPrintMessage('❌ Ошибка: ' + (error instanceof Error ? error.message : 'Неизвестная ошибка'));
} finally {
setIsPrinting(false);
setTimeout(() => {
setPrintStatus('idle');
setPrintMessage('');
}, 3000);
}
};
const toggleFullscreen = () => {
setIsFullscreen(!isFullscreen);
};
return (
<>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
style={{
padding: '20px',
maxWidth: '1200px',
margin: '0 auto',
paddingBottom: '80px',
}}
>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '25px',
}}>
<button
onClick={onBack}
style={{
padding: '10px 20px',
background: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '14px',
transition: 'all 0.3s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#5a6268';
e.currentTarget.style.transform = 'translateX(-2px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#6c757d';
e.currentTarget.style.transform = 'translateX(0)';
}}
>
← Назад
</button>
<h3 style={{
color: 'black',
fontSize: '25px',
fontWeight: 600,
}}>
📋 Расчетный лист
</h3>
<div style={{ width: '100px' }} />
</div>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.1 }}
style={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
borderRadius: '16px',
padding: '25px 30px',
color: 'white',
marginBottom: '30px',
boxShadow: '0 10px 30px rgba(102, 126, 234, 0.3)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '25px', flexWrap: 'wrap' }}>
<div>
<div style={{ fontSize: '22px', fontWeight: 500 }}>
{`${employee.famaly} ${employee.ima} ${employee.otch}`}
</div>
<div style={{ fontSize: '14px', opacity: 0.9, marginTop: '5px' }}>
Табельный номер: {employee.tnom}
</div>
</div>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
style={{
background: 'white',
borderRadius: '16px',
padding: '25px',
boxShadow: '0 4px 20px rgba(0,0,0,0.06)',
}}
>
<h3 style={{ margin: '0 0 20px 0', color: '#2d3436', fontSize: '18px', fontWeight: 500 }}>
📄 Расчетный листок
</h3>
<div style={{
display: 'flex',
gap: '15px',
flexWrap: 'wrap',
alignItems: 'center',
marginBottom: '20px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<label style={{ fontSize: '14px', color: '#636e72' }}>
Выберите период:
</label>
<select
value={selectedPeriod}
onChange={(e) => setSelectedPeriod(e.target.value)}
style={{
padding: '8px 14px',
borderRadius: '8px',
border: '1px solid #dfe6e9',
fontSize: '14px',
background: 'white',
cursor: 'pointer',
}}
>
{periods.map(period => {
const month = parseInt(period.substring(0, 2)) - 1;
const year = period.substring(2);
return (
<option key={period} value={period}>
{monthNames[month]} {year}
</option>
);
})}
</select>
</div>
</div>
{checkingPayslip ? (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '12px 16px',
background: '#f8f9fa',
borderRadius: '10px',
marginBottom: '16px',
}}>
<FaSpinner style={{ animation: 'spin 1s linear infinite', fontSize: '18px', color: '#636e72' }} />
<span style={{ color: '#636e72', fontSize: '14px' }}>Проверка наличия расчетного листка...</span>
</div>
) : hasPayslip ? (
<div style={{
padding: '12px 16px',
background: '#f0fdf4',
borderRadius: '10px',
border: '1px solid #bbf7d0',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '10px',
}}>
<FaCheckCircle style={{ color: '#22c55e', fontSize: '18px' }} />
<div>
<div style={{ fontSize: '14px', color: '#166534', fontWeight: 500 }}>
✅ Расчетный листок доступен
</div>
<div style={{ fontSize: '13px', color: '#14532d' }}>
Табельный номер: <strong>{employee?.tnom}</strong>
</div>
</div>
</div>
) : hasPayslip === false ? (
<div style={{
padding: '12px 16px',
background: '#fef2f2',
borderRadius: '10px',
border: '1px solid #fecaca',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '10px',
}}>
<FaExclamationCircle style={{ color: '#ef4444', fontSize: '18px' }} />
<div>
<div style={{ fontSize: '14px', color: '#991b1b', fontWeight: 500 }}>
❌ Расчетный листок не найден
</div>
<div style={{ fontSize: '13px', color: '#991b1b', opacity: 0.8 }}>
Для табельного номера <strong>{employee?.tnom}</strong> файл отсутствует
</div>
</div>
</div>
) : null}
<button
onClick={handlePreviewPdf}
disabled={!hasPayslip}
style={{
padding: '10px 25px',
background: hasPayslip ? 'linear-gradient(135deg, #00b894, #00cec9)' : '#dfe6e9',
color: hasPayslip ? 'white' : '#b2bec3',
border: 'none',
borderRadius: '10px',
fontSize: '14px',
fontWeight: 500,
cursor: hasPayslip ? 'pointer' : 'not-allowed',
transition: 'all 0.3s ease',
boxShadow: hasPayslip ? '0 4px 15px rgba(0, 206, 201, 0.3)' : 'none',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
onMouseEnter={(e) => {
if (hasPayslip) {
e.currentTarget.style.transform = 'translateY(-2px)';
e.currentTarget.style.boxShadow = '0 6px 20px rgba(0, 206, 201, 0.4)';
}
}}
onMouseLeave={(e) => {
if (hasPayslip) {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 15px rgba(0, 206, 201, 0.3)';
}
}}
>
<FaEye />
Предпросмотр
</button>
</motion.div>
<style>{`
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
</motion.div>
<AnimatePresence>
{printStatus !== 'idle' && (
<motion.div
initial={{ opacity: 0, y: -50, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -50, scale: 0.9 }}
style={{
position: 'fixed',
top: '20px',
left: '50%',
transform: 'translateX(-50%)',
zIndex: 99999,
padding: '16px 32px',
borderRadius: '12px',
background: printStatus === 'success' ? '#10b981' :
printStatus === 'error' ? '#ef4444' :
'#f59e0b',
color: 'white',
boxShadow: '0 10px 40px rgba(0,0,0,0.2)',
display: 'flex',
alignItems: 'center',
gap: '12px',
fontSize: '16px',
fontWeight: 500,
minWidth: '300px',
justifyContent: 'center',
}}
>
{printStatus === 'sending' && <FaSpinner style={{ animation: 'spin 1s linear infinite' }} />}
{printStatus === 'success' && <FaCheckCircle />}
{printStatus === 'error' && <FaExclamationCircle />}
{printMessage}
</motion.div>
)}
</AnimatePresence>
{showPdfPreview && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.85)',
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
zIndex: 9999,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px',
}}
onClick={handleClosePreview}
>
<motion.div
initial={{ scale: 0.9, y: 30, opacity: 0 }}
animate={{ scale: 1, y: 0, opacity: 1 }}
exit={{ scale: 0.9, y: 30, opacity: 0 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
style={{
background: '#1a1a2e',
borderRadius: '24px',
width: '100%',
maxWidth: isFullscreen ? '100%' : '900px',
height: isFullscreen ? '100%' : '90vh',
maxHeight: isFullscreen ? '100%' : '90vh',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
boxShadow: '0 50px 100px rgba(0,0,0,0.5)',
position: 'relative',
}}
onClick={(e) => e.stopPropagation()}
>
<div style={{
padding: '16px 24px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexShrink: 0,
minHeight: '60px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ fontSize: '20px' }}>📄</span>
<span style={{ color: 'white', fontSize: '16px', fontWeight: 500 }}>
Расчетный листок
</span>
<span style={{
color: 'rgba(255,255,255,0.5)',
fontSize: '12px',
background: 'rgba(255,255,255,0.15)',
padding: '2px 10px',
borderRadius: '12px',
}}>
{employee?.tnom}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<button
onClick={handlePrint}
disabled={isPrinting}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '8px 16px',
background: isPrinting ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.2)',
color: 'white',
border: '1px solid rgba(255,255,255,0.3)',
borderRadius: '10px',
cursor: isPrinting ? 'not-allowed' : 'pointer',
fontSize: '14px',
fontWeight: 500,
transition: 'all 0.3s ease',
backdropFilter: 'blur(4px)',
opacity: isPrinting ? 0.5 : 1,
}}
onMouseEnter={(e) => {
if (!isPrinting) {
e.currentTarget.style.background = 'rgba(255,255,255,0.3)';
}
}}
onMouseLeave={(e) => {
if (!isPrinting) {
e.currentTarget.style.background = 'rgba(255,255,255,0.2)';
}
}}
>
{isPrinting ? <FaSpinner style={{ animation: 'spin 1s linear infinite' }} /> : <FaPrint size={16} />}
{isPrinting ? 'Отправка...' : 'Печать'}
</button>
<button
onClick={toggleFullscreen}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '36px',
height: '36px',
background: 'rgba(255,255,255,0.2)',
color: 'white',
border: '1px solid rgba(255,255,255,0.3)',
borderRadius: '10px',
cursor: 'pointer',
transition: 'all 0.3s ease',
}}
>
{isFullscreen ? <FaCompress size={16} /> : <FaExpand size={16} />}
</button>
<button
onClick={handleClosePreview}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '36px',
height: '36px',
background: 'rgba(255,255,255,0.2)',
color: 'white',
border: '1px solid rgba(255,255,255,0.3)',
borderRadius: '10px',
cursor: 'pointer',
transition: 'all 0.3s ease',
}}
>
<FaTimes size={18} />
</button>
</div>
</div>
<div style={{
flex: 1,
padding: '20px',
overflow: 'auto',
background: '#0a0a0a',
position: 'relative',
WebkitOverflowScrolling: 'touch',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
{pdfLoading && (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '20px',
}}>
<div style={{
width: '60px',
height: '60px',
border: '4px solid #2d2d2d',
borderTop: '4px solid #667eea',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
}} />
<span style={{ color: '#888', fontSize: '16px' }}>
Загрузка документа...
</span>
</div>
)}
{!pdfLoading && payslipUrl && !pdfError && (
<iframe
ref={iframeRef}
src={`${payslipUrl}#toolbar=0&navpanes=0&scrollbar=1&view=FitH`}
style={{
width: '100%',
height: '100%',
minHeight: '600px',
border: 'none',
borderRadius: '12px',
background: '#0a0a0a',
boxShadow: '0 2px 12px rgba(0,0,0,0.3)',
}}
title="PDF Preview"
onError={() => {
setPdfLoading(false);
setPdfError('Не удалось загрузить PDF');
}}
/>
)}
{pdfError && (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '15px',
padding: '40px',
textAlign: 'center',
}}>
<div style={{ fontSize: '64px' }}>📄</div>
<div style={{ fontSize: '18px', color: '#e74c3c', fontWeight: 500 }}>
{pdfError}
</div>
<button
onClick={() => {
setPdfError(null);
setPdfLoading(true);
handlePreviewPdf();
}}
style={{
padding: '10px 30px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
border: 'none',
borderRadius: '10px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 500,
transition: 'all 0.3s ease',
}}
>
🔄 Попробовать снова
</button>
</div>
)}
</div>
<div style={{
padding: '12px 24px',
background: 'rgba(255,255,255,0.05)',
borderTop: '1px solid rgba(255,255,255,0.05)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexShrink: 0,
fontSize: '13px',
color: 'rgba(255,255,255,0.5)',
}}>
<div>
👤 {employee?.famaly} {employee?.ima}
</div>
<div>
📋 Табельный номер: <strong style={{ color: 'rgba(255,255,255,0.7)' }}>{employee?.tnom}</strong>
</div>
<div>
🏢 {employee?.organization}
</div>
</div>
</motion.div>
</motion.div>
)}
</>
); );
}; };
+667 -119
View File
@@ -1,20 +1,30 @@
import React, { useState } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { motion } from 'framer-motion'; 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 { interface FinanceProps {
onBack: () => void; onBack: () => void;
onViewPayslip: (period: string) => void; onViewPayslip: (fileUrl: string) => void;
employee: any; employee: any;
} }
interface FinancialIndicator {
title: string;
value: string;
change?: number;
unit?: string;
}
const Finance: React.FC<FinanceProps> = ({ onBack, onViewPayslip, employee }) => { 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 [selectedPeriod, setSelectedPeriod] = useState(() => {
const now = new Date(); const now = new Date();
const year = now.getFullYear(); const year = now.getFullYear();
@@ -22,13 +32,6 @@ const Finance: React.FC<FinanceProps> = ({ onBack, onViewPayslip, employee }) =>
return `${month}${year}`; 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 generatePeriods = () => {
const periods = []; const periods = [];
const now = new Date(); const now = new Date();
@@ -40,122 +43,667 @@ const Finance: React.FC<FinanceProps> = ({ onBack, onViewPayslip, employee }) =>
} }
return periods; return periods;
}; };
const periods = generatePeriods(); const periods = generatePeriods();
const monthNames = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', const monthNames = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь']; 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'];
const formatPeriod = (period: string) => { useEffect(() => {
const month = parseInt(period.substring(0, 2)) - 1; const checkPayslip = async () => {
const year = period.substring(2); if (!employee?.tnom) {
return `${monthNames[month]} ${year}`; 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 ( const handleClosePreview = () => {
<motion.div setShowPdfPreview(false);
initial={{ opacity: 0, y: 20 }} setPdfLoading(false);
animate={{ opacity: 1, y: 0 }} setPdfError(null);
exit={{ opacity: 0, y: -20 }} setIsFullscreen(false);
transition={{ duration: 0.3 }} };
className="finance-section"
style={{ padding: '20px', maxWidth: '1200px', margin: '0 auto', paddingBottom: '80px' }}
>
<div className="top-bar" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '25px', flexWrap: 'wrap', gap: '10px' }}>
<button
className="back-button"
onClick={onBack}
style={{ padding: '10px 20px', background: '#6c757d', color: 'white', border: 'none', borderRadius: '8px', cursor: 'pointer', fontSize: '14px', transition: 'all 0.3s ease' }}
onMouseEnter={(e) => { e.currentTarget.style.background = '#5a6268'; e.currentTarget.style.transform = 'translateX(-2px)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = '#6c757d'; e.currentTarget.style.transform = 'translateX(0)'; }}
>
← Назад
</button>
<h2 style={{ margin: 0, color: '#2d3436', fontSize: '24px', fontWeight: 300, display: 'flex', alignItems: 'center', gap: '12px' }}>
💰 Финансы
</h2>
<div style={{ width: '100px' }} />
</div>
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} transition={{ delay: 0.1 }} const handlePrint = async () => {
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)' }}> try {
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap' }}> setIsPrinting(true);
<div> setPrintStatus('sending');
<div style={{ fontSize: '14px', opacity: 0.8, marginBottom: '5px' }}>{employee.organization}</div> setPrintMessage('Отправка документа на печать...');
<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> const urlToPrint = payslipUrl; // payslipUrl для Employee/Finance, fileUrl для Viewer
</div> if (!urlToPrint) throw new Error('URL документа отсутствует');
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '12px', opacity: 0.7 }}>Доступно</div> const fullUrl = urlToPrint.startsWith('http') ? urlToPrint : `http://localhost:3001${urlToPrint}`;
<div style={{ fontSize: '28px', fontWeight: 600 }}>0.00</div>
<div style={{ fontSize: '12px', opacity: 0.7 }}>руб.</div> if ((window as any).electronAPI && (window as any).electronAPI.printPdf) {
</div> 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> </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>
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }} <AnimatePresence>
style={{ background: 'white', borderRadius: '16px', padding: '25px', boxShadow: '0 4px 20px rgba(0,0,0,0.06)', marginBottom: '25px' }}> {printStatus !== 'idle' && (
<h3 style={{ margin: '0 0 20px 0', color: '#2d3436', fontSize: '18px', fontWeight: 500, display: 'flex', alignItems: 'center', gap: '10px' }}> <motion.div
📊 Финансово-экономические показатели initial={{ opacity: 0, y: -50, scale: 0.9 }}
</h3> animate={{ opacity: 1, y: 0, scale: 1 }}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '15px' }}> exit={{ opacity: 0, y: -50, scale: 0.9 }}
{indicators.map((item, index) => ( style={{
<motion.div key={index} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 * index }} position: 'fixed',
style={{ background: '#f8f9fa', borderRadius: '12px', padding: '16px 20px', border: '1px solid #f1f2f6' }}> top: '20px',
<div style={{ fontSize: '13px', color: '#636e72', marginBottom: '8px' }}>{item.title}</div> left: '50%',
<div style={{ fontSize: '24px', fontWeight: 600, color: '#2d3436' }}> transform: 'translateX(-50%)',
{item.value} zIndex: 99999,
{item.unit && <span style={{ fontSize: '14px', fontWeight: 400, color: '#636e72', marginLeft: '4px' }}>{item.unit}</span>} 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> </div>
{item.change !== undefined && (
<div style={{ fontSize: '12px', color: item.change >= 0 ? '#2ecc71' : '#e74c3c', marginTop: '4px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{item.change >= 0 ? '↑' : '↓'} {Math.abs(item.change)}% <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> </div>
)} )}
</motion.div>
))}
</div>
</motion.div>
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.3 }} <iframe
style={{ background: 'white', borderRadius: '16px', padding: '25px', boxShadow: '0 4px 20px rgba(0,0,0,0.06)' }}> ref={iframeRef}
<h3 style={{ margin: '0 0 20px 0', color: '#2d3436', fontSize: '18px', fontWeight: 500, display: 'flex', alignItems: 'center', gap: '10px' }}> src={`${getFullUrl(payslipUrl)}#toolbar=0&navpanes=0&scrollbar=1&view=FitH`}
📄 Расчётный лист style={{
</h3> width: '100%',
<div style={{ display: 'flex', gap: '15px', flexWrap: 'wrap', alignItems: 'center' }}> height: '100%',
<div style={{ flex: 1, minWidth: '200px' }}> minHeight: '600px',
<label htmlFor="period-select" style={{ display: 'block', fontSize: '13px', color: '#636e72', marginBottom: '6px' }}> border: 'none',
📅 Выберите период: borderRadius: '12px',
</label> background: '#0a0a0a',
<select display: pdfLoading ? 'none' : 'block',
id="period-select" boxShadow: '0 2px 12px rgba(0,0,0,0.3)',
value={selectedPeriod} }}
onChange={(e) => setSelectedPeriod(e.target.value)} title="PDF Preview"
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' }} onLoad={() => setPdfLoading(false)}
onFocus={(e) => e.target.style.borderColor = '#6c5ce7'} onError={() => {
onBlur={(e) => e.target.style.borderColor = '#dfe6e9'} setPdfLoading(false);
> setPdfError('Не удалось загрузить PDF');
{periods.map(period => ( }}
<option key={period} value={period}>{formatPeriod(period)}</option> />
))}
</select> {pdfError && (
</div> <div style={{
<button display: 'flex',
onClick={() => onViewPayslip(selectedPeriod)} flexDirection: 'column',
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' }} alignItems: 'center',
onMouseEnter={(e) => { e.currentTarget.style.transform = 'translateY(-2px)'; e.currentTarget.style.boxShadow = '0 6px 20px rgba(108, 92, 231, 0.4)'; }} justifyContent: 'center',
onMouseLeave={(e) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 4px 15px rgba(108, 92, 231, 0.3)'; }} gap: '15px',
> padding: '40px',
📄 Просмотреть textAlign: 'center',
</button> }}>
</div> <div style={{ fontSize: '64px' }}>📄</div>
<div style={{ marginTop: '20px', padding: '16px 20px', background: '#fff5f5', borderRadius: '10px', border: '1px solid #fde8e8' }}> <div style={{ fontSize: '18px', color: '#e74c3c', fontWeight: 500 }}>
<div style={{ fontSize: '13px', color: '#636e72', marginBottom: '5px' }}>💡 Информация о премии</div> {pdfError}
<div style={{ fontSize: '14px', color: '#2d3436' }}> </div>
Премия по итогам работы за {formatPeriod(selectedPeriod)} составляет 15% от оклада. <button
</div> onClick={() => {
</div> setPdfError(null);
</motion.div> setPdfLoading(true);
</motion.div> 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 компонент рендерится!'); console.log('🔵 MainMenu компонент рендерится!');
const menuItems = [ const menuItems = [
{ id: 'employee-info', emoji: '📋' } { id: 'employee-info', emoji: '📋', description: 'Просмотр и печать' }
]; ];
const menuItems_2 = [ const menuItems_2 = [
@@ -29,97 +29,173 @@ const MainMenu: React.FC<MainMenuProps> = ({ onSectionSelect, employee }) => {
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
padding: '10px', padding: '20px 20px',
marginTop: '-5vh', marginTop: '-20px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', position: 'relative',
overflow: 'hidden',
backgroundImage: 'url(/assets/images/background.jpg)',
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}}> }}>
{employee && ( {/* ✅ Полупрозрачный оверлей для читаемости текста
<div style={{ <div style={{
color: '#fff', position: 'absolute',
fontSize: '1.5rem', top: 0,
marginBottom: '10px', left: 0,
}}> right: 0,
Приветствуем Вас, {employee.ima} {employee.famaly} bottom: 0,
</div> background: 'rgba(232, 240, 254, 0.2)',
)} backdropFilter: 'blur(4px)',
<h1 style={{ WebkitBackdropFilter: 'blur(4px)',
color: '#fff', zIndex: 0,
fontSize: '2.5rem', }} /> */}
marginBottom: '40px',
fontWeight: 300,
}}>
Выберите раздел
</h1>
{/* ✅ Декоративные голубые размытые круги (поверх оверлея) */}
<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={{ <div style={{
display: 'grid', display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 870px))', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 900px))',
gap: '25px', gap: '30px',
maxWidth: '900px', maxWidth: '960px',
width: '100%', width: '100%',
//height: '100%',
justifyContent: 'center', justifyContent: 'center',
position: 'relative',
zIndex: 1,
marginBottom: '20px',
}}> }}>
{menuItems.map((item) => ( {menuItems.map((item) => (
<button <button
key={item.id} key={item.id}
onClick={() => onSectionSelect(item.id)} onClick={() => onSectionSelect(item.id)}
style={{ style={{
background: 'linear-gradient(135deg, #1b718b 0%, #7ec87b 100%)', background: 'linear-gradient(145deg, rgba(255,255,255,0.92) 0%, rgba(235, 245, 255, 0.92) 100%)',
borderRadius: '16px', backdropFilter: 'blur(8px)',
padding: '25px 30px', WebkitBackdropFilter: 'blur(8px)',
marginBottom: '25px', borderRadius: '28px',
padding: '58px 36px',
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.3s ease', transition: 'all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)',
boxShadow: '0 4px 20px rgba(198, 231, 212, 0.25)', 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: 'inline-flex', display: 'flex',
justifyContent: 'flex-start', justifyContent: 'flex-start',
alignItems: 'center', alignItems: 'center',
minHeight: '80px', minHeight: '110px',
fontSize: '6.0rem', gap: '20px',
border: '1px solid rgba(255,255,255,0.5)',
width: '100%',
position: 'relative',
overflow: 'hidden',
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.05)'; e.currentTarget.style.transform = 'translateY(-4px) scale(1.01)';
e.currentTarget.style.boxShadow = '0 12px 40px rgba(0,0,0,0.2)'; 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) => { onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.transform = 'translateY(0) scale(1)';
e.currentTarget.style.boxShadow = '0 8px 30px rgba(0,0,0,0.12)'; 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> <div style={{
<span style={{ position: 'absolute',
fontSize: '1rem', left: 0,
fontWeight: 500, top: 0,
color: '#2d3436', bottom: 0,
textAlign: 'center', width: '6px',
}}> background: 'linear-gradient(180deg, #4285f4 0%, #5b9bd5 50%, #7cb9e8 100%)',
<div> 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={{ <h3 style={{
color: 'white', color: '#1a3a5c',
fontSize: '25px', fontSize: '32px',
fontWeight: 600, fontWeight: 600,
margin: 0,
letterSpacing: '-0.3px',
}}> }}>
Расчетный лист Расчетный лист
</h3> </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> </div>
</span>
</button> </button>
))} ))}
</div> </div>
{/* ✅ СЕТКА КНОПОК */}
<div style={{ <div style={{
display: 'grid', display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 200px))', gridTemplateColumns: 'repeat(auto-fit, minmax(170px, 210px))',
gap: '15px', gap: '22px',
maxWidth: '900px', maxWidth: '960px',
width: '100%', width: '100%',
//height: '100%',
justifyContent: 'center', justifyContent: 'center',
marginTop: '15px' position: 'relative',
zIndex: 1,
}}> }}>
{menuItems_2.map((item) => ( {menuItems_2.map((item) => (
<button <button
key={item.id} key={item.id}
@@ -129,38 +205,108 @@ const MainMenu: React.FC<MainMenuProps> = ({ onSectionSelect, employee }) => {
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: '12px', gap: '14px',
padding: '5px 5px', padding: '30px 16px 26px',
background: 'rgba(255,255,255,0.95)', background: 'rgba(255,255,255,0.85)',
border: 'none', backdropFilter: 'blur(8px)',
borderRadius: '16px', WebkitBackdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.5)',
borderRadius: '24px',
cursor: 'pointer', cursor: 'pointer',
boxShadow: '0 8px 30px rgba(0,0,0,0.12)', 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: '140px', minHeight: '190px',
fontSize: '3.5rem', fontSize: '4rem',
transition: 'all 0.3s ease', transition: 'all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)',
position: 'relative',
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.05)'; e.currentTarget.style.transform = 'translateY(-6px) scale(1.02)';
e.currentTarget.style.boxShadow = '0 12px 40px rgba(0,0,0,0.2)'; 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) => { onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.transform = 'translateY(0) scale(1)';
e.currentTarget.style.boxShadow = '0 8px 30px rgba(0,0,0,0.12)'; 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={{ <span style={{
fontSize: '1rem', display: 'block',
whiteSpace: 'normal',
wordWrap: 'break-word',
fontSize: '18px',
fontWeight: 500, fontWeight: 500,
color: '#2d3436', color: '#2a4a6a',
textAlign: 'center', textAlign: 'center',
letterSpacing: '0.2px',
lineHeight: 1.3,
maxWidth: '100%',
}}> }}>
{item.title} {item.title}
</span> </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> </button>
))} ))}
</div> </div>
<div style={{
marginTop: '30px',
fontSize: '13px',
color: 'rgba(66, 133, 244, 0.08)',
letterSpacing: '2px',
fontWeight: 300,
position: 'relative',
zIndex: 1,
}}>
⋆ ﹒ ˚ ﹒ ⋆ ﹒ ˚ ﹒ ⋆
</div>
</div> </div>
); );
}; };
+219 -30
View File
@@ -1,9 +1,6 @@
import React from 'react'; import React, { useRef, useState } from 'react';
import { Document, Page, pdfjs } from 'react-pdf'; import { motion, AnimatePresence } from 'framer-motion';
import { FaArrowLeft, FaPrint, FaSpinner, FaCheckCircle, FaExclamationCircle } from 'react-icons/fa';
// ИСПОЛЬЗОВАТЬ CDN - ЭТО РАБОТАЕТ
pdfjs.GlobalWorkerOptions.workerSrc =
'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
interface PayslipViewerProps { interface PayslipViewerProps {
fileUrl: string; fileUrl: string;
@@ -11,36 +8,228 @@ interface PayslipViewerProps {
} }
const PayslipViewer: React.FC<PayslipViewerProps> = ({ fileUrl, onBack }) => { const PayslipViewer: React.FC<PayslipViewerProps> = ({ fileUrl, onBack }) => {
const handlePrint = () => { const iframeRef = useRef<HTMLIFrameElement>(null);
const win = window.open(fileUrl, '_blank'); const fullUrl = fileUrl.startsWith('http') ? fileUrl : `http://localhost:3001${fileUrl}`;
if (win) {
win.focus(); const [isPrinting, setIsPrinting] = useState(false);
win.print(); 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 ( return (
<div className="payslip-viewer"> <motion.div
<div className="top-bar"> initial={{ opacity: 0, y: 20 }}
<button className="back-button" onClick={onBack}> animate={{ opacity: 1, y: 0 }}
← Назад exit={{ opacity: 0, y: -20 }}
</button> transition={{ duration: 0.3 }}
<button className="print-button" onClick={handlePrint}> style={{
🖨️ Печать padding: '20px',
</button> maxWidth: '1200px',
</div> margin: '0 auto',
<div className="pdf-container" style={{ height: 'calc(100vh - 80px)',
display: 'flex', display: 'flex',
justifyContent: 'center', flexDirection: 'column',
width: '100%' 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..."> <button
<div style={{ margin: '0 auto' }}> onClick={onBack}
<Page pageNumber={1} /> style={{
</div> display: 'flex',
</Document> 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>
<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 React, { useEffect, useRef, useState } from 'react';
import { motion } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
interface VideoBackgroundProps { interface VideoBackgroundProps {
onComplete: () => void; onComplete: () => void;
videoSrc?: string; 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> = ({ const VideoBackground: React.FC<VideoBackgroundProps> = ({
onComplete, onComplete,
videoSrc = '/assets/videos/flag.mp4' videoSrc = '/assets/videos/flag.mp4'
}) => { }) => {
const videoRef = useRef<HTMLVideoElement>(null); 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(() => { useEffect(() => {
if (videoRef.current) { if (videoRef.current) {
videoRef.current.play().catch(err => { videoRef.current.play().catch(err => {
console.warn('Видео не воспроизвелось автоматически:', err); console.warn('Видео не воспроизвелось автоматически:', err);
// Если видео не запустилось — показываем экран логина через 5 секунд
setTimeout(onComplete, 5000); setTimeout(onComplete, 5000);
}); });
} }
// ❌ Убираем таймер, который закрывал видео через 30 секунд
// Теперь видео будет крутиться бесконечно (loop=true)
// Только клик или касание пользователя завершают видео
return () => {
// Очистка не нужна, так как таймера больше нет
};
}, [onComplete]); }, [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 = () => { 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 ( return (
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.5 }} transition={{ duration: 0.5 }}
className="video-splash"
onClick={handleClick}
style={{ style={{
position: 'fixed', position: 'fixed',
top: 0, top: 0,
@@ -56,34 +204,264 @@ const VideoBackground: React.FC<VideoBackgroundProps> = ({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
overflow: 'hidden',
}} }}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
> >
{/* ✅ ВИДЕО (фон) */}
<video <video
ref={videoRef} ref={videoRef}
src={videoSrc} src={videoSrc}
autoPlay autoPlay
muted muted
playsInline playsInline
loop // ✅ Бесконечное воспроизведение loop
style={{ style={{
position: 'absolute',
width: '100%', width: '100%',
height: '100%', height: '100%',
objectFit: 'cover', objectFit: 'cover',
zIndex: 0,
}} }}
/> />
{/* ✅ ТЕМНЫЙ ОВЕРЛЕЙ */}
<div style={{ <div style={{
position: 'absolute', 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, left: 0,
right: 0, right: 0,
textAlign: 'center', textAlign: 'center',
color: '#fff', color: 'rgba(255,255,255,0.3)',
fontSize: '14px', fontSize: '13px',
opacity: 0.6, zIndex: 3,
textShadow: '0 2px 4px rgba(0,0,0,0.5)', textShadow: '0 2px 4px rgba(0,0,0,0.5)',
letterSpacing: '1px',
pointerEvents: 'none',
}}> }}>
Нажмите для продолжения {hasSlides ? 'Нажмите для входа · Для просмотра используйте стрелки' : 'Нажмите для продолжения'}
</div> </div>
<style>{`
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
</motion.div> </motion.div>
); );
}; };
+213 -142
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion } from 'framer-motion';
interface WeatherDay { interface WeatherDay {
date: string; date: string;
@@ -12,9 +12,13 @@ interface WeatherDay {
interface WeatherWidgetProps { interface WeatherWidgetProps {
onLogout?: () => void; 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 [weatherData, setWeatherData] = useState<WeatherDay[]>([]);
const [currentWeather, setCurrentWeather] = useState<WeatherDay | null>(null); const [currentWeather, setCurrentWeather] = useState<WeatherDay | null>(null);
const [isOnline, setIsOnline] = useState(navigator.onLine); const [isOnline, setIsOnline] = useState(navigator.onLine);
@@ -57,7 +61,6 @@ const WeatherWidget: React.FC<WeatherWidgetProps> = ({ onLogout }) => {
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
// fallback
setCurrentWeather({ setCurrentWeather({
date: new Date().toISOString().split('T')[0], date: new Date().toISOString().split('T')[0],
dayOfWeek: 'Сегодня', 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 ( return (
<motion.div <motion.div
initial={{ y: -100, opacity: 0 }} initial={{ y: -100, opacity: 0 }}
@@ -171,37 +188,42 @@ const WeatherWidget: React.FC<WeatherWidgetProps> = ({ onLogout }) => {
left: 0, left: 0,
right: 0, right: 0,
zIndex: 9999, zIndex: 9999,
background: 'rgba(44, 62, 80, 0.75)', // ✅ полупрозрачный background: 'linear-gradient(135deg, #1b718b 65%, #473f72 100%)',
backdropFilter: 'blur(12px)', backdropFilter: 'blur(16px)',
padding: '5px 15px', WebkitBackdropFilter: 'blur(16px)',
borderBottom: '1px solid rgba(255,255,255,0.08)', padding: '6px 20px',
borderBottom: '1px solid rgba(255,255,255,0.06)',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
minHeight: '70px', minHeight: '75px',
boxShadow: '0 2px 20px rgba(0,0,0,0.15)', gap: '8px',
flexWrap: 'nowrap',
}} }}
> >
{/* Левая часть: Время */} {/* ===== ЛЕВАЯ ЧАСТЬ: ВРЕМЯ + ПОГОДА СЕГОДНЯ ===== */}
<div style={{ <div style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '12px', gap: '16px',
flexShrink: 0, flexShrink: 0,
minWidth: '140px', zIndex: 2,
}}> }}>
{/* Время */}
<div style={{ <div style={{
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
alignItems: 'flex-start', alignItems: 'flex-start',
minWidth: '90px',
}}> }}>
<span style={{ <span style={{
fontSize: '30px', fontSize: '38px',
fontWeight: 700, fontWeight: 700,
color: '#fff', color: '#fff',
letterSpacing: '0.5px', letterSpacing: '0.5px',
lineHeight: 1, lineHeight: 1.1,
fontVariantNumeric: 'tabular-nums', fontVariantNumeric: 'tabular-nums',
textShadow: '0 2px 8px rgba(0,0,0,0.15)',
}}> }}>
{currentTime.toLocaleString('ru-RU', { {currentTime.toLocaleString('ru-RU', {
timeZone: 'Europe/Minsk', timeZone: 'Europe/Minsk',
@@ -211,10 +233,11 @@ const WeatherWidget: React.FC<WeatherWidgetProps> = ({ onLogout }) => {
})} })}
</span> </span>
<span style={{ <span style={{
fontSize: '12px', fontSize: '11px',
color: 'rgba(255,255,255,0.5)', color: 'rgba(255,255,255,0.4)',
letterSpacing: '0.3px', letterSpacing: '0.3px',
fontWeight: 400, fontWeight: 400,
textTransform: 'uppercase',
}}> }}>
{currentTime.toLocaleString('ru-RU', { {currentTime.toLocaleString('ru-RU', {
timeZone: 'Europe/Minsk', timeZone: 'Europe/Minsk',
@@ -224,128 +247,180 @@ const WeatherWidget: React.FC<WeatherWidgetProps> = ({ onLogout }) => {
})} })}
</span> </span>
</div> </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>
{/* Центральная часть: Погода */} {/* ===== ЦЕНТРАЛЬНАЯ ЧАСТЬ: ПРИВЕТСТВИЕ ===== */}
<div style={{ <div style={{
flex: 1, flex: '1 1 0%',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
padding: '0 16px', height: '100%',
minHeight: '56px', minWidth: 0,
overflow: 'hidden',
position: 'relative',
}}> }}>
<AnimatePresence mode="wait"> {employee && (
{isOnline && !loading && currentWeather ? ( <motion.div
<motion.div initial={{ opacity: 0, y: -5 }}
key="weather" animate={{ opacity: 1, y: 0 }}
initial={{ opacity: 0, scale: 0.9 }} transition={{ duration: 0.6, delay: 0.2 }}
animate={{ opacity: 1, scale: 1 }} style={{
exit={{ opacity: 0, scale: 0.9 }} fontSize: 'clamp(18px, 1.6vw, 28px)',
style={{ fontWeight: 500,
display: 'flex', color: 'rgba(255,255,255,0.92)',
alignItems: 'center', textAlign: 'center',
gap: '16px', whiteSpace: 'nowrap',
}} letterSpacing: '0.8px',
> textShadow: '0 2px 12px rgba(0,0,0,0.15)',
<div style={{ background: 'rgba(255,255,255,0.04)',
display: 'flex', padding: '4px 24px',
alignItems: 'center', borderRadius: '30px',
gap: '10px', border: '1px solid rgba(255,255,255,0.04)',
background: 'rgba(255,255,255,0.08)', maxWidth: '100%',
padding: '4px 16px 4px 10px', overflow: 'hidden',
borderRadius: '30px', textOverflow: 'ellipsis',
border: '1px solid rgba(255,255,255,0.06)', }}
}}> >
<span style={{ fontSize: '32px', lineHeight: 1 }}> {getGreeting()}
{getWeatherIcon(currentWeather.icon)} </motion.div>
</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>
</div> </div>
{/* Правая часть: Кнопка выхода */} {/* ===== ПРАВАЯ ЧАСТЬ: ПРОГНОЗ С ПРОКРУТКОЙ ===== */}
<div style={{ <div style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '8px', gap: '12px',
flexShrink: 0, flexShrink: 0,
minWidth: '80px', maxWidth: '380px',
justifyContent: 'flex-end', 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 && ( {onLogout && (
<motion.button <motion.button
@@ -353,38 +428,34 @@ const WeatherWidget: React.FC<WeatherWidgetProps> = ({ onLogout }) => {
whileTap={{ scale: 0.95 }} whileTap={{ scale: 0.95 }}
onClick={handleLogout} onClick={handleLogout}
style={{ style={{
padding: '6px 16px', padding: '6px 18px',
background: 'rgba(231, 76, 60, 0.7)', background: 'rgba(231, 76, 60, 0.5)',
color: 'white', color: 'white',
border: 'none', border: '1px solid rgba(255,255,255,0.06)',
borderRadius: '8px', borderRadius: '30px',
cursor: 'pointer', cursor: 'pointer',
fontSize: '13px', fontSize: '16px',
fontWeight: 500, fontWeight: 500,
transition: 'all 0.2s', transition: 'all 0.25s ease',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
minHeight: '34px', minHeight: '32px',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: '6px', gap: '6px',
backdropFilter: 'blur(4px)', backdropFilter: 'blur(4px)',
borderWidth: '1px', boxShadow: '0 2px 12px rgba(231, 76, 60, 0.1)',
borderStyle: 'solid',
borderColor: 'rgba(255,255,255,0.1)',
boxShadow: '0 2px 10px rgba(231, 76, 60, 0.2)',
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(231, 76, 60, 1)'; e.currentTarget.style.background = 'rgba(231, 76, 60, 0.8)';
e.currentTarget.style.boxShadow = '0 4px 20px rgba(231, 76, 60, 0.4)'; e.currentTarget.style.boxShadow = '0 4px 20px rgba(231, 76, 60, 0.25)';
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(231, 76, 60, 0.7)'; e.currentTarget.style.background = 'rgba(231, 76, 60, 0.5)';
e.currentTarget.style.boxShadow = '0 2px 10px rgba(231, 76, 60, 0.2)'; 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> </motion.button>
)} )}
</div> </div>
+14
View File
@@ -1 +1,15 @@
/// <reference types="vite/client" /> /// <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"}