print_add
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import 'quill/dist/quill.snow.css';
|
||||
import '../App.css';
|
||||
import DOMPurify from 'dompurify';
|
||||
import SlidesManager from './SlidesManager';
|
||||
|
||||
function Editor() {
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
@@ -15,7 +16,6 @@ function Editor() {
|
||||
const [editContent, setEditContent] = useState('');
|
||||
const [editPreviewContent, setEditPreviewContent] = useState('');
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
//const [csrfToken, setCsrfToken] = useState('');
|
||||
const [notify, setNotify] = useState({ message: '', type: '', show: false });
|
||||
|
||||
const editorRef = useRef(null);
|
||||
@@ -30,17 +30,6 @@ function Editor() {
|
||||
}, 2200);
|
||||
}, []);
|
||||
|
||||
|
||||
// Fetch CSRF token
|
||||
//useEffect(() => {
|
||||
// fetch('/moders/csrf-token')
|
||||
// .then(res => res.json())
|
||||
// .then(data => {
|
||||
// setCsrfToken(data.csrfToken);
|
||||
//})
|
||||
//.catch(() => {});
|
||||
//}, []);
|
||||
|
||||
// Check session on mount
|
||||
useEffect(() => {
|
||||
fetch('/moders/check-session', { credentials: 'include' })
|
||||
@@ -55,9 +44,9 @@ function Editor() {
|
||||
.catch(() => setIsLoggedIn(false));
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const authenticatedFetch = useCallback((url, options = {}) => {
|
||||
const authenticatedFetch = useCallback((url, options = {}) => {
|
||||
return fetch(url, options);
|
||||
}, []);
|
||||
}, []);
|
||||
|
||||
const loadBlocks = useCallback(async (tab) => {
|
||||
try {
|
||||
@@ -105,7 +94,7 @@ const authenticatedFetch = useCallback((url, options = {}) => {
|
||||
|
||||
const handleTabChange = (tab) => {
|
||||
setActiveTab(tab);
|
||||
if (isLoggedIn) {
|
||||
if (isLoggedIn && tab !== 'slides') {
|
||||
loadBlocks(tab);
|
||||
}
|
||||
};
|
||||
@@ -148,8 +137,6 @@ const authenticatedFetch = useCallback((url, options = {}) => {
|
||||
|
||||
const method = editingBlockId ? 'PUT' : 'POST';
|
||||
|
||||
console.log('➡️ Отправка:', method, url, { type: 'text', content });
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
@@ -157,22 +144,18 @@ const authenticatedFetch = useCallback((url, options = {}) => {
|
||||
body: JSON.stringify({ type: 'text', content })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
console.log('⬅️ Ответ сервера:', response.status, data);
|
||||
|
||||
if (response.ok) {
|
||||
showNotify(editingBlockId ? 'Блок обновлён' : 'Блок добавлен', 'success');
|
||||
setShowEditModal(false);
|
||||
loadBlocks(activeTab);
|
||||
} else {
|
||||
const data = await response.json();
|
||||
showNotify(`Ошибка: ${data.message || response.status}`, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('❌ Ошибка при сохранении:', e);
|
||||
showNotify('Ошибка сохранения', 'error');
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const handleTryCloseEditModal = () => {
|
||||
if (editContent !== originalContentRef.current) {
|
||||
@@ -194,7 +177,6 @@ const authenticatedFetch = useCallback((url, options = {}) => {
|
||||
// Initialize Quill when modal opens
|
||||
useEffect(() => {
|
||||
if (showEditModal && editorRef.current && !quillRef.current) {
|
||||
// Dynamically import Quill
|
||||
import('quill').then((QuillModule) => {
|
||||
const Quill = QuillModule.default || QuillModule;
|
||||
if (editorRef.current) {
|
||||
@@ -225,18 +207,16 @@ const authenticatedFetch = useCallback((url, options = {}) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Cleanup Quill when modal closes
|
||||
if (!showEditModal && quillRef.current) {
|
||||
quillRef.current = null;
|
||||
}
|
||||
}, [showEditModal, editContent]);
|
||||
|
||||
// Если не авторизован — показываем форму входа
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<>
|
||||
<div id="notify" className={notify.show ? `show ${notify.type}` : ''}>{notify.message}</div>
|
||||
<div className="container">
|
||||
<h1>Панель модератора</h1>
|
||||
{!isLoggedIn ? (
|
||||
<div id="login-section">
|
||||
<h2>Вход для модератора</h2>
|
||||
<form id="moder-login-form" onSubmit={handleLogin}>
|
||||
@@ -252,10 +232,19 @@ const authenticatedFetch = useCallback((url, options = {}) => {
|
||||
{loginError && <p id="login-error" style={{ color: 'red' }}>{loginError}</p>}
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div id="editor-section">
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Авторизован — показываем редактор
|
||||
return (
|
||||
<>
|
||||
<div id="notify" className={notify.show ? `show ${notify.type}` : ''}>{notify.message}</div>
|
||||
<div className="container">
|
||||
<h1>Панель модератора</h1>
|
||||
<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">
|
||||
Общая информация
|
||||
@@ -269,7 +258,17 @@ const authenticatedFetch = useCallback((url, options = {}) => {
|
||||
<button className={`tab-btn ${activeTab === 'housing' ? 'active' : ''}`} onClick={() => handleTabChange('housing')} data-tab="housing">
|
||||
🏠 Жилищные условия
|
||||
</button>
|
||||
{/* ✅ НОВАЯ ВКЛАДКА СЛАЙДЫ */}
|
||||
<button className={`tab-btn ${activeTab === 'slides' ? 'active' : ''}`} onClick={() => setActiveTab('slides')} data-tab="slides">
|
||||
📸 Слайды
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Рендеринг контента вкладок */}
|
||||
{activeTab === 'slides' ? (
|
||||
<SlidesManager />
|
||||
) : (
|
||||
<>
|
||||
<div style={{ margin: '18px 0' }}>
|
||||
<button id="add-block-btn" onClick={handleAddBlock}>Добавить блок</button>
|
||||
</div>
|
||||
@@ -283,9 +282,10 @@ const authenticatedFetch = useCallback((url, options = {}) => {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Модалка редактирования блоков */}
|
||||
{showEditModal && (
|
||||
<div id="edit-modal" className="modal" style={{ display: 'block' }} onClick={(e) => {
|
||||
if (e.target === e.currentTarget) handleTryCloseEditModal();
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
// src/components/SlidesManager.jsx
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
function SlidesManager() {
|
||||
const [slides, setSlides] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [editingSlide, setEditingSlide] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
image_url: '',
|
||||
title: '',
|
||||
description: '',
|
||||
link: '',
|
||||
order: 0,
|
||||
is_active: 1
|
||||
});
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [notify, setNotify] = useState({ message: '', type: '', show: false });
|
||||
|
||||
const showNotify = useCallback((message, type = 'success') => {
|
||||
setNotify({ message, type, show: true });
|
||||
setTimeout(() => {
|
||||
setNotify({ message: '', type: '', show: false });
|
||||
}, 2500);
|
||||
}, []);
|
||||
|
||||
// Загрузка слайдов
|
||||
const loadSlides = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/moders/content/slides/all', {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSlides(Array.isArray(data) ? data : []);
|
||||
} else {
|
||||
showNotify('Ошибка загрузки слайдов', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showNotify('Ошибка загрузки слайдов', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [showNotify]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSlides();
|
||||
}, [loadSlides]);
|
||||
|
||||
// Открыть модалку для добавления
|
||||
const handleAddSlide = () => {
|
||||
setEditingSlide(null);
|
||||
setFormData({
|
||||
image_url: '',
|
||||
title: '',
|
||||
description: '',
|
||||
link: '',
|
||||
order: slides.length,
|
||||
is_active: 1
|
||||
});
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
// Открыть модалку для редактирования
|
||||
const handleEditSlide = (slide) => {
|
||||
setEditingSlide(slide);
|
||||
setFormData({
|
||||
image_url: slide.image_url || '',
|
||||
title: slide.title || '',
|
||||
description: slide.description || '',
|
||||
link: slide.link || '',
|
||||
order: slide.order || 0,
|
||||
is_active: slide.is_active !== undefined ? slide.is_active : 1
|
||||
});
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
// Сохранить слайд
|
||||
// В функции handleSaveSlide
|
||||
const handleSaveSlide = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// ✅ ЛОГИ ДЛЯ ОТЛАДКИ
|
||||
console.log('========================================');
|
||||
console.log('📤 ОТПРАВКА ДАННЫХ НА СЕРВЕР');
|
||||
console.log('📤 editingSlide:', editingSlide);
|
||||
console.log('📤 formData:', JSON.stringify(formData, null, 2));
|
||||
console.log('========================================');
|
||||
|
||||
if (!formData.image_url) {
|
||||
showNotify('URL изображения обязателен', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const url = editingSlide
|
||||
? `/moders/content/slides/${editingSlide.id}`
|
||||
: '/moders/content/slides';
|
||||
|
||||
const method = editingSlide ? 'PUT' : 'POST';
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
console.log('📥 ОТВЕТ СЕРВЕРА:', res.status, data);
|
||||
|
||||
if (res.ok) {
|
||||
showNotify(editingSlide ? 'Слайд обновлён' : 'Слайд добавлен', 'success');
|
||||
setShowEditModal(false);
|
||||
loadSlides();
|
||||
} else {
|
||||
showNotify(data.message || `Ошибка: ${res.status}`, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('❌ Ошибка:', e);
|
||||
showNotify('Ошибка сохранения', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Удалить слайд
|
||||
const handleDeleteSlide = async (id) => {
|
||||
if (!window.confirm('Удалить этот слайд?')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/moders/content/slides/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
if (res.ok) {
|
||||
showNotify('Слайд удалён', 'success');
|
||||
loadSlides();
|
||||
} else {
|
||||
showNotify('Ошибка удаления', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showNotify('Ошибка удаления', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Загрузка изображения
|
||||
const handleUploadImage = async (file) => {
|
||||
if (!file) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const res = await fetch('/moders/content/slides/upload', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setFormData(prev => ({ ...prev, image_url: data.path }));
|
||||
showNotify('Изображение загружено', 'success');
|
||||
} else {
|
||||
showNotify('Ошибка загрузки изображения', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showNotify('Ошибка загрузки изображения', 'error');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div id="slides-manager">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||
<h2>📸 Слайды карусели</h2>
|
||||
<button id="add-slide-btn" onClick={handleAddSlide}>+ Добавить слайд</button>
|
||||
</div>
|
||||
|
||||
{/* Уведомления */}
|
||||
{notify.show && (
|
||||
<div className={`notify ${notify.type}`} style={{
|
||||
padding: '10px 16px',
|
||||
marginBottom: '15px',
|
||||
borderRadius: '8px',
|
||||
background: notify.type === 'success' ? '#d4edda' : '#f8d7da',
|
||||
color: notify.type === 'success' ? '#155724' : '#721c24',
|
||||
border: `1px solid ${notify.type === 'success' ? '#c3e6cb' : '#f5c6cb'}`
|
||||
}}>
|
||||
{notify.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Список слайдов */}
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>Загрузка...</div>
|
||||
) : slides.length === 0 ? (
|
||||
<p style={{ color: '#999', textAlign: 'center', padding: '40px' }}>Нет слайдов. Добавьте первый!</p>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))', gap: '20px' }}>
|
||||
{slides.map(slide => (
|
||||
<div key={slide.id} style={{
|
||||
background: '#fff',
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
||||
border: `2px solid ${slide.is_active ? '#28a745' : '#dc3545'}`,
|
||||
position: 'relative'
|
||||
}}>
|
||||
<img
|
||||
src={slide.image_url}
|
||||
alt={slide.title || 'Слайд'}
|
||||
style={{ width: '100%', height: '150px', objectFit: 'cover' }}
|
||||
onError={(e) => {
|
||||
e.target.src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="100%25" height="100%25"%3E%3Crect width="100%25" height="100%25" fill="%23ddd"/%3E%3Ctext x="50%25" y="50%25" text-anchor="middle" dy=".3em" fill="%23999" font-size="14"%3E%D0%9D%D0%B5%D1%82%20%D0%B8%D0%B7%D0%BE%D0%B1%D1%80%D0%B0%D0%B6%D0%B5%D0%BD%D0%B8%D1%8F%3C/text%3E%3C/svg%3E';
|
||||
}}
|
||||
/>
|
||||
<div style={{ padding: '12px 16px' }}>
|
||||
<h4 style={{ margin: '0 0 4px 0', fontSize: '16px' }}>{slide.title || 'Без названия'}</h4>
|
||||
<p style={{ margin: '0 0 8px 0', fontSize: '13px', color: '#666', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{slide.description || ''}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '8px', fontSize: '12px', color: '#999', marginBottom: '10px' }}>
|
||||
<span>Порядок: {slide.order}</span>
|
||||
<span>•</span>
|
||||
<span style={{ color: slide.is_active ? '#28a745' : '#dc3545' }}>
|
||||
{slide.is_active ? 'Активен' : 'Неактивен'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button className="btn-small" onClick={() => handleEditSlide(slide)}>✏️</button>
|
||||
<button className="btn-small" onClick={() => handleDeleteSlide(slide.id)}>🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Модалка редактирования */}
|
||||
{showEditModal && (
|
||||
<div className="modal" style={{
|
||||
display: 'block',
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
zIndex: 2000,
|
||||
padding: '20px',
|
||||
overflowY: 'auto'
|
||||
}} onClick={(e) => {
|
||||
if (e.target === e.currentTarget) setShowEditModal(false);
|
||||
}}>
|
||||
<div className="modal-content" style={{
|
||||
maxWidth: '700px',
|
||||
margin: '40px auto',
|
||||
background: '#fff',
|
||||
borderRadius: '16px',
|
||||
padding: '30px',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<button className="close" style={{
|
||||
position: 'absolute',
|
||||
top: '12px',
|
||||
right: '20px',
|
||||
fontSize: '28px',
|
||||
cursor: 'pointer',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#999'
|
||||
}} onClick={() => setShowEditModal(false)}>×</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;
|
||||
@@ -2017,3 +2017,322 @@
|
||||
[2026-08-18T13:21:48.177Z] [INFO] Login successful
|
||||
[2026-08-18T13:21:48.283Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||
[2026-08-18T13:21:48.296Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||
[2026-08-19T06:49:14.829Z] [INFO] Server running on http://localhost:3005
|
||||
[2026-08-19T06:49:14.837Z] [INFO] Card logs initialized successfully
|
||||
[2026-08-19T08:16:23.702Z] [INFO] Server running on http://localhost:3005
|
||||
[2026-08-19T08:16:23.706Z] [INFO] Card logs initialized successfully
|
||||
[2026-08-19T09:32:26.775Z] [INFO] Server running on http://localhost:3005
|
||||
[2026-08-19T09:32:26.778Z] [INFO] Card logs initialized successfully
|
||||
[2026-08-19T09:54:13.538Z] [INFO] Server running on http://localhost:3005
|
||||
[2026-08-19T09:54:13.541Z] [INFO] Card logs initialized successfully
|
||||
[2026-08-19T10:03:57.687Z] [INFO] Server running on http://localhost:3005
|
||||
[2026-08-19T10:03:57.691Z] [INFO] Card logs initialized successfully
|
||||
[2026-08-19T13:16:59.418Z] [INFO] Login successful
|
||||
[2026-08-19T13:16:59.575Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||
[2026-08-19T13:16:59.591Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||
[2026-08-19T13:17:34.211Z] [INFO] [LOGIN] Попытка входа: vova
|
||||
[2026-08-19T13:17:34.212Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||
[2026-08-19T13:17:34.217Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||
[2026-08-19T13:17:34.217Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||
[2026-08-19T13:17:34.291Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||
[2026-08-19T13:17:34.292Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||
[2026-08-19T13:17:34.293Z] [INFO] [LOGIN] Ответ отправлен
|
||||
[2026-08-22T10:02:00.127Z] [INFO] Server running on http://localhost:3005
|
||||
[2026-08-22T10:02:00.130Z] [INFO] Card logs initialized successfully
|
||||
[2026-08-28T13:16:54.329Z] [INFO] Server running on http://localhost:3005
|
||||
[2026-08-28T13:16:54.345Z] [INFO] Card logs initialized successfully
|
||||
[2026-08-28T13:17:44.305Z] [WARN] Неверный пароль
|
||||
[2026-08-28T13:17:47.842Z] [WARN] Неверный пароль
|
||||
[2026-08-28T13:17:49.122Z] [WARN] Неверный пароль
|
||||
[2026-08-28T13:18:24.580Z] [INFO] Login successful
|
||||
[2026-08-28T13:18:24.735Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||
[2026-08-28T13:18:24.756Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||
[2026-08-28T13:18:42.365Z] [INFO] [LOGIN] Попытка входа: vova
|
||||
[2026-08-28T13:18:42.366Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||
[2026-08-28T13:18:42.380Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||
[2026-08-28T13:18:42.381Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||
[2026-08-28T13:18:42.462Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||
[2026-08-28T13:18:42.463Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||
[2026-08-28T13:18:42.464Z] [INFO] [LOGIN] Ответ отправлен
|
||||
[2026-08-28T13:21:07.055Z] [INFO] Server running on http://localhost:3005
|
||||
[2026-08-28T13:21:07.058Z] [INFO] Card logs initialized successfully
|
||||
[2026-08-28T13:21:24.349Z] [ERROR] MulterError: Unexpected field
|
||||
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||
at Multipart.emit (node:events:509:28)
|
||||
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||
[2026-08-28T13:21:28.269Z] [ERROR] MulterError: Unexpected field
|
||||
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||
at Multipart.emit (node:events:509:28)
|
||||
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||
[2026-08-28T13:22:13.764Z] [ERROR] MulterError: Unexpected field
|
||||
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||
at Multipart.emit (node:events:509:28)
|
||||
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||
[2026-08-28T13:25:47.640Z] [INFO] Server running on http://localhost:3005
|
||||
[2026-08-28T13:25:47.643Z] [INFO] Card logs initialized successfully
|
||||
[2026-08-28T13:25:57.085Z] [ERROR] MulterError: Unexpected field
|
||||
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||
at Multipart.emit (node:events:509:28)
|
||||
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||
[2026-08-28T13:28:40.679Z] [ERROR] MulterError: Unexpected field
|
||||
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||
at Multipart.emit (node:events:509:28)
|
||||
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||
[2026-08-28T13:35:09.914Z] [ERROR] MulterError: Unexpected field
|
||||
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||
at Multipart.emit (node:events:509:28)
|
||||
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||
[2026-08-28T13:36:00.169Z] [INFO] Server running on http://localhost:3005
|
||||
[2026-08-28T13:36:00.172Z] [INFO] Card logs initialized successfully
|
||||
[2026-08-28T13:37:58.077Z] [INFO] Server running on http://localhost:3005
|
||||
[2026-08-28T13:37:58.080Z] [INFO] Card logs initialized successfully
|
||||
[2026-08-28T13:38:24.214Z] [ERROR] MulterError: Unexpected field
|
||||
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||
at Multipart.emit (node:events:509:28)
|
||||
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||
[2026-08-28T13:38:54.054Z] [ERROR] MulterError: Unexpected field
|
||||
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||
at Multipart.emit (node:events:509:28)
|
||||
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||
[2026-08-28T13:38:57.735Z] [ERROR] MulterError: Unexpected field
|
||||
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||
at Multipart.emit (node:events:509:28)
|
||||
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||
[2026-08-28T13:40:58.165Z] [INFO] Server running on http://localhost:3005
|
||||
[2026-08-28T13:40:58.168Z] [INFO] Card logs initialized successfully
|
||||
[2026-08-28T13:41:09.217Z] [INFO] [LOGIN] Попытка входа: vova
|
||||
[2026-08-28T13:41:09.218Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||
[2026-08-28T13:41:09.220Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||
[2026-08-28T13:41:09.220Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||
[2026-08-28T13:41:09.298Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||
[2026-08-28T13:41:09.299Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||
[2026-08-28T13:41:09.301Z] [INFO] [LOGIN] Ответ отправлен
|
||||
[2026-08-28T13:41:18.242Z] [ERROR] MulterError: Unexpected field
|
||||
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||
at Multipart.emit (node:events:509:28)
|
||||
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||
[2026-08-28T13:44:33.109Z] [INFO] Server running on http://localhost:3005
|
||||
[2026-08-28T13:44:33.112Z] [INFO] Card logs initialized successfully
|
||||
[2026-08-28T13:44:45.327Z] [ERROR] MulterError: Unexpected field
|
||||
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||
at Multipart.emit (node:events:509:28)
|
||||
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||
[2026-08-28T13:47:57.993Z] [INFO] Server running on http://localhost:3005
|
||||
[2026-08-28T13:47:57.997Z] [INFO] Card logs initialized successfully
|
||||
[2026-08-28T13:52:17.446Z] [INFO] Server running on http://localhost:3005
|
||||
[2026-08-28T13:52:17.449Z] [INFO] Card logs initialized successfully
|
||||
[2026-08-28T13:53:18.856Z] [INFO] Server running on http://localhost:3005
|
||||
[2026-08-28T13:53:18.859Z] [INFO] Card logs initialized successfully
|
||||
[2026-08-28T13:53:30.533Z] [INFO] [LOGIN] Попытка входа: vova
|
||||
[2026-08-28T13:53:30.534Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||
[2026-08-28T13:53:30.546Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||
[2026-08-28T13:53:30.547Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||
[2026-08-28T13:53:30.646Z] [INFO] [LOGIN] Результат сравнения пароля: false
|
||||
[2026-08-28T13:53:30.646Z] [WARN] [LOGIN] Неверный пароль для пользователя: vova
|
||||
[2026-08-28T13:53:36.962Z] [INFO] [LOGIN] Попытка входа: vova
|
||||
[2026-08-28T13:53:36.963Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||
[2026-08-28T13:53:36.965Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||
[2026-08-28T13:53:36.965Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||
[2026-08-28T13:53:37.043Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||
[2026-08-28T13:53:37.043Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||
[2026-08-28T13:53:37.045Z] [INFO] [LOGIN] Ответ отправлен
|
||||
[2026-08-28T13:54:12.715Z] [INFO] [LOGIN] Попытка входа: vova
|
||||
[2026-08-28T13:54:12.715Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||
[2026-08-28T13:54:12.717Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||
[2026-08-28T13:54:12.718Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||
[2026-08-28T13:54:12.803Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||
[2026-08-28T13:54:12.804Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||
[2026-08-28T13:54:12.805Z] [INFO] [LOGIN] Ответ отправлен
|
||||
[2026-08-28T13:54:29.593Z] [ERROR] MulterError: Unexpected field
|
||||
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||
at Multipart.emit (node:events:509:28)
|
||||
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||
[2026-08-28T13:55:32.725Z] [INFO] Server running on http://localhost:3005
|
||||
[2026-08-28T13:55:32.728Z] [INFO] Card logs initialized successfully
|
||||
[2026-08-28T13:55:44.526Z] [ERROR] MulterError: Unexpected field
|
||||
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||
at Multipart.emit (node:events:509:28)
|
||||
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||
[2026-08-28T13:57:30.453Z] [INFO] Server running on http://localhost:3005
|
||||
[2026-08-28T13:57:30.456Z] [INFO] Card logs initialized successfully
|
||||
[2026-08-28T13:57:37.890Z] [INFO] [LOGIN] Попытка входа: vova
|
||||
[2026-08-28T13:57:37.891Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||
[2026-08-28T13:57:37.893Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||
[2026-08-28T13:57:37.893Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||
[2026-08-28T13:57:37.973Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||
[2026-08-28T13:57:37.973Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||
[2026-08-28T13:57:37.975Z] [INFO] [LOGIN] Ответ отправлен
|
||||
[2026-08-28T13:59:04.163Z] [INFO] Server running on http://localhost:3005
|
||||
[2026-08-28T13:59:04.166Z] [INFO] Card logs initialized successfully
|
||||
[2026-08-28T13:59:18.113Z] [INFO] [LOGIN] Попытка входа: vova
|
||||
[2026-08-28T13:59:18.113Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||
[2026-08-28T13:59:18.128Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||
[2026-08-28T13:59:18.129Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||
[2026-08-28T13:59:18.259Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||
[2026-08-28T13:59:18.260Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||
[2026-08-28T13:59:18.262Z] [INFO] [LOGIN] Ответ отправлен
|
||||
[2026-08-28T14:02:13.224Z] [INFO] Server running on http://localhost:3005
|
||||
[2026-08-28T14:02:13.226Z] [INFO] Card logs initialized successfully
|
||||
[2026-08-28T14:02:25.923Z] [INFO] [LOGIN] Попытка входа: vova
|
||||
[2026-08-28T14:02:25.924Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||
[2026-08-28T14:02:25.940Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||
[2026-08-28T14:02:25.941Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||
[2026-08-28T14:02:26.078Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||
[2026-08-28T14:02:26.087Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||
[2026-08-28T14:02:26.089Z] [INFO] [LOGIN] Ответ отправлен
|
||||
[2026-08-28T14:03:37.800Z] [ERROR] MulterError: Unexpected field
|
||||
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||
at Multipart.emit (node:events:509:28)
|
||||
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||
[2026-08-28T14:05:00.439Z] [INFO] Server running on http://localhost:3005
|
||||
[2026-08-28T14:05:00.442Z] [INFO] Card logs initialized successfully
|
||||
[2026-08-28T14:05:47.706Z] [INFO] [LOGIN] Попытка входа: vova
|
||||
[2026-08-28T14:05:47.707Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||
[2026-08-28T14:05:47.709Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||
[2026-08-28T14:05:47.710Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||
[2026-08-28T14:05:47.796Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||
[2026-08-28T14:05:47.797Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||
[2026-08-28T14:05:47.798Z] [INFO] [LOGIN] Ответ отправлен
|
||||
[2026-08-28T14:06:31.202Z] [INFO] [LOGIN] Попытка входа: grivova
|
||||
[2026-08-28T14:06:31.202Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||
[2026-08-28T14:06:31.204Z] [INFO] [LOGIN] Результат поиска пользователя: не найден
|
||||
[2026-08-28T14:06:31.204Z] [WARN] [LOGIN] Пользователь не найден: grivova
|
||||
[2026-08-28T14:06:43.404Z] [INFO] [LOGIN] Попытка входа: asutp
|
||||
[2026-08-28T14:06:43.406Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||
[2026-08-28T14:06:43.407Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||
[2026-08-28T14:06:43.408Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||
[2026-08-28T14:06:43.489Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||
[2026-08-28T14:06:43.490Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":5,"username":"asutp"}
|
||||
[2026-08-28T14:06:43.491Z] [INFO] [LOGIN] Ответ отправлен
|
||||
[2026-08-28T14:07:57.016Z] [INFO] Server running on http://localhost:3005
|
||||
[2026-08-28T14:07:57.018Z] [INFO] Card logs initialized successfully
|
||||
[2026-08-28T14:08:15.541Z] [INFO] [LOGIN] Попытка входа: vova
|
||||
[2026-08-28T14:08:15.543Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||
[2026-08-28T14:08:15.546Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||
[2026-08-28T14:08:15.547Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||
[2026-08-28T14:08:15.629Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||
[2026-08-28T14:08:15.630Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||
[2026-08-28T14:08:15.631Z] [INFO] [LOGIN] Ответ отправлен
|
||||
[2026-08-28T14:09:51.626Z] [INFO] Server running on http://localhost:3005
|
||||
[2026-08-28T14:09:51.629Z] [INFO] Card logs initialized successfully
|
||||
[2026-08-31T05:58:23.110Z] [INFO] Server running on http://localhost:3005
|
||||
[2026-08-31T05:58:23.117Z] [INFO] Card logs initialized successfully
|
||||
[2026-08-31T11:41:26.146Z] [INFO] Server running on http://localhost:3005
|
||||
[2026-08-31T11:41:26.163Z] [INFO] Card logs initialized successfully
|
||||
[2026-08-31T11:41:58.530Z] [INFO] [LOGIN] Попытка входа: vova
|
||||
[2026-08-31T11:41:58.531Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||
[2026-08-31T11:41:58.554Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||
[2026-08-31T11:41:58.562Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||
[2026-08-31T11:41:58.657Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||
[2026-08-31T11:41:58.661Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||
[2026-08-31T11:41:58.668Z] [INFO] [LOGIN] Ответ отправлен
|
||||
[2026-08-31T12:06:00.544Z] [INFO] Server running on http://localhost:3005
|
||||
[2026-08-31T12:06:00.547Z] [INFO] Card logs initialized successfully
|
||||
[2026-08-31T12:18:40.420Z] [INFO] Server running on http://localhost:3005
|
||||
[2026-08-31T12:18:40.423Z] [INFO] Card logs initialized successfully
|
||||
[2026-08-31T12:21:29.695Z] [INFO] Server running on http://localhost:3005
|
||||
[2026-08-31T12:21:29.697Z] [INFO] Card logs initialized successfully
|
||||
[2026-08-31T12:23:29.133Z] [INFO] Server running on http://localhost:3005
|
||||
[2026-08-31T12:23:29.137Z] [INFO] Card logs initialized successfully
|
||||
[2026-08-31T12:27:21.641Z] [INFO] Server running on http://localhost:3005
|
||||
[2026-08-31T12:27:21.644Z] [INFO] Card logs initialized successfully
|
||||
[2026-08-31T12:30:33.180Z] [INFO] Server running on http://localhost:3005
|
||||
[2026-08-31T12:30:33.183Z] [INFO] Card logs initialized successfully
|
||||
|
||||
@@ -34,6 +34,123 @@ const upload = multer({
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 📸 СЛАЙДЫ ДЛЯ КАРУСЕЛИ (В САМОМ НАЧАЛЕ)
|
||||
// ============================================
|
||||
|
||||
// Получить все активные слайды
|
||||
router.get('/slides', async (req, res) => {
|
||||
console.log('📸 GET /slides');
|
||||
try {
|
||||
const [rows] = await db.query(
|
||||
'SELECT * FROM slides WHERE is_active = 1 ORDER BY `order` ASC'
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
console.error('❌ Ошибка получения слайдов:', err);
|
||||
res.status(500).json({ message: 'Ошибка сервера', error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Получить все слайды (для админки)
|
||||
router.get('/slides/all', async (req, res) => {
|
||||
console.log('📸 GET /slides/all');
|
||||
try {
|
||||
const [rows] = await db.query(
|
||||
'SELECT * FROM slides ORDER BY `order` ASC'
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
console.error('❌ Ошибка получения всех слайдов:', err);
|
||||
res.status(500).json({ message: 'Ошибка сервера', error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Загрузка изображения для слайда
|
||||
router.post('/slides/upload', upload.single('image'), (req, res) => {
|
||||
console.log('📸 POST /slides/upload');
|
||||
console.log('📁 req.file:', req.file);
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ message: 'Файл не загружен' });
|
||||
}
|
||||
const relPath = '/uploads/' + req.file.filename;
|
||||
console.log('✅ Изображение загружено:', relPath);
|
||||
res.json({ path: relPath });
|
||||
});
|
||||
|
||||
// Создать слайд
|
||||
router.post('/slides', async (req, res) => {
|
||||
console.log('📸 POST /slides');
|
||||
const { image_url, title, description, link, order, is_active } = req.body;
|
||||
|
||||
if (!image_url) {
|
||||
return res.status(400).json({ message: 'image_url обязателен' });
|
||||
}
|
||||
|
||||
try {
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO slides (image_url, title, description, link, \`order\`, is_active, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())`,
|
||||
[image_url, title || null, description || null, link || null, order || 0, is_active !== undefined ? is_active : 1]
|
||||
);
|
||||
console.log('✅ Слайд создан, ID:', result.insertId);
|
||||
res.json({ id: result.insertId, message: 'Слайд создан' });
|
||||
} catch (err) {
|
||||
console.error('❌ Ошибка создания слайда:', err);
|
||||
res.status(500).json({ message: 'Ошибка сервера', error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Обновить слайд
|
||||
router.put('/slides/:id', async (req, res) => {
|
||||
console.log('📸 PUT /slides/' + req.params.id);
|
||||
const { id } = req.params;
|
||||
const { image_url, title, description, link, order, is_active } = req.body;
|
||||
|
||||
if (!image_url) {
|
||||
return res.status(400).json({ message: 'image_url обязателен' });
|
||||
}
|
||||
|
||||
try {
|
||||
await db.query(
|
||||
`UPDATE slides
|
||||
SET image_url = ?,
|
||||
title = ?,
|
||||
description = ?,
|
||||
link = ?,
|
||||
\`order\` = ?,
|
||||
is_active = ?,
|
||||
updated_at = NOW()
|
||||
WHERE id = ?`,
|
||||
[image_url, title || null, description || null, link || null, order || 0, is_active !== undefined ? is_active : 1, id]
|
||||
);
|
||||
console.log('✅ Слайд обновлён, ID:', id);
|
||||
res.json({ message: 'Слайд обновлён' });
|
||||
} catch (err) {
|
||||
console.error('❌ Ошибка обновления слайда:', err);
|
||||
res.status(500).json({ message: 'Ошибка сервера', error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Удалить слайд
|
||||
router.delete('/slides/:id', async (req, res) => {
|
||||
console.log('📸 DELETE /slides/' + req.params.id);
|
||||
const { id } = req.params;
|
||||
try {
|
||||
await db.query('DELETE FROM slides WHERE id = ?', [id]);
|
||||
console.log('✅ Слайд удалён, ID:', id);
|
||||
res.json({ message: 'Слайд удалён' });
|
||||
} catch (err) {
|
||||
console.error('❌ Ошибка удаления слайда:', err);
|
||||
res.status(500).json({ message: 'Ошибка сервера', error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 📚 ОСТАЛЬНЫЕ МАРШРУТЫ (НИЖЕ)
|
||||
// ============================================
|
||||
|
||||
// Получить все блоки для вкладки
|
||||
router.get('/:tab', async (req, res) => {
|
||||
const { tab } = req.params;
|
||||
@@ -96,6 +213,7 @@ router.delete('/:tab/:id', requireModerator, async (req, res) => {
|
||||
res.status(500).json({ message: 'Ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/section/:section', async (req, res) => {
|
||||
const { section } = req.params;
|
||||
if (!['events', 'help', 'housing'].includes(section)) {
|
||||
@@ -106,7 +224,6 @@ router.get('/section/:section', async (req, res) => {
|
||||
'SELECT * FROM content_blocks WHERE tab = ? ORDER BY created_at ASC',
|
||||
[section]
|
||||
);
|
||||
// Если есть блоки — возвращаем их, иначе пустой массив
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -114,7 +231,7 @@ router.get('/section/:section', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Сохранить контент раздела (очищаем старые и вставляем новый)
|
||||
// Сохранить контент раздела
|
||||
router.post('/section/:section', requireModerator, async (req, res) => {
|
||||
const { section } = req.params;
|
||||
const { content } = req.body;
|
||||
@@ -124,10 +241,8 @@ router.post('/section/:section', requireModerator, async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
// Удаляем старые блоки для этого раздела
|
||||
await db.query('DELETE FROM content_blocks WHERE tab = ?', [section]);
|
||||
|
||||
// Вставляем новый блок
|
||||
const [result] = await db.query(
|
||||
'INSERT INTO content_blocks (tab, type, content, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW())',
|
||||
[section, 'text', content, req.session.userId]
|
||||
@@ -143,4 +258,5 @@ router.post('/section/:section', requireModerator, async (req, res) => {
|
||||
res.status(500).json({ message: 'Ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 804 KiB |
|
After Width: | Height: | Size: 804 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"payslipsPath": "\\\\PC-20010\\payslips2",
|
||||
"payslipsPath": "./payslips",
|
||||
"networkCredentials": {
|
||||
"username": "",
|
||||
"password": "",
|
||||
"domain": "WORKGROUP"
|
||||
},
|
||||
"server": {
|
||||
"host": "localhost",
|
||||
"port": "3001"
|
||||
@@ -51,9 +56,9 @@
|
||||
}
|
||||
],
|
||||
"payslips": [
|
||||
"Петров_ПП_2026-08.pdf",
|
||||
"Сидорова_АС_2026-08.pdf",
|
||||
"Иванов_ИИ_2026-08.pdf"
|
||||
"001.pdf",
|
||||
"002.pdf",
|
||||
"003.pdf"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -27,10 +27,11 @@ async function readJson(filePath) {
|
||||
}
|
||||
|
||||
async function pathExists(filePath) {
|
||||
console.log('🔍 Проверка пути:', filePath);
|
||||
try {
|
||||
await fsPromises.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -69,7 +70,9 @@ async function connectToNetworkShare(config) {
|
||||
// ============================================
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(cors());
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
app.use(function(req, res, next) {
|
||||
@@ -81,7 +84,31 @@ app.use(function(req, res, next) {
|
||||
next();
|
||||
});
|
||||
|
||||
app.use(helmet());
|
||||
// ✅ ИСПРАВЛЕННАЯ НАСТРОЙКА HELMET (разрешаем iframe)
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
// РАЗРЕШАЕМ встраивание в iframe с localhost:3000
|
||||
frameAncestors: ["'self'", "http://localhost:3000", "http://127.0.0.1:3000"],
|
||||
// Для PDF файлов
|
||||
frameSrc: ["'self'", "blob:"],
|
||||
// Другие необходимые директивы
|
||||
scriptSrc: ["'self'", "'unsafe-inline'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", "data:", "blob:"],
|
||||
connectSrc: ["'self'"],
|
||||
fontSrc: ["'self'"],
|
||||
objectSrc: ["'self'", "blob:"],
|
||||
mediaSrc: ["'self'"],
|
||||
},
|
||||
},
|
||||
// Отключаем лишние политики для разработки
|
||||
crossOriginEmbedderPolicy: false,
|
||||
crossOriginOpenerPolicy: false,
|
||||
crossOriginResourcePolicy: { policy: "cross-origin" },
|
||||
}));
|
||||
|
||||
app.use('/api/', limiter);
|
||||
|
||||
// ============================================
|
||||
@@ -342,6 +369,269 @@ app.post('/api/rates/update-from-nbrb', async function(req, res) {
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 📄 РАСЧЕТНЫЕ ЛИСТКИ (ОБНОВЛЕННЫЕ ЭНДПОИНТЫ)
|
||||
// ============================================
|
||||
|
||||
// ✅ НОВАЯ ФУНКЦИЯ: получает путь к файлу с учетом периода (месяца)
|
||||
async function getPayslipFilePath(tnom, period = null) {
|
||||
const configPath = path.join(__dirname, '../config.json');
|
||||
const config = await readJson(configPath);
|
||||
let payslipBasePath = config.payslipsPath;
|
||||
|
||||
if (!path.isAbsolute(payslipBasePath)) {
|
||||
payslipBasePath = path.join(__dirname, '..', payslipBasePath);
|
||||
}
|
||||
|
||||
let filePath;
|
||||
if (period) {
|
||||
// ✅ Если передан период — ищем в подпапке
|
||||
filePath = path.join(payslipBasePath, period, tnom + '.pdf');
|
||||
console.log(`📁 Поиск в подпапке: ${period} -> ${filePath}`);
|
||||
} else {
|
||||
// ✅ Если период не передан — ищем в корне (для совместимости)
|
||||
filePath = path.join(payslipBasePath, tnom + '.pdf');
|
||||
console.log(`📁 Поиск в корне: ${filePath}`);
|
||||
}
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
// ✅ ОБНОВЛЕННАЯ ПРОВЕРКА наличия расчетного листка (с period)
|
||||
app.get('/api/payslip/check/:tnom', async function(req, res) {
|
||||
try {
|
||||
var tnom = req.params.tnom;
|
||||
var period = req.query.period || null; // ✅ Берем period из query-параметров
|
||||
|
||||
console.log('==========================================');
|
||||
console.log(`📁 [PAYSLIP CHECK] Табельный номер: ${tnom}, Период: ${period || 'не указан'}`);
|
||||
|
||||
const filePath = await getPayslipFilePath(tnom, period);
|
||||
const exists = await pathExists(filePath);
|
||||
|
||||
console.log(`📁 exists: ${exists}`);
|
||||
console.log('==========================================');
|
||||
|
||||
res.json({
|
||||
exists: exists,
|
||||
tnom: tnom,
|
||||
period: period,
|
||||
path: filePath,
|
||||
fileName: tnom + '.pdf'
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
logger.error('Ошибка проверки расчетного листка:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ✅ ОБНОВЛЕННОЕ ПОЛУЧЕНИЕ URL (с period)
|
||||
app.get('/api/payslip/url/:tnom', async function(req, res) {
|
||||
try {
|
||||
var tnom = req.params.tnom;
|
||||
var period = req.query.period || null; // ✅ Берем period из query-параметров
|
||||
|
||||
console.log('==========================================');
|
||||
console.log(`📁 [PAYSLIP URL] Табельный номер: ${tnom}, Период: ${period || 'не указан'}`);
|
||||
|
||||
const filePath = await getPayslipFilePath(tnom, period);
|
||||
console.log(`📁 Полный путь к файлу: ${filePath}`);
|
||||
|
||||
const exists = await pathExists(filePath);
|
||||
console.log(`📁 exists: ${exists}`);
|
||||
console.log('==========================================');
|
||||
|
||||
if (exists) {
|
||||
// ✅ Формируем URL для получения файла
|
||||
let fileUrl = `/api/payslip/tnom/${tnom}`;
|
||||
if (period) {
|
||||
fileUrl += `?period=${period}`; // ✅ Добавляем period в query
|
||||
}
|
||||
logger.info(`[PAYSLIP URL] Файл найден для ${tnom} (период: ${period || 'корень'})`);
|
||||
res.json({
|
||||
exists: true,
|
||||
url: fileUrl,
|
||||
tnom: tnom,
|
||||
period: period,
|
||||
fileName: tnom + '.pdf'
|
||||
});
|
||||
} else {
|
||||
logger.warn(`[PAYSLIP URL] Файл не найден для ${tnom} (период: ${period || 'корень'})`);
|
||||
res.json({
|
||||
exists: false,
|
||||
message: 'Расчетный листок не найден',
|
||||
tnom: tnom,
|
||||
period: period
|
||||
});
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
logger.error('Ошибка получения URL расчетного листка:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ✅ ОБНОВЛЕННАЯ ОТДАЧА РАСЧЕТНОГО ЛИСТКА (с period)
|
||||
app.get('/api/payslip/tnom/:tnom', async function(req, res) {
|
||||
try {
|
||||
var tnom = req.params.tnom;
|
||||
var period = req.query.period || null; // ✅ Берем period из query-параметров
|
||||
|
||||
console.log('==========================================');
|
||||
console.log(`📁 [PAYSLIP] Запрос к /api/payslip/tnom/${tnom}, Период: ${period || 'не указан'}`);
|
||||
|
||||
const filePath = await getPayslipFilePath(tnom, period);
|
||||
console.log(`📁 Полный путь к файлу: ${filePath}`);
|
||||
|
||||
// ✅ ЯВНО проверяем существование файла
|
||||
try {
|
||||
await fsPromises.access(filePath);
|
||||
console.log('✅ Файл найден, начинаем отправку...');
|
||||
} catch (err) {
|
||||
console.log('❌ Файл НЕ найден:', err.message);
|
||||
return res.status(404).json({
|
||||
message: 'Расчетный листок не найден',
|
||||
tnom: tnom,
|
||||
period: period,
|
||||
path: filePath,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
|
||||
// ✅ Добавляем правильные заголовки для PDF
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', 'inline; filename="' + tnom + '.pdf"');
|
||||
|
||||
// ✅ Отправляем файл с обработкой ошибок
|
||||
res.sendFile(filePath, function(err) {
|
||||
if (err) {
|
||||
console.error('❌ Ошибка при отправке файла:', err);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({
|
||||
message: 'Ошибка при отправке файла',
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.log('✅ Файл успешно отправлен');
|
||||
}
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('❌ Ошибка получения расчетного листка:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 🗄️ СТАРЫЕ ЭНДПОИНТЫ РАСЧЕТНЫХ ЛИСТКОВ (для обратной совместимости)
|
||||
// ============================================
|
||||
|
||||
app.get('/api/payslip/:period/:fileName', async function(req, res) {
|
||||
try {
|
||||
var period = req.params.period;
|
||||
var fileName = req.params.fileName;
|
||||
var configPath = path.join(__dirname, '../config.json');
|
||||
var config = await readJson(configPath);
|
||||
var payslipBasePath = config.payslipsPath;
|
||||
|
||||
logger.info('Payslip request - Period: ' + period + ', File: ' + fileName);
|
||||
logger.info('Base path from config: ' + payslipBasePath);
|
||||
|
||||
var networkConnected = await connectToNetworkShare(config);
|
||||
logger.info('Network share connection for payslip: ' + networkConnected);
|
||||
|
||||
var finalPayslipPath;
|
||||
if (path.isAbsolute(payslipBasePath)) {
|
||||
finalPayslipPath = path.join(payslipBasePath, period, fileName);
|
||||
} else {
|
||||
var projectRoot = path.resolve(__dirname, '../../');
|
||||
finalPayslipPath = path.join(projectRoot, payslipBasePath, period, fileName);
|
||||
}
|
||||
|
||||
logger.info('Final payslip path: ' + finalPayslipPath);
|
||||
|
||||
try {
|
||||
var basePathExists = await pathExists(payslipBasePath);
|
||||
logger.info('Base path exists: ' + basePathExists);
|
||||
|
||||
if (basePathExists) {
|
||||
var basePathStats = await fsPromises.stat(payslipBasePath);
|
||||
logger.info('Base path stats: ' + JSON.stringify({
|
||||
isDirectory: basePathStats.isDirectory(),
|
||||
permissions: basePathStats.mode,
|
||||
size: basePathStats.size
|
||||
}));
|
||||
|
||||
var basePathContents = await fsPromises.readdir(payslipBasePath);
|
||||
logger.info('Base path contents: ' + JSON.stringify(basePathContents));
|
||||
|
||||
var periodPath = path.join(payslipBasePath, period);
|
||||
var periodPathExists = await pathExists(periodPath);
|
||||
logger.info('Period path exists: ' + periodPathExists + ' (' + periodPath + ')');
|
||||
|
||||
if (periodPathExists) {
|
||||
var periodPathContents = await fsPromises.readdir(periodPath);
|
||||
logger.info('Period path contents: ' + JSON.stringify(periodPathContents));
|
||||
}
|
||||
}
|
||||
} catch (basePathError) {
|
||||
logger.error('Error checking base path', {
|
||||
error: basePathError.message,
|
||||
stack: basePathError.stack
|
||||
});
|
||||
}
|
||||
|
||||
if (await pathExists(finalPayslipPath)) {
|
||||
logger.info('Payslip found, sending file: ' + finalPayslipPath);
|
||||
res.sendFile(finalPayslipPath);
|
||||
} else {
|
||||
logger.warn('Payslip not found: ' + finalPayslipPath);
|
||||
res.status(404).json({
|
||||
message: 'Расчетный листок не найден',
|
||||
path: finalPayslipPath,
|
||||
period: period,
|
||||
fileName: fileName,
|
||||
basePath: payslipBasePath,
|
||||
networkConnected: networkConnected
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error getting payslip', { error: err.message, stack: err.stack });
|
||||
res.status(500).json({ message: 'Ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/payslip/:fileName', async function(req, res) {
|
||||
try {
|
||||
var fileName = req.params.fileName;
|
||||
var configPath = path.join(__dirname, '../config.json');
|
||||
var config = await readJson(configPath);
|
||||
var payslipBasePath = config.payslipsPath;
|
||||
|
||||
var finalPayslipPath;
|
||||
if (path.isAbsolute(payslipBasePath)) {
|
||||
finalPayslipPath = path.join(payslipBasePath, fileName);
|
||||
} else {
|
||||
var projectRoot = path.resolve(__dirname, '../../');
|
||||
finalPayslipPath = path.join(projectRoot, payslipBasePath, fileName);
|
||||
}
|
||||
|
||||
logger.info('Attempting to find payslip at resolved path: ' + finalPayslipPath);
|
||||
|
||||
if (await pathExists(finalPayslipPath)) {
|
||||
res.sendFile(finalPayslipPath);
|
||||
} else {
|
||||
logger.warn('Payslip not found: ' + finalPayslipPath);
|
||||
res.status(404).json({ message: 'Расчетный листок не найден' });
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error getting payslip', { error: err.message, stack: err.stack });
|
||||
res.status(500).json({ message: 'Ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 🗄️ ДРУГИЕ ЭНДПОИНТЫ
|
||||
// ============================================
|
||||
@@ -459,110 +749,6 @@ app.get('/api/test-network', async function(req, res) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/payslip/:period/:fileName', async function(req, res) {
|
||||
try {
|
||||
var period = req.params.period;
|
||||
var fileName = req.params.fileName;
|
||||
var configPath = path.join(__dirname, '../config.json');
|
||||
var config = await readJson(configPath);
|
||||
var payslipBasePath = config.payslipsPath;
|
||||
|
||||
logger.info('Payslip request - Period: ' + period + ', File: ' + fileName);
|
||||
logger.info('Base path from config: ' + payslipBasePath);
|
||||
|
||||
var networkConnected = await connectToNetworkShare(config);
|
||||
logger.info('Network share connection for payslip: ' + networkConnected);
|
||||
|
||||
var finalPayslipPath;
|
||||
if (path.isAbsolute(payslipBasePath)) {
|
||||
finalPayslipPath = path.join(payslipBasePath, period, fileName);
|
||||
} else {
|
||||
var projectRoot = path.resolve(__dirname, '../../');
|
||||
finalPayslipPath = path.join(projectRoot, payslipBasePath, period, fileName);
|
||||
}
|
||||
|
||||
logger.info('Final payslip path: ' + finalPayslipPath);
|
||||
|
||||
try {
|
||||
var basePathExists = await pathExists(payslipBasePath);
|
||||
logger.info('Base path exists: ' + basePathExists);
|
||||
|
||||
if (basePathExists) {
|
||||
var basePathStats = await fsPromises.stat(payslipBasePath);
|
||||
logger.info('Base path stats: ' + JSON.stringify({
|
||||
isDirectory: basePathStats.isDirectory(),
|
||||
permissions: basePathStats.mode,
|
||||
size: basePathStats.size
|
||||
}));
|
||||
|
||||
var basePathContents = await fsPromises.readdir(payslipBasePath);
|
||||
logger.info('Base path contents: ' + JSON.stringify(basePathContents));
|
||||
|
||||
var periodPath = path.join(payslipBasePath, period);
|
||||
var periodPathExists = await pathExists(periodPath);
|
||||
logger.info('Period path exists: ' + periodPathExists + ' (' + periodPath + ')');
|
||||
|
||||
if (periodPathExists) {
|
||||
var periodPathContents = await fsPromises.readdir(periodPath);
|
||||
logger.info('Period path contents: ' + JSON.stringify(periodPathContents));
|
||||
}
|
||||
}
|
||||
} catch (basePathError) {
|
||||
logger.error('Error checking base path', {
|
||||
error: basePathError.message,
|
||||
stack: basePathError.stack
|
||||
});
|
||||
}
|
||||
|
||||
if (await pathExists(finalPayslipPath)) {
|
||||
logger.info('Payslip found, sending file: ' + finalPayslipPath);
|
||||
res.sendFile(finalPayslipPath);
|
||||
} else {
|
||||
logger.warn('Payslip not found: ' + finalPayslipPath);
|
||||
res.status(404).json({
|
||||
message: 'Расчетный листок не найден',
|
||||
path: finalPayslipPath,
|
||||
period: period,
|
||||
fileName: fileName,
|
||||
basePath: payslipBasePath,
|
||||
networkConnected: networkConnected
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error getting payslip', { error: err.message, stack: err.stack });
|
||||
res.status(500).json({ message: 'Ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/payslip/:fileName', async function(req, res) {
|
||||
try {
|
||||
var fileName = req.params.fileName;
|
||||
var configPath = path.join(__dirname, '../config.json');
|
||||
var config = await readJson(configPath);
|
||||
var payslipBasePath = config.payslipsPath;
|
||||
|
||||
var finalPayslipPath;
|
||||
if (path.isAbsolute(payslipBasePath)) {
|
||||
finalPayslipPath = path.join(payslipBasePath, fileName);
|
||||
} else {
|
||||
var projectRoot = path.resolve(__dirname, '../../');
|
||||
finalPayslipPath = path.join(projectRoot, payslipBasePath, fileName);
|
||||
}
|
||||
|
||||
logger.info('Attempting to find payslip at resolved path: ' + finalPayslipPath);
|
||||
|
||||
if (await pathExists(finalPayslipPath)) {
|
||||
res.sendFile(finalPayslipPath);
|
||||
} else {
|
||||
logger.warn('Payslip not found: ' + finalPayslipPath);
|
||||
res.status(404).json({ message: 'Расчетный листок не найден' });
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error getting payslip', { error: err.message, stack: err.stack });
|
||||
res.status(500).json({ message: 'Ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/db-status', async function(req, res) {
|
||||
try {
|
||||
var db = require('./config/db');
|
||||
@@ -583,6 +769,13 @@ app.get('/api/time', function(req, res) {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 📄 РАСЧЕТНЫЙ ЛИСТОК (ФОРМА ДЛЯ ПЕЧАТИ)
|
||||
// ============================================
|
||||
|
||||
const payslipFormRoutes = require('./routes/payslipForm');
|
||||
app.use('/api/payslip', payslipFormRoutes);
|
||||
|
||||
// ============================================
|
||||
// 🚀 ЗАПУСК СЕРВЕРА
|
||||
// ============================================
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -9,20 +9,25 @@
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"axios": "^1.19.0",
|
||||
"axios": "^1.20.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"dompurify": "^3.4.13",
|
||||
"dotenv": "^17.4.2",
|
||||
"electron-is-dev": "^2.0.0",
|
||||
"framer-motion": "^13.1.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdf-to-printer": "^5.8.1",
|
||||
"pdfjs-dist": "^3.11.174",
|
||||
"pdfkit": "^0.20.2",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-icons": "^4.11.0",
|
||||
"react-pdf": "^8.0.0",
|
||||
"react-zoom-pan-pinch": "^3.3.0",
|
||||
"sharp": "^0.35.4",
|
||||
"typescript": "^5.8.0",
|
||||
"web-vitals": "^2.1.4"
|
||||
"web-vitals": "^2.1.4",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
|
||||
|
After Width: | Height: | Size: 187 KiB |
@@ -3,11 +3,11 @@ const path = require('path');
|
||||
require('dotenv').config({path: path.join(__dirname, 'config.env')})
|
||||
const isDev = require('electron-is-dev');
|
||||
const fs = require('fs');
|
||||
const COM_PORT = process.env.COM_PORT;
|
||||
const COM_SPEED = process.env.COM_SPEED;
|
||||
const axios = require('axios');
|
||||
const { logger } = require('../../backend/src/config/logger');
|
||||
const config = require('../src/config.json')
|
||||
const { print } = require('pdf-to-printer');
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: 'app',
|
||||
@@ -64,13 +64,12 @@ function createWindow() {
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
kiosk: true,
|
||||
titleBarStyle:'hidden',
|
||||
titleBarStyle: 'hidden',
|
||||
alwaysOnTop: false,
|
||||
webPreferences: {
|
||||
preload: preloadPath,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
|
||||
},
|
||||
});
|
||||
|
||||
@@ -91,6 +90,98 @@ function createWindow() {
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 📄 ПЕЧАТЬ PDF ЧЕРЕЗ pdf-to-printer
|
||||
// ============================================
|
||||
|
||||
ipcMain.handle('print-pdf', async (event, data = {}) => {
|
||||
let tempFile = null;
|
||||
|
||||
try {
|
||||
if (!data.pdfUrl) {
|
||||
throw new Error('URL файла PDF не передан');
|
||||
}
|
||||
|
||||
const cleanUrl = data.pdfUrl.split('#')[0];
|
||||
logger.info('[PRINT] Загрузка PDF:', cleanUrl);
|
||||
|
||||
// ✅ Скачиваем PDF
|
||||
const response = await axios.get(cleanUrl, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
const pdfBuffer = Buffer.from(response.data);
|
||||
logger.info('[PRINT] Размер PDF:', pdfBuffer.length, 'байт');
|
||||
|
||||
if (pdfBuffer.length < 100) {
|
||||
throw new Error('PDF слишком маленький или пустой');
|
||||
}
|
||||
|
||||
// ✅ Сохраняем во временный файл
|
||||
const tempDir = app.getPath('temp');
|
||||
tempFile = path.join(tempDir, `print_${Date.now()}.pdf`);
|
||||
fs.writeFileSync(tempFile, pdfBuffer);
|
||||
logger.info('[PRINT] Временный файл создан:', tempFile);
|
||||
|
||||
// ✅ ПЕЧАТАЕМ ЧЕРЕЗ pdf-to-printer
|
||||
const printerName = 'GP-80250 Series';
|
||||
logger.info('[PRINT] Отправляем на печать через pdf-to-printer:', printerName);
|
||||
|
||||
// ✅ Опции печати
|
||||
const options = {
|
||||
printer: printerName,
|
||||
// scale: 'fit', // Масштабирование
|
||||
// paperSize: 'A4',
|
||||
// landscape: false,
|
||||
// copies: 1,
|
||||
// silent: true // Тихая печать
|
||||
};
|
||||
|
||||
const result = await print(tempFile, options);
|
||||
logger.info('[PRINT] Результат печати:', result);
|
||||
|
||||
// ✅ Удаляем временный файл
|
||||
try {
|
||||
if (tempFile && fs.existsSync(tempFile)) {
|
||||
fs.unlinkSync(tempFile);
|
||||
logger.info('[PRINT] Временный файл удален');
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
logger.info('[PRINT] ✅ Документ отправлен на печать');
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[PRINT] ❌ Ошибка:', error.message);
|
||||
try {
|
||||
if (tempFile && fs.existsSync(tempFile)) {
|
||||
fs.unlinkSync(tempFile);
|
||||
}
|
||||
} catch (e) {}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 📄 ПОЛУЧЕНИЕ СПИСКА ПРИНТЕРОВ
|
||||
// ============================================
|
||||
|
||||
ipcMain.handle('get-printers', async () => {
|
||||
return [
|
||||
{
|
||||
name: 'GP-80250 Series',
|
||||
displayName: 'GP-80250 Series',
|
||||
isDefault: true,
|
||||
status: 'ready'
|
||||
}
|
||||
];
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 🚀 ЗАПУСК
|
||||
// ============================================
|
||||
|
||||
app.whenReady().then(() => {
|
||||
createWindow();
|
||||
global.allowMinimize = false;
|
||||
@@ -112,14 +203,13 @@ app.whenReady().then(() => {
|
||||
}
|
||||
}, 10);
|
||||
});
|
||||
|
||||
globalShortcut.register('Control+Alt+Enter', () => {
|
||||
global.allowClose = true;
|
||||
app.quit();
|
||||
});
|
||||
|
||||
globalShortcut.register('F11', () => {
|
||||
});
|
||||
|
||||
globalShortcut.register('F11', () => {});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
@@ -138,12 +228,6 @@ app.on('will-quit', () => {
|
||||
globalShortcut.unregisterAll();
|
||||
});
|
||||
|
||||
//считыватель
|
||||
|
||||
ipcMain.handle('get-app-config', async (event) => {
|
||||
console.log('[IPC] Renderer requested app config. Sending:', appConfig);
|
||||
return appConfig;
|
||||
});
|
||||
|
||||
app.whenReady().then(() => {
|
||||
});
|
||||
@@ -1,6 +1,12 @@
|
||||
const { logger } = require('../../backend/src/config/logger');
|
||||
logger.info('[Preload] Script started.');
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
try {
|
||||
const { logger } = require('../../backend/src/config/logger');
|
||||
logger.info('[Preload] Script started.');
|
||||
} catch (e) {
|
||||
console.error('[Preload] Логгер не загрузился, но едем дальше.', e);
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld(
|
||||
'electronAPI',
|
||||
{
|
||||
@@ -8,12 +14,13 @@ contextBridge.exposeInMainWorld(
|
||||
onCardDetected: (callback) => {
|
||||
ipcRenderer.on('card-detected', (event, uid) => callback(uid));
|
||||
},
|
||||
|
||||
removeCardDetectedListeners: () => {
|
||||
ipcRenderer.removeAllListeners('card-detected');
|
||||
}
|
||||
},
|
||||
printPdf: (options) => ipcRenderer.invoke('print-pdf', options),
|
||||
getPrinters: () => ipcRenderer.invoke('get-printers'),
|
||||
}
|
||||
);
|
||||
|
||||
logger.log('[Preload] Script finished. electronAPI exposed.');
|
||||
console.log('[Preload] Script finished. electronAPI exposed.');
|
||||
window.dispatchEvent(new Event('electronApiReady'));
|
||||
@@ -45,6 +45,7 @@ function App() {
|
||||
const [currentSection, setCurrentSection] = useState<string | null>(null);
|
||||
const [showPayslip, setShowPayslip] = useState(false);
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('');
|
||||
const [payslipFileUrl, setPayslipFileUrl] = useState<string>('');
|
||||
|
||||
// 📊 Состояния для таймера
|
||||
const [showInactiveWarning, setShowInactiveWarning] = useState(false);
|
||||
@@ -64,6 +65,7 @@ function App() {
|
||||
setCurrentSection(null);
|
||||
setShowPayslip(false);
|
||||
setSelectedPeriod('');
|
||||
setPayslipFileUrl('');
|
||||
setShowInactiveWarning(false);
|
||||
setLoginError('');
|
||||
|
||||
@@ -138,6 +140,7 @@ function App() {
|
||||
setCurrentSection(null);
|
||||
setShowPayslip(false);
|
||||
setSelectedPeriod('');
|
||||
setPayslipFileUrl('');
|
||||
handleUserActivity();
|
||||
};
|
||||
|
||||
@@ -146,8 +149,8 @@ function App() {
|
||||
handleUserActivity();
|
||||
};
|
||||
|
||||
const handleViewPayslip = (period: string) => {
|
||||
setSelectedPeriod(period);
|
||||
const handleViewPayslip = (fileUrl: string) => {
|
||||
setPayslipFileUrl(fileUrl);
|
||||
setShowPayslip(true);
|
||||
handleUserActivity();
|
||||
};
|
||||
@@ -155,14 +158,10 @@ function App() {
|
||||
const handlePayslipBack = () => {
|
||||
setShowPayslip(false);
|
||||
setSelectedPeriod('');
|
||||
setPayslipFileUrl('');
|
||||
handleUserActivity();
|
||||
};
|
||||
|
||||
const getPayslipUrl = (employee: Employee, period: string) => {
|
||||
const fileName = `${employee.tnom}.pdf`;
|
||||
return `${config.backendUrl}/api/payslip/${period}/${fileName}`;
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// ⚠️ Предупреждение о неактивности
|
||||
// ============================================
|
||||
@@ -279,7 +278,7 @@ function App() {
|
||||
if (showPayslip) {
|
||||
return (
|
||||
<PayslipViewer
|
||||
fileUrl={employee && selectedPeriod ? getPayslipUrl(employee, selectedPeriod) : "/payslips/test.pdf"}
|
||||
fileUrl={payslipFileUrl || "/payslips/test.pdf"}
|
||||
onBack={handlePayslipBack}
|
||||
/>
|
||||
);
|
||||
@@ -316,7 +315,7 @@ function App() {
|
||||
// 🚀 Рендер приложения
|
||||
// ============================================
|
||||
const showTopBar = !showVideo && !showPayslip;
|
||||
const topBarHeight = 70; // высота виджета
|
||||
const topBarHeight = 70;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -331,9 +330,9 @@ function App() {
|
||||
{/* ⚠️ Предупреждение о неактивности */}
|
||||
{renderWarning()}
|
||||
|
||||
{/* 🌤️ ВЕРХНИЙ ВИДЖЕТ: ПОГОДА + ВРЕМЯ + ВЫХОД */}
|
||||
{/* 🌤️ ВЕРХНИЙ ВИДЖЕТ: ПОГОДА + ВРЕМЯ + ПРИВЕТСТВИЕ + ВЫХОД */}
|
||||
{showTopBar && (
|
||||
<WeatherWidget onLogout={handleBack} />
|
||||
<WeatherWidget onLogout={handleBack} employee={employee || undefined} />
|
||||
)}
|
||||
|
||||
{/* 📄 Основной контент с отступом сверху */}
|
||||
@@ -348,7 +347,7 @@ function App() {
|
||||
{renderContent()}
|
||||
</div>
|
||||
|
||||
{/* 📊 НИЖНЯЯ ПАНЕЛЬ: КУРСЫ ВАЛЮТ */}
|
||||
{/* 📊 НИЖНЯЯ ПАНЕЛЬ: ТОЛЬКО КУРСЫ ВАЛЮТ (БЕЗ ПРИВЕТСТВИЯ) */}
|
||||
{showTopBar && (
|
||||
<BottomBar />
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback, memo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { FaArrowLeft, FaSearch, FaArrowUp, FaArrowDown } from 'react-icons/fa';
|
||||
import MapViewer from './MapViewer';
|
||||
@@ -75,7 +75,7 @@ function parseHandbookBlocks(html: string) {
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 📚 Функции рендеринга с подсветкой и поиском
|
||||
// 📚 Функции рендеринга
|
||||
// ============================================
|
||||
const ADMIN_SERVER_URL = config.adminServerUrl || 'http://localhost:3005';
|
||||
|
||||
@@ -85,120 +85,82 @@ function patchMediaSrc(html: string): string {
|
||||
.replace(/<video([^>]+)src=["'](\/uploads\/[^"']+)["']/g, `<video$1src="${ADMIN_SERVER_URL}$2"`);
|
||||
}
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = html;
|
||||
return tmp.textContent || tmp.innerText || '';
|
||||
}
|
||||
|
||||
// ✅ Подсветка текста внутри HTML с сохранением разметки
|
||||
function highlightTextInHtml(html: string, searchTerm: string): { html: string; count: number } {
|
||||
if (!searchTerm.trim()) return { html, count: 0 };
|
||||
// ✅ Простая подсветка через replace (без DOM-обхода)
|
||||
function highlightText(html: string, searchTerm: string): string {
|
||||
if (!searchTerm.trim()) return html;
|
||||
|
||||
const term = searchTerm.trim();
|
||||
const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp(`(${escapedTerm})`, 'gi');
|
||||
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = html;
|
||||
let matchCount = 0;
|
||||
|
||||
function walkNodes(node: Node): void {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.textContent || '';
|
||||
if (text.toLowerCase().includes(term.toLowerCase())) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
const tempRegex = new RegExp(escapedTerm, 'gi');
|
||||
|
||||
while ((match = tempRegex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
fragment.appendChild(document.createTextNode(text.substring(lastIndex, match.index)));
|
||||
}
|
||||
const mark = document.createElement('mark');
|
||||
mark.style.cssText = 'background: #ffdd57; color: #1a3c34; padding: 0 4px; border-radius: 3px; font-weight: 600;';
|
||||
mark.dataset.matchIndex = String(matchCount);
|
||||
mark.textContent = match[0];
|
||||
fragment.appendChild(mark);
|
||||
lastIndex = match.index + match[0].length;
|
||||
matchCount++;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
fragment.appendChild(document.createTextNode(text.substring(lastIndex)));
|
||||
}
|
||||
node.parentNode?.replaceChild(fragment, node);
|
||||
}
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const children = Array.from(node.childNodes);
|
||||
for (const child of children) {
|
||||
walkNodes(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walkNodes(temp);
|
||||
return { html: temp.innerHTML, count: matchCount };
|
||||
}
|
||||
|
||||
function blockMatchesSearch(block: any, searchTerm: string): boolean {
|
||||
if (!searchTerm.trim()) return true;
|
||||
const term = searchTerm.trim().toLowerCase();
|
||||
if (typeof block === 'string') {
|
||||
return stripHtml(block).toLowerCase().includes(term);
|
||||
} else if (block.type === 'phones') {
|
||||
return block.rows.some((row: { desc: string; internal: string; city: string }) =>
|
||||
row.desc.toLowerCase().includes(term) ||
|
||||
row.internal.includes(term) ||
|
||||
row.city.includes(term)
|
||||
return html.replace(/>([^<]*)</g, (match: string, text: string) => {
|
||||
const highlighted = text.replace(regex, (m: string) =>
|
||||
`<mark style="background: #ffdd57; color: #1a3c34; padding: 0 4px; border-radius: 3px; font-weight: 600;">${m}</mark>`
|
||||
);
|
||||
}
|
||||
return true;
|
||||
return `>${highlighted}<`;
|
||||
});
|
||||
}
|
||||
|
||||
function countMatches(html: string, searchTerm: string): number {
|
||||
if (!searchTerm.trim()) return 0;
|
||||
const term = searchTerm.trim();
|
||||
const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp(escapedTerm, 'gi');
|
||||
const matches = html.match(regex);
|
||||
return matches ? matches.length : 0;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 📦 Компонент для рендеринга справочника
|
||||
// 📦 Компонент для рендеринга справочника (ОПТИМИЗИРОВАННЫЙ)
|
||||
// ============================================
|
||||
const HandbookRenderer: React.FC<{
|
||||
const HandbookRenderer = memo(({
|
||||
blocks,
|
||||
searchTerm,
|
||||
onMatchCount
|
||||
}: {
|
||||
blocks: any[];
|
||||
searchTerm: string;
|
||||
onMatchCount: (count: number) => void;
|
||||
}> = ({ blocks, searchTerm, onMatchCount }) => {
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [renderedHtml, setRenderedHtml] = useState<{ html: string; count: number }[]>([]);
|
||||
const [renderedBlocks, setRenderedBlocks] = useState<any[]>([]);
|
||||
const [totalMatches, setTotalMatches] = useState(0);
|
||||
const hasSearch = searchTerm.trim().length > 0;
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const processingRef = useRef(false);
|
||||
|
||||
// ✅ Обработка блоков с задержкой через requestAnimationFrame
|
||||
useEffect(() => {
|
||||
if (!blocks.length) return;
|
||||
|
||||
const result: { html: string; count: number }[] = [];
|
||||
let total = 0;
|
||||
// ✅ Если идет обработка — пропускаем
|
||||
if (processingRef.current) return;
|
||||
processingRef.current = true;
|
||||
|
||||
blocks.forEach(block => {
|
||||
// ✅ Показываем индикатор поиска
|
||||
setIsSearching(true);
|
||||
|
||||
// ✅ Используем requestAnimationFrame для отложенной обработки
|
||||
requestAnimationFrame(() => {
|
||||
// ✅ Еще одна задержка через setTimeout для накопления изменений
|
||||
const timeoutId = setTimeout(() => {
|
||||
const processed = blocks.map(block => {
|
||||
if (typeof block === 'string') {
|
||||
const content = patchMediaSrc(block);
|
||||
if (hasSearch) {
|
||||
const highlighted = highlightTextInHtml(content, searchTerm);
|
||||
result.push({ html: highlighted.html, count: highlighted.count });
|
||||
total += highlighted.count;
|
||||
} else {
|
||||
result.push({ html: content, count: 0 });
|
||||
if (searchTerm.trim()) {
|
||||
return highlightText(content, searchTerm);
|
||||
}
|
||||
return content;
|
||||
} else if (block.type === 'phones') {
|
||||
const term = searchTerm.trim().toLowerCase();
|
||||
let rows = block.rows;
|
||||
if (hasSearch && term) {
|
||||
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) {
|
||||
result.push({ html: '', count: 0 });
|
||||
return;
|
||||
}
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
let tableHtml = `<table class="handbook-table">`;
|
||||
tableHtml += `
|
||||
@@ -215,19 +177,11 @@ const HandbookRenderer: React.FC<{
|
||||
let descHtml = row.desc;
|
||||
let internalHtml = row.internal;
|
||||
let cityHtml = row.city;
|
||||
let rowHasMatch = false;
|
||||
|
||||
if (hasSearch && term) {
|
||||
const descResult = highlightTextInHtml(row.desc, searchTerm);
|
||||
descHtml = descResult.html;
|
||||
if (descResult.count > 0) rowHasMatch = true;
|
||||
const internalResult = highlightTextInHtml(row.internal, searchTerm);
|
||||
internalHtml = internalResult.html;
|
||||
if (internalResult.count > 0) rowHasMatch = true;
|
||||
const cityResult = highlightTextInHtml(row.city, searchTerm);
|
||||
cityHtml = cityResult.html;
|
||||
if (cityResult.count > 0) rowHasMatch = true;
|
||||
total += descResult.count + internalResult.count + cityResult.count;
|
||||
if (term) {
|
||||
descHtml = highlightText(row.desc, searchTerm);
|
||||
internalHtml = highlightText(row.internal, searchTerm);
|
||||
cityHtml = highlightText(row.city, searchTerm);
|
||||
}
|
||||
|
||||
tableHtml += `
|
||||
@@ -239,31 +193,56 @@ const HandbookRenderer: React.FC<{
|
||||
});
|
||||
|
||||
tableHtml += `</tbody></table>`;
|
||||
result.push({ html: tableHtml, count: 0 });
|
||||
return tableHtml;
|
||||
}
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
|
||||
setRenderedBlocks(processed);
|
||||
setIsSearching(false);
|
||||
processingRef.current = false;
|
||||
|
||||
// ✅ Подсчет совпадений
|
||||
if (searchTerm.trim()) {
|
||||
let total = 0;
|
||||
processed.forEach(block => {
|
||||
if (typeof block === 'string') {
|
||||
total += countMatches(block, searchTerm);
|
||||
}
|
||||
});
|
||||
|
||||
setRenderedHtml(result);
|
||||
setTotalMatches(total);
|
||||
onMatchCount(total);
|
||||
}, [blocks, searchTerm, onMatchCount]);
|
||||
} else {
|
||||
setTotalMatches(0);
|
||||
onMatchCount(0);
|
||||
}
|
||||
}, 50);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const marks = containerRef.current.querySelectorAll('mark');
|
||||
marks.forEach((el, idx) => {
|
||||
el.dataset.matchIndex = String(idx);
|
||||
return () => clearTimeout(timeoutId);
|
||||
});
|
||||
}, [renderedHtml]);
|
||||
}, [blocks, searchTerm, onMatchCount]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="handbook-content">
|
||||
{renderedHtml.map((item, idx) => (
|
||||
item.html ? <div key={idx} style={{ marginBottom: 16 }} dangerouslySetInnerHTML={{ __html: item.html }} /> : null
|
||||
{isSearching && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '8px',
|
||||
color: '#636e72',
|
||||
fontSize: '13px',
|
||||
borderBottom: '1px solid #f1f2f6',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
<span style={{ display: 'inline-block', animation: 'spin 1s linear infinite', marginRight: '8px' }}>⟳</span>
|
||||
Поиск...
|
||||
</div>
|
||||
)}
|
||||
{renderedBlocks.map((block, idx) => (
|
||||
<div key={idx} style={{ marginBottom: 16 }} dangerouslySetInnerHTML={{ __html: block }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 🏠 Основной компонент Contacts
|
||||
@@ -280,6 +259,10 @@ const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// ✅ Debounce для поиска (300ms)
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('');
|
||||
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const MAP_FILE_URL = '/assets/maps/enterprise-map.tiff';
|
||||
|
||||
useEffect(() => {
|
||||
@@ -288,6 +271,26 @@ const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
// ✅ DEBOUNCE: обновляем debouncedSearchTerm с задержкой 300ms
|
||||
useEffect(() => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
}
|
||||
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
setDebouncedSearchTerm(searchTerm);
|
||||
debounceTimerRef.current = null;
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [searchTerm]);
|
||||
|
||||
const loadHandbook = async () => {
|
||||
setLoadingHandbook(true);
|
||||
setErrorHandbook(null);
|
||||
@@ -329,10 +332,10 @@ const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
|
||||
const container = contentRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const marks = container.querySelectorAll('mark[data-match-index]');
|
||||
const marks = container.querySelectorAll('mark');
|
||||
if (marks.length === 0) return;
|
||||
|
||||
const targetMark = Array.from(marks).find((el: Element) => el.getAttribute('data-match-index') === String(index));
|
||||
const targetMark = marks[index];
|
||||
if (!targetMark) return;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
@@ -629,7 +632,6 @@ const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ✅ БЕЛЫЙ ФОН, ТЁМНЫЙ ТЕКСТ */}
|
||||
<div
|
||||
ref={contentRef}
|
||||
style={{
|
||||
@@ -676,10 +678,8 @@ const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
|
||||
) : blocks.length > 0 ? (
|
||||
<HandbookRenderer
|
||||
blocks={blocks}
|
||||
searchTerm={searchTerm}
|
||||
onMatchCount={(count) => {
|
||||
setMatchCount(count);
|
||||
}}
|
||||
searchTerm={debouncedSearchTerm}
|
||||
onMatchCount={setMatchCount}
|
||||
/>
|
||||
) : (
|
||||
<p style={{ textAlign: 'center', color: '#b2bec3' }}>Нет данных для отображения</p>
|
||||
@@ -744,7 +744,6 @@ const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ✅ ТЁМНЫЙ ТЕКСТ ДЛЯ СПРАВОЧНИКА */}
|
||||
<style>
|
||||
{`
|
||||
@keyframes spin {
|
||||
@@ -800,10 +799,6 @@ const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
font-weight: 600;
|
||||
transition: outline 0.3s ease;
|
||||
}
|
||||
mark[data-match-index] {
|
||||
cursor: pointer;
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { FaArrowLeft, FaSpinner, FaEye, FaPrint, FaTimes, FaExpand, FaCompress, FaChevronUp, FaChevronDown, FaCheckCircle, FaExclamationCircle } from 'react-icons/fa';
|
||||
import config from '../config.json';
|
||||
|
||||
interface Employee {
|
||||
id: string;
|
||||
@@ -31,7 +34,22 @@ const EmployeeInfo: React.FC<EmployeeInfoProps> = ({
|
||||
return `${month}${year}`;
|
||||
});
|
||||
|
||||
// Генерируем список периодов за последние 12 месяцев
|
||||
const [showPdfPreview, setShowPdfPreview] = useState(false);
|
||||
const [pdfLoading, setPdfLoading] = useState(false);
|
||||
const [pdfError, setPdfError] = useState<string | null>(null);
|
||||
const [payslipUrl, setPayslipUrl] = useState<string | null>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
const [hasPayslip, setHasPayslip] = useState<boolean | null>(null);
|
||||
const [checkingPayslip, setCheckingPayslip] = useState(false);
|
||||
|
||||
const [isPrinting, setIsPrinting] = useState(false);
|
||||
const [printStatus, setPrintStatus] = useState<'idle' | 'success' | 'error' | 'sending'>('idle');
|
||||
const [printMessage, setPrintMessage] = useState('');
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
const generatePeriods = () => {
|
||||
const periods = [];
|
||||
const now = new Date();
|
||||
@@ -50,52 +68,677 @@ const EmployeeInfo: React.FC<EmployeeInfoProps> = ({
|
||||
const monthNames = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
|
||||
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'];
|
||||
|
||||
const getFullUrl = (url: string) => {
|
||||
return url.startsWith('http') ? url : `http://localhost:3001${url}`;
|
||||
};
|
||||
|
||||
const checkPayslip = async () => {
|
||||
if (!employee?.tnom) return;
|
||||
|
||||
try {
|
||||
setCheckingPayslip(true);
|
||||
const backendUrl = config.backendUrl || 'http://localhost:3001';
|
||||
const response = await fetch(`${backendUrl}/api/payslip/url/${employee.tnom}?period=${selectedPeriod}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setHasPayslip(data.exists);
|
||||
if (data.exists && data.url) {
|
||||
setPayslipUrl(data.url);
|
||||
}
|
||||
} else {
|
||||
setHasPayslip(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка проверки расчетного листка:', error);
|
||||
setHasPayslip(false);
|
||||
} finally {
|
||||
setCheckingPayslip(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkPayslip();
|
||||
}, [selectedPeriod, employee]);
|
||||
|
||||
const handlePreviewPdf = async () => {
|
||||
if (!employee?.tnom) return;
|
||||
|
||||
setShowPdfPreview(true);
|
||||
setPdfLoading(true);
|
||||
setPdfError(null);
|
||||
setIsFullscreen(false);
|
||||
|
||||
try {
|
||||
const backendUrl = config.backendUrl || 'http://localhost:3001';
|
||||
const response = await fetch(`${backendUrl}/api/payslip/url/${employee.tnom}?period=${selectedPeriod}`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.exists && data.url) {
|
||||
const fullUrl = data.url.startsWith('http') ? data.url : `${backendUrl}${data.url}`;
|
||||
setPayslipUrl(fullUrl);
|
||||
setPdfLoading(false);
|
||||
} else {
|
||||
setPdfError('Расчетный листок не найден');
|
||||
setPdfLoading(false);
|
||||
}
|
||||
} else {
|
||||
setPdfError('Ошибка при проверке расчетного листка');
|
||||
setPdfLoading(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки PDF:', error);
|
||||
setPdfError('Не удалось загрузить расчетный листок');
|
||||
setPdfLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClosePreview = () => {
|
||||
setShowPdfPreview(false);
|
||||
setPdfLoading(false);
|
||||
setPdfError(null);
|
||||
setIsFullscreen(false);
|
||||
};
|
||||
|
||||
const handlePrint = async () => {
|
||||
try {
|
||||
setIsPrinting(true);
|
||||
setPrintStatus('sending');
|
||||
setPrintMessage('Отправка документа на печать...');
|
||||
|
||||
const urlToPrint = payslipUrl ; // payslipUrl для Employee/Finance, fileUrl для Viewer
|
||||
if (!urlToPrint) throw new Error('URL документа отсутствует');
|
||||
|
||||
const fullUrl = urlToPrint.startsWith('http') ? urlToPrint : `http://localhost:3001${urlToPrint}`;
|
||||
// 🚀 ВАРИАНТ 1: Мы в десктопе (Electron)
|
||||
if ((window as any).electronAPI && (window as any).electronAPI.printPdf) {
|
||||
console.log('🖨️ [PRINT] Печатаем тихо через Electron API');
|
||||
await (window as any).electronAPI.printPdf({
|
||||
pdfUrl: fullUrl,
|
||||
deviceName: '' // <-- Оставляем пустым! Electron сам плюнет на дефолтный принтер
|
||||
});
|
||||
|
||||
setPrintStatus('success');
|
||||
setPrintMessage('✅ Отправлено на принтер');
|
||||
}
|
||||
// 🌐 ВАРИАНТ 2: Мы в браузере (Chrome/Edge)
|
||||
else {
|
||||
console.log('🖨️ [PRINT] Electron API нет. Обходим CORS и печатаем через браузер');
|
||||
|
||||
// Скачиваем PDF как Blob, чтобы сделать его "своим" для браузера
|
||||
const response = await fetch(fullUrl);
|
||||
if (!response.ok) throw new Error('Не удалось скачать PDF с сервера');
|
||||
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
// Создаем невидимый iframe
|
||||
const printIframe = document.createElement('iframe');
|
||||
printIframe.style.display = 'none';
|
||||
printIframe.src = blobUrl;
|
||||
document.body.appendChild(printIframe);
|
||||
|
||||
printIframe.onload = () => {
|
||||
try {
|
||||
printIframe.contentWindow?.focus();
|
||||
printIframe.contentWindow?.print();
|
||||
setPrintStatus('success');
|
||||
setPrintMessage('✅ Документ отправлен на печать');
|
||||
} catch (e) {
|
||||
console.error('Ошибка iframe печати:', e);
|
||||
throw new Error('Браузер заблокировал печать');
|
||||
} finally {
|
||||
// Чистим за собой мусор через 5 секунд
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(printIframe);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ [PRINT] Ошибка печати:', error);
|
||||
setPrintStatus('error');
|
||||
setPrintMessage('❌ Ошибка: ' + (error instanceof Error ? error.message : 'Неизвестная ошибка'));
|
||||
} finally {
|
||||
setIsPrinting(false);
|
||||
setTimeout(() => {
|
||||
setPrintStatus('idle');
|
||||
setPrintMessage('');
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
setIsFullscreen(!isFullscreen);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="employee-info">
|
||||
<div className="top-bar">
|
||||
<button className="back-button" onClick={onBack}>
|
||||
<>
|
||||
<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>
|
||||
|
||||
<div className="employee-header">
|
||||
<div className="employee-photo">
|
||||
<div className="photo-placeholder">Фото</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 className="employee-details">
|
||||
<h2>{`${employee.famaly} ${employee.ima} ${employee.otch}`}</h2>
|
||||
<p>Табельный номер: {employee.tnom}</p>
|
||||
{employee.category?.trim() && <p>Категория: {employee.category}</p>}
|
||||
{employee.departmentName && (
|
||||
<p>Подразделение: {employee.departmentName}</p>
|
||||
)}
|
||||
<p>Организация: {employee.organization}</p>
|
||||
{employee.profession && <p>Профессия: {employee.profession}</p>}
|
||||
<div style={{ fontSize: '14px', opacity: 0.9, marginTop: '5px' }}>
|
||||
Табельный номер: {employee.tnom}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="payslip-section">
|
||||
<h3>Расчётный лист</h3>
|
||||
<div className="period-selector">
|
||||
<label htmlFor="period-select">Выберите период:</label>
|
||||
<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
|
||||
id="period-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 => (
|
||||
{periods.map(period => {
|
||||
const month = parseInt(period.substring(0, 2)) - 1;
|
||||
const year = period.substring(2);
|
||||
return (
|
||||
<option key={period} value={period}>
|
||||
{monthNames[parseInt(period.substring(0, 2)) - 1]} {period.substring(2)}
|
||||
{monthNames[month]} {year}
|
||||
</option>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onClick={() => onViewPayslip(selectedPeriod)}>
|
||||
Посмотреть расчётный лист
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { FaArrowLeft, FaSpinner, FaEye, FaPrint, FaTimes, FaExpand, FaCompress, FaCheckCircle, FaExclamationCircle } from 'react-icons/fa';
|
||||
import config from '../config.json';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
interface FinanceProps {
|
||||
onBack: () => void;
|
||||
onViewPayslip: (period: string) => void;
|
||||
onViewPayslip: (fileUrl: string) => void;
|
||||
employee: any;
|
||||
}
|
||||
|
||||
interface FinancialIndicator {
|
||||
title: string;
|
||||
value: string;
|
||||
change?: number;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
const Finance: React.FC<FinanceProps> = ({ onBack, onViewPayslip, employee }) => {
|
||||
const [hasPayslip, setHasPayslip] = useState<boolean | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [payslipUrl, setPayslipUrl] = useState<string | null>(null);
|
||||
const [showPdfPreview, setShowPdfPreview] = useState(false);
|
||||
const [pdfLoading, setPdfLoading] = useState(false);
|
||||
const [pdfError, setPdfError] = useState<string | null>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
const [isPrinting, setIsPrinting] = useState(false);
|
||||
const [printStatus, setPrintStatus] = useState<'idle' | 'success' | 'error' | 'sending'>('idle');
|
||||
const [printMessage, setPrintMessage] = useState('');
|
||||
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
const [selectedPeriod, setSelectedPeriod] = useState(() => {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
@@ -22,13 +32,6 @@ const Finance: React.FC<FinanceProps> = ({ onBack, onViewPayslip, employee }) =>
|
||||
return `${month}${year}`;
|
||||
});
|
||||
|
||||
const [indicators] = useState<FinancialIndicator[]>([
|
||||
{ title: 'Средняя ЗП по предприятию', value: '1 850', unit: 'руб.' },
|
||||
{ title: 'Премия по итогам месяца', value: '15', unit: '%' },
|
||||
{ title: 'Материальная помощь к отпуску', value: '500', unit: 'руб.' },
|
||||
{ title: 'Выплаты к Новому году', value: '300', unit: 'руб.' },
|
||||
]);
|
||||
|
||||
const generatePeriods = () => {
|
||||
const periods = [];
|
||||
const now = new Date();
|
||||
@@ -40,49 +43,210 @@ const Finance: React.FC<FinanceProps> = ({ onBack, onViewPayslip, employee }) =>
|
||||
}
|
||||
return periods;
|
||||
};
|
||||
|
||||
const periods = generatePeriods();
|
||||
const monthNames = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
|
||||
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'];
|
||||
|
||||
const formatPeriod = (period: string) => {
|
||||
const month = parseInt(period.substring(0, 2)) - 1;
|
||||
const year = period.substring(2);
|
||||
return `${monthNames[month]} ${year}`;
|
||||
useEffect(() => {
|
||||
const checkPayslip = async () => {
|
||||
if (!employee?.tnom) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const backendUrl = config.backendUrl || 'http://localhost:3001';
|
||||
const response = await fetch(`${backendUrl}/api/payslip/url/${employee.tnom}?period=${selectedPeriod}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setHasPayslip(data.exists);
|
||||
if (data.exists && data.url) {
|
||||
setPayslipUrl(data.url);
|
||||
}
|
||||
} else {
|
||||
setHasPayslip(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка проверки расчетного листка:', error);
|
||||
setHasPayslip(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkPayslip();
|
||||
}, [employee, selectedPeriod]);
|
||||
|
||||
const handlePreviewPdf = () => {
|
||||
if (payslipUrl) {
|
||||
setShowPdfPreview(true);
|
||||
setPdfLoading(true);
|
||||
setPdfError(null);
|
||||
setIsFullscreen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClosePreview = () => {
|
||||
setShowPdfPreview(false);
|
||||
setPdfLoading(false);
|
||||
setPdfError(null);
|
||||
setIsFullscreen(false);
|
||||
};
|
||||
|
||||
const handlePrint = async () => {
|
||||
try {
|
||||
setIsPrinting(true);
|
||||
setPrintStatus('sending');
|
||||
setPrintMessage('Отправка документа на печать...');
|
||||
|
||||
const urlToPrint = payslipUrl; // payslipUrl для Employee/Finance, fileUrl для Viewer
|
||||
if (!urlToPrint) throw new Error('URL документа отсутствует');
|
||||
|
||||
const fullUrl = urlToPrint.startsWith('http') ? urlToPrint : `http://localhost:3001${urlToPrint}`;
|
||||
|
||||
if ((window as any).electronAPI && (window as any).electronAPI.printPdf) {
|
||||
console.log('🖨️ [PRINT] Печатаем тихо через Electron API');
|
||||
await (window as any).electronAPI.printPdf({
|
||||
pdfUrl: fullUrl,
|
||||
deviceName: '' // <-- Оставляем пустым! Electron сам плюнет на дефолтный принтер
|
||||
});
|
||||
|
||||
setPrintStatus('success');
|
||||
setPrintMessage('✅ Отправлено на принтер');
|
||||
}
|
||||
// 🌐 ВАРИАНТ 2: Мы в браузере (Chrome/Edge)
|
||||
else {
|
||||
console.log('🖨️ [PRINT] Electron API нет. Обходим CORS и печатаем через браузер');
|
||||
|
||||
// Скачиваем PDF как Blob, чтобы сделать его "своим" для браузера
|
||||
const response = await fetch(fullUrl);
|
||||
if (!response.ok) throw new Error('Не удалось скачать PDF с сервера');
|
||||
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
// Создаем невидимый iframe
|
||||
const printIframe = document.createElement('iframe');
|
||||
printIframe.style.display = 'none';
|
||||
printIframe.src = blobUrl;
|
||||
document.body.appendChild(printIframe);
|
||||
|
||||
printIframe.onload = () => {
|
||||
try {
|
||||
printIframe.contentWindow?.focus();
|
||||
printIframe.contentWindow?.print();
|
||||
setPrintStatus('success');
|
||||
setPrintMessage('✅ Документ отправлен на печать');
|
||||
} catch (e) {
|
||||
console.error('Ошибка iframe печати:', e);
|
||||
throw new Error('Браузер заблокировал печать');
|
||||
} finally {
|
||||
// Чистим за собой мусор через 5 секунд
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(printIframe);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ [PRINT] Ошибка печати:', error);
|
||||
setPrintStatus('error');
|
||||
setPrintMessage('❌ Ошибка: ' + (error instanceof Error ? error.message : 'Неизвестная ошибка'));
|
||||
} finally {
|
||||
setIsPrinting(false);
|
||||
setTimeout(() => {
|
||||
setPrintStatus('idle');
|
||||
setPrintMessage('');
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
setIsFullscreen(!isFullscreen);
|
||||
};
|
||||
|
||||
const getFullUrl = (url: string) => {
|
||||
return url.startsWith('http') ? url : `http://localhost:3001${url}`;
|
||||
};
|
||||
|
||||
const financialIndicators = [
|
||||
{ title: 'Средняя ЗП по предприятию', value: '1 850', unit: 'руб.' },
|
||||
{ title: 'Премия по итогам месяца', value: '15', unit: '%' },
|
||||
{ title: 'Материальная помощь к отпуску', value: '500', unit: 'руб.' },
|
||||
{ title: 'Выплаты к Новому году', value: '300', unit: 'руб.' },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="finance-section"
|
||||
style={{ padding: '20px', maxWidth: '1200px', margin: '0 auto', paddingBottom: '80px' }}
|
||||
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' }}>
|
||||
<div 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)'; }}
|
||||
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 style={{
|
||||
margin: 0,
|
||||
color: '#2d3436',
|
||||
fontSize: '24px',
|
||||
fontWeight: 300,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
}}>
|
||||
💰 Финансы
|
||||
</h2>
|
||||
<div style={{ width: '100px' }} />
|
||||
</div>
|
||||
|
||||
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} transition={{ delay: 0.1 }}
|
||||
style={{ background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', borderRadius: '16px', padding: '25px 30px', color: 'white', marginBottom: '30px', boxShadow: '0 10px 30px rgba(102, 126, 234, 0.3)' }}>
|
||||
<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 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>
|
||||
@@ -92,70 +256,454 @@ const Finance: React.FC<FinanceProps> = ({ onBack, onViewPayslip, employee }) =>
|
||||
</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, display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<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' }}>
|
||||
{indicators.map((item, index) => (
|
||||
<motion.div key={index} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 * index }}
|
||||
style={{ background: '#f8f9fa', borderRadius: '12px', padding: '16px 20px', border: '1px solid #f1f2f6' }}>
|
||||
{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>
|
||||
{item.change !== undefined && (
|
||||
<div style={{ fontSize: '12px', color: item.change >= 0 ? '#2ecc71' : '#e74c3c', marginTop: '4px' }}>
|
||||
{item.change >= 0 ? '↑' : '↓'} {Math.abs(item.change)}%
|
||||
</div>
|
||||
{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, display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
📄 Расчётный лист
|
||||
<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' }}>
|
||||
<div style={{ flex: 1, minWidth: '200px' }}>
|
||||
<label htmlFor="period-select" style={{ display: 'block', fontSize: '13px', color: '#636e72', marginBottom: '6px' }}>
|
||||
📅 Выберите период:
|
||||
|
||||
<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
|
||||
id="period-select"
|
||||
value={selectedPeriod}
|
||||
onChange={(e) => setSelectedPeriod(e.target.value)}
|
||||
style={{ width: '100%', padding: '12px 16px', border: '2px solid #dfe6e9', borderRadius: '10px', fontSize: '15px', background: 'white', cursor: 'pointer', outline: 'none', transition: 'border-color 0.3s ease' }}
|
||||
onFocus={(e) => e.target.style.borderColor = '#6c5ce7'}
|
||||
onBlur={(e) => e.target.style.borderColor = '#dfe6e9'}
|
||||
style={{
|
||||
padding: '8px 14px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #dfe6e9',
|
||||
fontSize: '14px',
|
||||
background: 'white',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{periods.map(period => (
|
||||
<option key={period} value={period}>{formatPeriod(period)}</option>
|
||||
))}
|
||||
{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={() => onViewPayslip(selectedPeriod)}
|
||||
style={{ padding: '12px 30px', background: 'linear-gradient(135deg, #6c5ce7, #a29bfe)', color: 'white', border: 'none', borderRadius: '10px', fontSize: '15px', fontWeight: 500, cursor: 'pointer', transition: 'all 0.3s ease', boxShadow: '0 4px 15px rgba(108, 92, 231, 0.3)', display: 'flex', alignItems: 'center', gap: '8px' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.transform = 'translateY(-2px)'; e.currentTarget.style.boxShadow = '0 6px 20px rgba(108, 92, 231, 0.4)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 4px 15px rgba(108, 92, 231, 0.3)'; }}
|
||||
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={{ marginTop: '20px', padding: '16px 20px', background: '#fff5f5', borderRadius: '10px', border: '1px solid #fde8e8' }}>
|
||||
<div style={{ fontSize: '13px', color: '#636e72', marginBottom: '5px' }}>💡 Информация о премии</div>
|
||||
<div style={{ fontSize: '14px', color: '#2d3436' }}>
|
||||
Премия по итогам работы за {formatPeriod(selectedPeriod)} составляет 15% от оклада.
|
||||
) : (
|
||||
<div 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>
|
||||
|
||||
<AnimatePresence>
|
||||
{printStatus !== 'idle' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -50, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -50, scale: 0.9 }}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '20px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 99999,
|
||||
padding: '16px 32px',
|
||||
borderRadius: '12px',
|
||||
background: printStatus === 'success' ? '#10b981' :
|
||||
printStatus === 'error' ? '#ef4444' :
|
||||
'#f59e0b',
|
||||
color: 'white',
|
||||
boxShadow: '0 10px 40px rgba(0,0,0,0.2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
fontSize: '16px',
|
||||
fontWeight: 500,
|
||||
minWidth: '300px',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{printStatus === 'sending' && <FaSpinner style={{ animation: 'spin 1s linear infinite' }} />}
|
||||
{printStatus === 'success' && <FaCheckCircle />}
|
||||
{printStatus === 'error' && <FaExclamationCircle />}
|
||||
{printMessage}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{showPdfPreview && payslipUrl && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.85)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
WebkitBackdropFilter: 'blur(8px)',
|
||||
zIndex: 9999,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '20px',
|
||||
}}
|
||||
onClick={handleClosePreview}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, y: 30, opacity: 0 }}
|
||||
animate={{ scale: 1, y: 0, opacity: 1 }}
|
||||
exit={{ scale: 0.9, y: 30, opacity: 0 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||
style={{
|
||||
background: '#1a1a2e',
|
||||
borderRadius: '24px',
|
||||
width: '100%',
|
||||
maxWidth: isFullscreen ? '100%' : '900px',
|
||||
height: isFullscreen ? '100%' : '90vh',
|
||||
maxHeight: isFullscreen ? '100%' : '90vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 50px 100px rgba(0,0,0,0.5)',
|
||||
position: 'relative',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{
|
||||
padding: '16px 24px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
minHeight: '60px',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<span style={{ fontSize: '20px' }}>📄</span>
|
||||
<span style={{ color: 'white', fontSize: '16px', fontWeight: 500 }}>
|
||||
Расчетный листок
|
||||
</span>
|
||||
<span style={{
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
fontSize: '12px',
|
||||
background: 'rgba(255,255,255,0.15)',
|
||||
padding: '2px 10px',
|
||||
borderRadius: '12px',
|
||||
}}>
|
||||
{employee?.tnom}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<button
|
||||
onClick={handlePrint}
|
||||
disabled={isPrinting}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '8px 16px',
|
||||
background: isPrinting ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.2)',
|
||||
color: 'white',
|
||||
border: '1px solid rgba(255,255,255,0.3)',
|
||||
borderRadius: '10px',
|
||||
cursor: isPrinting ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
transition: 'all 0.3s ease',
|
||||
backdropFilter: 'blur(4px)',
|
||||
opacity: isPrinting ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{isPrinting ? <FaSpinner style={{ animation: 'spin 1s linear infinite' }} /> : <FaPrint size={16} />}
|
||||
{isPrinting ? 'Отправка...' : 'Печать'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
background: 'rgba(255,255,255,0.2)',
|
||||
color: 'white',
|
||||
border: '1px solid rgba(255,255,255,0.3)',
|
||||
borderRadius: '10px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{isFullscreen ? <FaCompress size={16} /> : <FaExpand size={16} />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleClosePreview}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
background: 'rgba(255,255,255,0.2)',
|
||||
color: 'white',
|
||||
border: '1px solid rgba(255,255,255,0.3)',
|
||||
borderRadius: '10px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<FaTimes size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: '20px',
|
||||
overflow: 'auto',
|
||||
background: '#0a0a0a',
|
||||
position: 'relative',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
{pdfLoading && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '20px',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
border: '4px solid #2d2d2d',
|
||||
borderTop: '4px solid #667eea',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
}} />
|
||||
<span style={{ color: '#888', fontSize: '16px' }}>
|
||||
Загрузка документа...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={`${getFullUrl(payslipUrl)}#toolbar=0&navpanes=0&scrollbar=1&view=FitH`}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
minHeight: '600px',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
background: '#0a0a0a',
|
||||
display: pdfLoading ? 'none' : 'block',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
title="PDF Preview"
|
||||
onLoad={() => setPdfLoading(false)}
|
||||
onError={() => {
|
||||
setPdfLoading(false);
|
||||
setPdfError('Не удалось загрузить PDF');
|
||||
}}
|
||||
/>
|
||||
|
||||
{pdfError && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '15px',
|
||||
padding: '40px',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<div style={{ fontSize: '64px' }}>📄</div>
|
||||
<div style={{ fontSize: '18px', color: '#e74c3c', fontWeight: 500 }}>
|
||||
{pdfError}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setPdfError(null);
|
||||
setPdfLoading(true);
|
||||
const iframe = document.querySelector('iframe[title="PDF Preview"]') as HTMLIFrameElement;
|
||||
if (iframe) {
|
||||
iframe.src = `${getFullUrl(payslipUrl)}#toolbar=0&navpanes=0&scrollbar=1&view=FitH`;
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: '10px 30px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '10px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
🔄 Попробовать снова
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
padding: '12px 24px',
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
borderTop: '1px solid rgba(255,255,255,0.05)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
fontSize: '13px',
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
}}>
|
||||
<div>
|
||||
👤 {employee?.famaly} {employee?.ima}
|
||||
</div>
|
||||
<div>
|
||||
📋 Табельный номер: <strong style={{ color: 'rgba(255,255,255,0.7)' }}>{employee?.tnom}</strong>
|
||||
</div>
|
||||
<div>
|
||||
🏢 {employee?.organization}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ const MainMenu: React.FC<MainMenuProps> = ({ onSectionSelect, employee }) => {
|
||||
console.log('🔵 MainMenu компонент рендерится!');
|
||||
|
||||
const menuItems = [
|
||||
{ id: 'employee-info', emoji: '📋' }
|
||||
{ id: 'employee-info', emoji: '📋', description: 'Просмотр и печать' }
|
||||
];
|
||||
|
||||
const menuItems_2 = [
|
||||
@@ -29,97 +29,173 @@ const MainMenu: React.FC<MainMenuProps> = ({ onSectionSelect, employee }) => {
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '10px',
|
||||
marginTop: '-5vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
padding: '20px 20px',
|
||||
marginTop: '-20px',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
backgroundImage: 'url(/assets/images/background.jpg)',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}>
|
||||
{employee && (
|
||||
{/* ✅ Полупрозрачный оверлей для читаемости текста
|
||||
<div style={{
|
||||
color: '#fff',
|
||||
fontSize: '1.5rem',
|
||||
marginBottom: '10px',
|
||||
}}>
|
||||
Приветствуем Вас, {employee.ima} {employee.famaly}
|
||||
</div>
|
||||
)}
|
||||
<h1 style={{
|
||||
color: '#fff',
|
||||
fontSize: '2.5rem',
|
||||
marginBottom: '40px',
|
||||
fontWeight: 300,
|
||||
}}>
|
||||
Выберите раздел
|
||||
</h1>
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(232, 240, 254, 0.2)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
WebkitBackdropFilter: 'blur(4px)',
|
||||
zIndex: 0,
|
||||
}} /> */}
|
||||
|
||||
{/* ✅ Декоративные голубые размытые круги (поверх оверлея) */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '-25%',
|
||||
right: '-10%',
|
||||
width: '55%',
|
||||
height: '70%',
|
||||
background: 'radial-gradient(ellipse, rgba(66, 133, 244, 0.08) 0%, rgba(66, 133, 244, 0.02) 60%, transparent 80%)',
|
||||
borderRadius: '50%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
}} />
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '-20%',
|
||||
left: '-15%',
|
||||
width: '50%',
|
||||
height: '60%',
|
||||
background: 'radial-gradient(ellipse, rgba(52, 152, 219, 0.06) 0%, transparent 70%)',
|
||||
borderRadius: '50%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
}} />
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '30%',
|
||||
left: '25%',
|
||||
width: '40%',
|
||||
height: '50%',
|
||||
background: 'radial-gradient(ellipse, rgba(41, 128, 185, 0.04) 0%, transparent 70%)',
|
||||
borderRadius: '50%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
}} />
|
||||
|
||||
{/* ✅ КНОПКА "РАСЧЕТНЫЙ ЛИСТ" */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 870px))',
|
||||
gap: '25px',
|
||||
maxWidth: '900px',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 900px))',
|
||||
gap: '30px',
|
||||
maxWidth: '960px',
|
||||
width: '100%',
|
||||
//height: '100%',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
marginBottom: '20px',
|
||||
}}>
|
||||
|
||||
{menuItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onSectionSelect(item.id)}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #1b718b 0%, #7ec87b 100%)',
|
||||
borderRadius: '16px',
|
||||
padding: '25px 30px',
|
||||
marginBottom: '25px',
|
||||
background: 'linear-gradient(145deg, rgba(255,255,255,0.92) 0%, rgba(235, 245, 255, 0.92) 100%)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
WebkitBackdropFilter: 'blur(8px)',
|
||||
borderRadius: '28px',
|
||||
padding: '58px 36px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
boxShadow: '0 4px 20px rgba(198, 231, 212, 0.25)',
|
||||
display: 'inline-flex',
|
||||
transition: 'all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
boxShadow: '0 8px 32px rgba(66, 133, 244, 0.10), 0 2px 8px rgba(66, 133, 244, 0.04), inset 0 1px 0 rgba(255,255,255,0.9)',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'center',
|
||||
minHeight: '80px',
|
||||
fontSize: '6.0rem',
|
||||
minHeight: '110px',
|
||||
gap: '20px',
|
||||
border: '1px solid rgba(255,255,255,0.5)',
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1.05)';
|
||||
e.currentTarget.style.boxShadow = '0 12px 40px rgba(0,0,0,0.2)';
|
||||
e.currentTarget.style.transform = 'translateY(-4px) scale(1.01)';
|
||||
e.currentTarget.style.boxShadow = '0 24px 64px rgba(66, 133, 244, 0.18), 0 8px 24px rgba(66, 133, 244, 0.08), inset 0 1px 0 rgba(255,255,255,0.95)';
|
||||
e.currentTarget.style.borderColor = 'rgba(66, 133, 244, 0.2)';
|
||||
e.currentTarget.style.background = 'linear-gradient(145deg, rgba(255,255,255,0.96) 0%, rgba(235, 245, 255, 0.96) 100%)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
e.currentTarget.style.boxShadow = '0 8px 30px rgba(0,0,0,0.12)';
|
||||
e.currentTarget.style.transform = 'translateY(0) scale(1)';
|
||||
e.currentTarget.style.boxShadow = '0 8px 32px rgba(66, 133, 244, 0.10), 0 2px 8px rgba(66, 133, 244, 0.04), inset 0 1px 0 rgba(255,255,255,0.9)';
|
||||
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.5)';
|
||||
e.currentTarget.style.background = 'linear-gradient(145deg, rgba(255,255,255,0.92) 0%, rgba(235, 245, 255, 0.92) 100%)';
|
||||
}}
|
||||
>
|
||||
<div>{item.emoji}</div>
|
||||
<span style={{
|
||||
fontSize: '1rem',
|
||||
fontWeight: 500,
|
||||
color: '#2d3436',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<div>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '6px',
|
||||
background: 'linear-gradient(180deg, #4285f4 0%, #5b9bd5 50%, #7cb9e8 100%)',
|
||||
borderRadius: '0 3px 3px 0',
|
||||
opacity: 0.7,
|
||||
}} />
|
||||
|
||||
<div style={{
|
||||
fontSize: '4.5rem',
|
||||
marginLeft: '10px',
|
||||
filter: 'drop-shadow(0 4px 12px rgba(66, 133, 244, 0.08))',
|
||||
}}>{item.emoji}</div>
|
||||
|
||||
<div style={{ flex: 1, textAlign: 'left' }}>
|
||||
<h3 style={{
|
||||
color: 'white',
|
||||
fontSize: '25px',
|
||||
color: '#1a3a5c',
|
||||
fontSize: '32px',
|
||||
fontWeight: 600,
|
||||
margin: 0,
|
||||
letterSpacing: '-0.3px',
|
||||
}}>
|
||||
Расчетный лист
|
||||
</h3>
|
||||
</div>
|
||||
<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>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ✅ СЕТКА КНОПОК */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 200px))',
|
||||
gap: '15px',
|
||||
maxWidth: '900px',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(170px, 210px))',
|
||||
gap: '22px',
|
||||
maxWidth: '960px',
|
||||
width: '100%',
|
||||
//height: '100%',
|
||||
justifyContent: 'center',
|
||||
marginTop: '15px'
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}>
|
||||
|
||||
{menuItems_2.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
@@ -129,38 +205,108 @@ const MainMenu: React.FC<MainMenuProps> = ({ onSectionSelect, employee }) => {
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '12px',
|
||||
padding: '5px 5px',
|
||||
background: 'rgba(255,255,255,0.95)',
|
||||
border: 'none',
|
||||
borderRadius: '16px',
|
||||
gap: '14px',
|
||||
padding: '30px 16px 26px',
|
||||
background: 'rgba(255,255,255,0.85)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
WebkitBackdropFilter: 'blur(8px)',
|
||||
border: '1px solid rgba(255,255,255,0.5)',
|
||||
borderRadius: '24px',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 8px 30px rgba(0,0,0,0.12)',
|
||||
minHeight: '140px',
|
||||
fontSize: '3.5rem',
|
||||
transition: 'all 0.3s ease',
|
||||
boxShadow: '0 6px 24px rgba(66, 133, 244, 0.06), 0 2px 8px rgba(66, 133, 244, 0.03), inset 0 1px 0 rgba(255,255,255,0.9)',
|
||||
minHeight: '190px',
|
||||
fontSize: '4rem',
|
||||
transition: 'all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1.05)';
|
||||
e.currentTarget.style.boxShadow = '0 12px 40px rgba(0,0,0,0.2)';
|
||||
e.currentTarget.style.transform = 'translateY(-6px) scale(1.02)';
|
||||
e.currentTarget.style.boxShadow = '0 24px 64px rgba(66, 133, 244, 0.12), 0 8px 24px rgba(66, 133, 244, 0.06), inset 0 1px 0 rgba(255,255,255,0.95)';
|
||||
e.currentTarget.style.borderColor = 'rgba(66, 133, 244, 0.15)';
|
||||
e.currentTarget.style.background = 'rgba(255,255,255,0.92)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
e.currentTarget.style.boxShadow = '0 8px 30px rgba(0,0,0,0.12)';
|
||||
e.currentTarget.style.transform = 'translateY(0) scale(1)';
|
||||
e.currentTarget.style.boxShadow = '0 6px 24px rgba(66, 133, 244, 0.06), 0 2px 8px rgba(66, 133, 244, 0.03), inset 0 1px 0 rgba(255,255,255,0.9)';
|
||||
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.5)';
|
||||
e.currentTarget.style.background = 'rgba(255,255,255,0.85)';
|
||||
}}
|
||||
>
|
||||
<div>{item.emoji}</div>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: '15%',
|
||||
right: '15%',
|
||||
height: '3px',
|
||||
background: 'linear-gradient(90deg, transparent, rgba(66, 133, 244, 0.15), transparent)',
|
||||
borderRadius: '0 0 3px 3px',
|
||||
transition: 'all 0.4s ease',
|
||||
}} />
|
||||
|
||||
<div style={{
|
||||
fontSize: '4.2rem',
|
||||
filter: 'drop-shadow(0 4px 12px rgba(66, 133, 244, 0.04))',
|
||||
transition: 'all 0.3s ease',
|
||||
marginBottom: '2px',
|
||||
}}>
|
||||
{item.emoji}
|
||||
</div>
|
||||
|
||||
<span style={{
|
||||
fontSize: '1rem',
|
||||
display: 'block',
|
||||
whiteSpace: 'normal',
|
||||
wordWrap: 'break-word',
|
||||
fontSize: '18px',
|
||||
fontWeight: 500,
|
||||
color: '#2d3436',
|
||||
color: '#2a4a6a',
|
||||
textAlign: 'center',
|
||||
letterSpacing: '0.2px',
|
||||
lineHeight: 1.3,
|
||||
maxWidth: '100%',
|
||||
}}>
|
||||
{item.title}
|
||||
</span>
|
||||
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '8px',
|
||||
left: '25%',
|
||||
right: '25%',
|
||||
height: '2px',
|
||||
background: 'linear-gradient(90deg, transparent, rgba(66, 133, 244, 0.2), transparent)',
|
||||
borderRadius: '2px',
|
||||
opacity: 0,
|
||||
transition: 'all 0.4s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
const indicator = e.currentTarget.parentElement?.querySelector('div:last-child');
|
||||
if (indicator) {
|
||||
(indicator as HTMLElement).style.opacity = '1';
|
||||
(indicator as HTMLElement).style.background = 'linear-gradient(90deg, transparent, rgba(66, 133, 244, 0.3), transparent)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
const indicator = e.currentTarget.parentElement?.querySelector('div:last-child');
|
||||
if (indicator) {
|
||||
(indicator as HTMLElement).style.opacity = '0';
|
||||
(indicator as HTMLElement).style.background = 'linear-gradient(90deg, transparent, rgba(66, 133, 244, 0.2), transparent)';
|
||||
}
|
||||
}} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
marginTop: '30px',
|
||||
fontSize: '13px',
|
||||
color: 'rgba(66, 133, 244, 0.08)',
|
||||
letterSpacing: '2px',
|
||||
fontWeight: 300,
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}>
|
||||
⋆ ﹒ ˚ ﹒ ⋆ ﹒ ˚ ﹒ ⋆
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Document, Page, pdfjs } from 'react-pdf';
|
||||
|
||||
// ИСПОЛЬЗОВАТЬ CDN - ЭТО РАБОТАЕТ
|
||||
pdfjs.GlobalWorkerOptions.workerSrc =
|
||||
'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { FaArrowLeft, FaPrint, FaSpinner, FaCheckCircle, FaExclamationCircle } from 'react-icons/fa';
|
||||
|
||||
interface PayslipViewerProps {
|
||||
fileUrl: string;
|
||||
@@ -11,36 +8,228 @@ interface PayslipViewerProps {
|
||||
}
|
||||
|
||||
const PayslipViewer: React.FC<PayslipViewerProps> = ({ fileUrl, onBack }) => {
|
||||
const handlePrint = () => {
|
||||
const win = window.open(fileUrl, '_blank');
|
||||
if (win) {
|
||||
win.focus();
|
||||
win.print();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const fullUrl = fileUrl.startsWith('http') ? fileUrl : `http://localhost:3001${fileUrl}`;
|
||||
|
||||
const [isPrinting, setIsPrinting] = useState(false);
|
||||
const [printStatus, setPrintStatus] = useState<'idle' | 'success' | 'error' | 'sending'>('idle');
|
||||
const [printMessage, setPrintMessage] = useState('');
|
||||
|
||||
// ✅ ИСПРАВЛЕННЫЙ МЕТОД ПЕЧАТИ
|
||||
const handlePrint = async () => {
|
||||
try {
|
||||
setIsPrinting(true);
|
||||
setPrintStatus('sending');
|
||||
setPrintMessage('Отправка документа на печать...');
|
||||
|
||||
const urlToPrint = fileUrl; // payslipUrl для Employee/Finance, fileUrl для Viewer
|
||||
if (!urlToPrint) throw new Error('URL документа отсутствует');
|
||||
|
||||
const fullUrl = urlToPrint.startsWith('http') ? urlToPrint : `http://localhost:3001${urlToPrint}`;
|
||||
|
||||
// 🚀 ВАРИАНТ 1: Мы в десктопе (Electron)
|
||||
if ((window as any).electronAPI && (window as any).electronAPI.printPdf) {
|
||||
console.log('🖨️ [PRINT] Печатаем тихо через Electron API');
|
||||
await (window as any).electronAPI.printPdf({
|
||||
pdfUrl: fullUrl,
|
||||
deviceName: '' // <-- Оставляем пустым! Electron сам плюнет на дефолтный принтер
|
||||
});
|
||||
|
||||
setPrintStatus('success');
|
||||
setPrintMessage('✅ Отправлено на принтер');
|
||||
}
|
||||
// 🌐 ВАРИАНТ 2: Мы в браузере (Chrome/Edge)
|
||||
else {
|
||||
console.log('🖨️ [PRINT] Electron API нет. Обходим CORS и печатаем через браузер');
|
||||
|
||||
// Скачиваем PDF как Blob, чтобы сделать его "своим" для браузера
|
||||
const response = await fetch(fullUrl);
|
||||
if (!response.ok) throw new Error('Не удалось скачать PDF с сервера');
|
||||
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
// Создаем невидимый iframe
|
||||
const printIframe = document.createElement('iframe');
|
||||
printIframe.style.display = 'none';
|
||||
printIframe.src = blobUrl;
|
||||
document.body.appendChild(printIframe);
|
||||
|
||||
printIframe.onload = () => {
|
||||
try {
|
||||
printIframe.contentWindow?.focus();
|
||||
printIframe.contentWindow?.print();
|
||||
setPrintStatus('success');
|
||||
setPrintMessage('✅ Документ отправлен на печать');
|
||||
} catch (e) {
|
||||
console.error('Ошибка iframe печати:', e);
|
||||
throw new Error('Браузер заблокировал печать');
|
||||
} finally {
|
||||
// Чистим за собой мусор через 5 секунд
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(printIframe);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ [PRINT] Ошибка печати:', error);
|
||||
setPrintStatus('error');
|
||||
setPrintMessage('❌ Ошибка: ' + (error instanceof Error ? error.message : 'Неизвестная ошибка'));
|
||||
} finally {
|
||||
setIsPrinting(false);
|
||||
setTimeout(() => {
|
||||
setPrintStatus('idle');
|
||||
setPrintMessage('');
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="payslip-viewer">
|
||||
<div className="top-bar">
|
||||
<button className="back-button" onClick={onBack}>
|
||||
← Назад
|
||||
</button>
|
||||
<button className="print-button" onClick={handlePrint}>
|
||||
🖨️ Печать
|
||||
</button>
|
||||
</div>
|
||||
<div className="pdf-container" style={{
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
style={{
|
||||
padding: '20px',
|
||||
maxWidth: '1200px',
|
||||
margin: '0 auto',
|
||||
height: 'calc(100vh - 80px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#f5f6fa',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{printStatus !== 'idle' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -50, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -50, scale: 0.9 }}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '20px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 9999,
|
||||
padding: '16px 32px',
|
||||
borderRadius: '12px',
|
||||
background: printStatus === 'success' ? '#10b981' :
|
||||
printStatus === 'error' ? '#ef4444' :
|
||||
'#f59e0b',
|
||||
color: 'white',
|
||||
boxShadow: '0 10px 40px rgba(0,0,0,0.2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
fontSize: '16px',
|
||||
fontWeight: 500,
|
||||
minWidth: '300px',
|
||||
justifyContent: 'center',
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
{printStatus === 'sending' && <FaSpinner style={{ animation: 'spin 1s linear infinite' }} />}
|
||||
{printStatus === 'success' && <FaCheckCircle />}
|
||||
{printStatus === 'error' && <FaExclamationCircle />}
|
||||
{printMessage}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '20px',
|
||||
flexShrink: 0,
|
||||
padding: '0 4px',
|
||||
}}>
|
||||
<Document file={fileUrl} loading="Загрузка PDF...">
|
||||
<div style={{ margin: '0 auto' }}>
|
||||
<Page pageNumber={1} />
|
||||
</div>
|
||||
</Document>
|
||||
<button
|
||||
onClick={onBack}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '10px 20px',
|
||||
background: '#6c757d',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
>
|
||||
<FaArrowLeft />
|
||||
Назад
|
||||
</button>
|
||||
|
||||
<h2 style={{
|
||||
margin: 0,
|
||||
color: '#2d3436',
|
||||
fontSize: '22px',
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
📄 Расчетный листок
|
||||
</h2>
|
||||
|
||||
<button
|
||||
onClick={handlePrint}
|
||||
disabled={isPrinting}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '10px 30px',
|
||||
background: isPrinting ? '#b2bec3' : 'linear-gradient(135deg, #00b894, #00cec9)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '10px',
|
||||
cursor: isPrinting ? 'not-allowed' : 'pointer',
|
||||
fontSize: '15px',
|
||||
fontWeight: 500,
|
||||
transition: 'all 0.3s ease',
|
||||
boxShadow: isPrinting ? 'none' : '0 4px 15px rgba(0, 206, 201, 0.3)',
|
||||
opacity: isPrinting ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{isPrinting ? <FaSpinner style={{ animation: 'spin 1s linear infinite' }} /> : <FaPrint />}
|
||||
{isPrinting ? 'Отправка...' : 'Печать'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
flex: 1,
|
||||
background: '#f5f6fa',
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
minHeight: 0,
|
||||
border: 'none',
|
||||
}}>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={`${fullUrl}#toolbar=0&navpanes=0&scrollbar=1&view=FitH`}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
display: 'block',
|
||||
background: '#f5f6fa',
|
||||
}}
|
||||
title="PDF Viewer"
|
||||
/>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,49 +1,197 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
|
||||
|
||||
interface VideoBackgroundProps {
|
||||
onComplete: () => void;
|
||||
videoSrc?: string;
|
||||
}
|
||||
|
||||
interface Slide {
|
||||
id: number;
|
||||
image_url: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
link?: string;
|
||||
order: number;
|
||||
is_active: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
const VideoBackground: React.FC<VideoBackgroundProps> = ({
|
||||
onComplete,
|
||||
videoSrc = '/assets/videos/flag.mp4'
|
||||
}) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [slides, setSlides] = useState<Slide[]>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAutoPlay, setIsAutoPlay] = useState(true);
|
||||
const autoPlayInterval = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const ADMIN_SERVER_URL = 'http://localhost:3005';
|
||||
|
||||
// ✅ Загрузка слайдов с сервера модератора
|
||||
useEffect(() => {
|
||||
const fetchSlides = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`${ADMIN_SERVER_URL}/moders/content/slides`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const sortedSlides = Array.isArray(data)
|
||||
? data.sort((a: Slide, b: Slide) => (a.order || 0) - (b.order || 0))
|
||||
: [];
|
||||
setSlides(sortedSlides);
|
||||
} else {
|
||||
console.warn('Не удалось загрузить слайды, используем тестовые');
|
||||
setSlides(getDefaultSlides());
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Ошибка загрузки слайдов:', error);
|
||||
setSlides(getDefaultSlides());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSlides();
|
||||
}, []);
|
||||
|
||||
// ✅ Тестовые слайды (если модератор не настроен)
|
||||
const getDefaultSlides = (): Slide[] => {
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
image_url: 'https://picsum.photos/1920/1080?random=1',
|
||||
title: 'Добро пожаловать!',
|
||||
description: 'Инфокиоск для сотрудников',
|
||||
order: 1,
|
||||
is_active: 1
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
image_url: 'https://picsum.photos/1920/1080?random=2',
|
||||
title: 'Новости предприятия',
|
||||
description: 'Будьте в курсе событий',
|
||||
order: 2,
|
||||
is_active: 1
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
image_url: 'https://picsum.photos/1920/1080?random=3',
|
||||
title: 'С днем рождения!',
|
||||
description: 'Поздравляем именинников',
|
||||
order: 3,
|
||||
is_active: 1
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
// ✅ Видео плеер
|
||||
useEffect(() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.play().catch(err => {
|
||||
console.warn('Видео не воспроизвелось автоматически:', err);
|
||||
// Если видео не запустилось — показываем экран логина через 5 секунд
|
||||
setTimeout(onComplete, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
// ❌ Убираем таймер, который закрывал видео через 30 секунд
|
||||
// Теперь видео будет крутиться бесконечно (loop=true)
|
||||
// Только клик или касание пользователя завершают видео
|
||||
|
||||
return () => {
|
||||
// Очистка не нужна, так как таймера больше нет
|
||||
};
|
||||
}, [onComplete]);
|
||||
|
||||
// ❌ Убираем handleVideoEnd — не нужен, так как loop=true
|
||||
// ✅ Автопрокрутка слайдов
|
||||
useEffect(() => {
|
||||
if (slides.length <= 1 || !isAutoPlay) return;
|
||||
|
||||
autoPlayInterval.current = setInterval(() => {
|
||||
goToNext();
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
if (autoPlayInterval.current) {
|
||||
clearInterval(autoPlayInterval.current);
|
||||
}
|
||||
};
|
||||
}, [slides.length, isAutoPlay, currentIndex]);
|
||||
|
||||
// ✅ Навигация
|
||||
const goToNext = () => {
|
||||
setCurrentIndex((prev) => (prev + 1) % slides.length);
|
||||
};
|
||||
|
||||
const goToPrev = () => {
|
||||
setCurrentIndex((prev) => (prev - 1 + slides.length) % slides.length);
|
||||
};
|
||||
|
||||
const goToSlide = (index: number) => {
|
||||
setCurrentIndex(index);
|
||||
resetAutoPlay();
|
||||
};
|
||||
|
||||
const resetAutoPlay = () => {
|
||||
if (autoPlayInterval.current) {
|
||||
clearInterval(autoPlayInterval.current);
|
||||
}
|
||||
if (slides.length > 1) {
|
||||
autoPlayInterval.current = setInterval(() => {
|
||||
goToNext();
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setIsAutoPlay(false);
|
||||
if (autoPlayInterval.current) {
|
||||
clearInterval(autoPlayInterval.current);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setIsAutoPlay(true);
|
||||
resetAutoPlay();
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
onComplete(); // По клику переходим дальше
|
||||
onComplete();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 9999,
|
||||
background: '#000',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
border: '4px solid #2d2d2d',
|
||||
borderTop: '4px solid #667eea',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasSlides = slides.length > 0;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="video-splash"
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
@@ -56,34 +204,264 @@ const VideoBackground: React.FC<VideoBackgroundProps> = ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* ✅ ВИДЕО (фон) */}
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoSrc}
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
loop // ✅ Бесконечное воспроизведение
|
||||
loop
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ✅ ТЕМНЫЙ ОВЕРЛЕЙ */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '60px',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.35)',
|
||||
zIndex: 1,
|
||||
}} />
|
||||
|
||||
{/* ✅ КАРУСЕЛЬ ИЗОБРАЖЕНИЙ */}
|
||||
{hasSlides && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentIndex}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
style={{
|
||||
width: '80%',
|
||||
maxWidth: '900px',
|
||||
maxHeight: '70vh',
|
||||
borderRadius: '20px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 30px 80px rgba(0,0,0,0.5)',
|
||||
position: 'relative',
|
||||
background: '#1a1a2e',
|
||||
border: '1px solid rgba(255,255,255,0.05)',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`${ADMIN_SERVER_URL}${slides[currentIndex].image_url}`}
|
||||
alt={slides[currentIndex].title || 'Слайд'}
|
||||
crossOrigin="anonymous"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
maxHeight: '70vh',
|
||||
objectFit: 'cover',
|
||||
display: 'block',
|
||||
}}
|
||||
onError={(e) => {
|
||||
console.error('❌ Ошибка загрузки:', `${ADMIN_SERVER_URL}${slides[currentIndex].image_url}`);
|
||||
(e.target as HTMLImageElement).src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="100%25" height="100%25"%3E%3Crect width="100%25" height="100%25" fill="%232d3436"/%3E%3Ctext x="50%25" y="50%25" text-anchor="middle" dy=".3em" fill="%23636e72" font-size="24" font-family="sans-serif"%3EИзображение не найдено%3C/text%3E%3C/svg%3E';
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ✅ Текст поверх изображения */}
|
||||
{(slides[currentIndex].title || slides[currentIndex].description) && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: '30px 40px 40px',
|
||||
background: 'linear-gradient(transparent, rgba(0,0,0,0.7))',
|
||||
color: 'white',
|
||||
}}>
|
||||
{slides[currentIndex].title && (
|
||||
<h2 style={{
|
||||
margin: 0,
|
||||
fontSize: '28px',
|
||||
fontWeight: 600,
|
||||
textShadow: '0 2px 10px rgba(0,0,0,0.3)',
|
||||
}}>
|
||||
{slides[currentIndex].title}
|
||||
</h2>
|
||||
)}
|
||||
{slides[currentIndex].description && (
|
||||
<p style={{
|
||||
margin: '8px 0 0',
|
||||
fontSize: '16px',
|
||||
opacity: 0.8,
|
||||
textShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
||||
}}>
|
||||
{slides[currentIndex].description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* ✅ КНОПКИ НАВИГАЦИИ
|
||||
{slides.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goToPrev();
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '20px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
width: '50px',
|
||||
height: '50px',
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '20px',
|
||||
transition: 'all 0.3s ease',
|
||||
zIndex: 10,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255,255,255,0.2)';
|
||||
e.currentTarget.style.transform = 'translateY(-50%) scale(1.1)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255,255,255,0.1)';
|
||||
e.currentTarget.style.transform = 'translateY(-50%) scale(1)';
|
||||
}}
|
||||
>
|
||||
<FaChevronLeft />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goToNext();
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '20px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
width: '50px',
|
||||
height: '50px',
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '20px',
|
||||
transition: 'all 0.3s ease',
|
||||
zIndex: 10,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255,255,255,0.2)';
|
||||
e.currentTarget.style.transform = 'translateY(-50%) scale(1.1)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255,255,255,0.1)';
|
||||
e.currentTarget.style.transform = 'translateY(-50%) scale(1)';
|
||||
}}
|
||||
>
|
||||
<FaChevronRight />
|
||||
</button>
|
||||
</>
|
||||
)} */}
|
||||
|
||||
{/* ✅ ИНДИКАТОРЫ СЛАЙДОВ */}
|
||||
{slides.length > 1 && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '30px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
zIndex: 10,
|
||||
}}>
|
||||
{slides.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goToSlide(index);
|
||||
}}
|
||||
style={{
|
||||
width: index === currentIndex ? '12px' : '8px',
|
||||
height: '8px',
|
||||
borderRadius: '4px',
|
||||
background: index === currentIndex
|
||||
? 'rgba(255,255,255,0.9)'
|
||||
: 'rgba(255,255,255,0.3)',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
padding: 0,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ✅ ПОДСКАЗКА */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '20px',
|
||||
left: 0,
|
||||
right: 0,
|
||||
textAlign: 'center',
|
||||
color: '#fff',
|
||||
fontSize: '14px',
|
||||
opacity: 0.6,
|
||||
color: 'rgba(255,255,255,0.3)',
|
||||
fontSize: '13px',
|
||||
zIndex: 3,
|
||||
textShadow: '0 2px 4px rgba(0,0,0,0.5)',
|
||||
letterSpacing: '1px',
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
Нажмите для продолжения
|
||||
{hasSlides ? 'Нажмите для входа · Для просмотра используйте стрелки' : 'Нажмите для продолжения'}
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface WeatherDay {
|
||||
date: string;
|
||||
@@ -12,9 +12,13 @@ interface WeatherDay {
|
||||
|
||||
interface WeatherWidgetProps {
|
||||
onLogout?: () => void;
|
||||
employee?: {
|
||||
ima: string;
|
||||
famaly: string;
|
||||
};
|
||||
}
|
||||
|
||||
const WeatherWidget: React.FC<WeatherWidgetProps> = ({ onLogout }) => {
|
||||
const WeatherWidget: React.FC<WeatherWidgetProps> = ({ onLogout, employee }) => {
|
||||
const [weatherData, setWeatherData] = useState<WeatherDay[]>([]);
|
||||
const [currentWeather, setCurrentWeather] = useState<WeatherDay | null>(null);
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine);
|
||||
@@ -57,7 +61,6 @@ const WeatherWidget: React.FC<WeatherWidgetProps> = ({ onLogout }) => {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
// fallback
|
||||
setCurrentWeather({
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
dayOfWeek: 'Сегодня',
|
||||
@@ -160,6 +163,20 @@ const WeatherWidget: React.FC<WeatherWidgetProps> = ({ onLogout }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const forecastDays = weatherData.slice(1, 7);
|
||||
const doubledForecast = [...forecastDays, ...forecastDays, ...forecastDays];
|
||||
|
||||
const getGreeting = () => {
|
||||
if (!employee) return '';
|
||||
const hour = currentTime.getHours();
|
||||
let greeting = '';
|
||||
if (hour >= 5 && hour < 12) greeting = 'Доброе утро';
|
||||
else if (hour >= 12 && hour < 17) greeting = 'Добрый день';
|
||||
else if (hour >= 17 && hour < 22) greeting = 'Добрый вечер';
|
||||
else greeting = 'Доброй ночи';
|
||||
return `${greeting}, ${employee.ima}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ y: -100, opacity: 0 }}
|
||||
@@ -171,37 +188,42 @@ const WeatherWidget: React.FC<WeatherWidgetProps> = ({ onLogout }) => {
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 9999,
|
||||
background: 'rgba(44, 62, 80, 0.75)', // ✅ полупрозрачный
|
||||
backdropFilter: 'blur(12px)',
|
||||
padding: '5px 15px',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.08)',
|
||||
background: 'linear-gradient(135deg, #1b718b 65%, #473f72 100%)',
|
||||
backdropFilter: 'blur(16px)',
|
||||
WebkitBackdropFilter: 'blur(16px)',
|
||||
padding: '6px 20px',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.06)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
minHeight: '70px',
|
||||
boxShadow: '0 2px 20px rgba(0,0,0,0.15)',
|
||||
minHeight: '75px',
|
||||
gap: '8px',
|
||||
flexWrap: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{/* Левая часть: Время */}
|
||||
{/* ===== ЛЕВАЯ ЧАСТЬ: ВРЕМЯ + ПОГОДА СЕГОДНЯ ===== */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
gap: '16px',
|
||||
flexShrink: 0,
|
||||
minWidth: '140px',
|
||||
zIndex: 2,
|
||||
}}>
|
||||
{/* Время */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
minWidth: '90px',
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: '30px',
|
||||
fontSize: '38px',
|
||||
fontWeight: 700,
|
||||
color: '#fff',
|
||||
letterSpacing: '0.5px',
|
||||
lineHeight: 1,
|
||||
lineHeight: 1.1,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
textShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
}}>
|
||||
{currentTime.toLocaleString('ru-RU', {
|
||||
timeZone: 'Europe/Minsk',
|
||||
@@ -211,10 +233,11 @@ const WeatherWidget: React.FC<WeatherWidgetProps> = ({ onLogout }) => {
|
||||
})}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
fontSize: '11px',
|
||||
color: 'rgba(255,255,255,0.4)',
|
||||
letterSpacing: '0.3px',
|
||||
fontWeight: 400,
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
{currentTime.toLocaleString('ru-RU', {
|
||||
timeZone: 'Europe/Minsk',
|
||||
@@ -224,43 +247,36 @@ const WeatherWidget: React.FC<WeatherWidgetProps> = ({ onLogout }) => {
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Центральная часть: Погода */}
|
||||
{/* Разделитель */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0 16px',
|
||||
minHeight: '56px',
|
||||
}}>
|
||||
<AnimatePresence mode="wait">
|
||||
{isOnline && !loading && currentWeather ? (
|
||||
width: '1px',
|
||||
height: '32px',
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
|
||||
{/* Погода сегодня */}
|
||||
{isOnline && !loading && currentWeather && (
|
||||
<motion.div
|
||||
key="weather"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
padding: '4px 16px 4px 10px',
|
||||
borderRadius: '30px',
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
}}>
|
||||
<span style={{ fontSize: '32px', lineHeight: 1 }}>
|
||||
<span style={{ fontSize: '26px', lineHeight: 1 }}>
|
||||
{getWeatherIcon(currentWeather.icon)}
|
||||
</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', lineHeight: 1.2 }}>
|
||||
<span style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 600,
|
||||
@@ -269,83 +285,142 @@ const WeatherWidget: React.FC<WeatherWidgetProps> = ({ onLogout }) => {
|
||||
{Math.round(currentWeather.tempMax)}°
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
fontSize: '10px',
|
||||
color: 'rgba(255,255,255,0.35)',
|
||||
textTransform: 'capitalize',
|
||||
}}>
|
||||
{currentWeather.description}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ===== ЦЕНТРАЛЬНАЯ ЧАСТЬ: ПРИВЕТСТВИЕ ===== */}
|
||||
<div style={{
|
||||
flex: '1 1 0%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{employee && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
style={{
|
||||
fontSize: 'clamp(18px, 1.6vw, 28px)',
|
||||
fontWeight: 500,
|
||||
color: 'rgba(255,255,255,0.92)',
|
||||
textAlign: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
letterSpacing: '0.8px',
|
||||
textShadow: '0 2px 12px rgba(0,0,0,0.15)',
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
padding: '4px 24px',
|
||||
borderRadius: '30px',
|
||||
border: '1px solid rgba(255,255,255,0.04)',
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{getGreeting()}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ===== ПРАВАЯ ЧАСТЬ: ПРОГНОЗ С ПРОКРУТКОЙ ===== */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
flexShrink: 0,
|
||||
maxWidth: '380px',
|
||||
minWidth: '140px',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
maskImage: 'linear-gradient(to right, transparent, black 3%, black 97%, transparent)',
|
||||
WebkitMaskImage: 'linear-gradient(to right, transparent, black 3%, black 97%, transparent)',
|
||||
}}>
|
||||
{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 }}
|
||||
animate={{
|
||||
x: ['0%', '-33.33%'],
|
||||
}}
|
||||
transition={{
|
||||
duration: 20,
|
||||
repeat: Infinity,
|
||||
ease: 'linear',
|
||||
repeatType: 'loop',
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '14px',
|
||||
whiteSpace: 'nowrap',
|
||||
willChange: 'transform',
|
||||
transform: 'translateZ(0)',
|
||||
backfaceVisibility: 'hidden',
|
||||
WebkitFontSmoothing: 'antialiased',
|
||||
MozOsxFontSmoothing: 'grayscale',
|
||||
textRendering: 'geometricPrecision',
|
||||
paddingRight: '14px',
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
gap: '2px',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
>
|
||||
{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)',
|
||||
minWidth: '40px',
|
||||
border: '1px solid rgba(255,255,255,0.03)',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
fontSize: '9px',
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
fontSize: '10px',
|
||||
color: 'rgba(255,255,255,0.4)',
|
||||
fontWeight: 500,
|
||||
letterSpacing: '0.3px',
|
||||
minWidth: '20px',
|
||||
}}>
|
||||
{day.dayOfWeek}
|
||||
</span>
|
||||
<span style={{ fontSize: '16px', lineHeight: 1 }}>
|
||||
<span style={{ fontSize: '18px', lineHeight: 1 }}>
|
||||
{getWeatherIcon(day.icon)}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '10px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
minWidth: '24px',
|
||||
textAlign: 'right',
|
||||
}}>
|
||||
{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 style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
flexShrink: 0,
|
||||
minWidth: '80px',
|
||||
justifyContent: 'flex-end',
|
||||
marginLeft: '4px',
|
||||
zIndex: 2,
|
||||
}}>
|
||||
{onLogout && (
|
||||
<motion.button
|
||||
@@ -353,38 +428,34 @@ const WeatherWidget: React.FC<WeatherWidgetProps> = ({ onLogout }) => {
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={handleLogout}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
background: 'rgba(231, 76, 60, 0.7)',
|
||||
padding: '6px 18px',
|
||||
background: 'rgba(231, 76, 60, 0.5)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
borderRadius: '30px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
fontSize: '16px',
|
||||
fontWeight: 500,
|
||||
transition: 'all 0.2s',
|
||||
transition: 'all 0.25s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
minHeight: '34px',
|
||||
minHeight: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '6px',
|
||||
backdropFilter: 'blur(4px)',
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
borderColor: 'rgba(255,255,255,0.1)',
|
||||
boxShadow: '0 2px 10px rgba(231, 76, 60, 0.2)',
|
||||
boxShadow: '0 2px 12px rgba(231, 76, 60, 0.1)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(231, 76, 60, 1)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 20px rgba(231, 76, 60, 0.4)';
|
||||
e.currentTarget.style.background = 'rgba(231, 76, 60, 0.8)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 20px rgba(231, 76, 60, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(231, 76, 60, 0.7)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 10px rgba(231, 76, 60, 0.2)';
|
||||
e.currentTarget.style.background = 'rgba(231, 76, 60, 0.5)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 12px rgba(231, 76, 60, 0.1)';
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '16px' }}>⏻</span>
|
||||
Выйти
|
||||
<span style={{ display: 'inline', padding: '5px 10px' }}>ВЫЙТИ</span>
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1 +1,15 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI?: {
|
||||
getAppConfig: () => Promise<any>;
|
||||
onCardDetected: (callback: (uid: string) => void) => void;
|
||||
removeCardDetectedListeners: () => void;
|
||||
printPdf: (options?: { pdfUrl?: string; deviceName?: string }) => Promise<boolean>;
|
||||
getPrinters: () => Promise<any[]>;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.test.tsx","./src/app.tsx","./src/config.js","./src/global.d.ts","./src/index.tsx","./src/r5usb.js","./src/r5usb_example.js","./src/react-app-env.d.ts","./src/reportwebvitals.ts","./src/setuptests.ts","./src/components/bottombar.tsx","./src/components/contacts.tsx","./src/components/employeeinfo.tsx","./src/components/finance.tsx","./src/components/guestmode.tsx","./src/components/laborsafety.tsx","./src/components/loginwindow.tsx","./src/components/mapviewer.tsx","./src/components/medicine.tsx","./src/components/menucomponent.tsx","./src/components/news.tsx","./src/components/payslipviewer.tsx","./src/components/profile.tsx","./src/components/tradeunion.tsx","./src/components/videobackground.tsx","./src/components/useactivitytimer.ts","./vite.config.js"],"version":"5.9.3"}
|
||||
{"root":["./src/app.test.tsx","./src/app.tsx","./src/config.js","./src/global.d.ts","./src/index.tsx","./src/r5usb.js","./src/r5usb_example.js","./src/react-app-env.d.ts","./src/reportwebvitals.ts","./src/setuptests.ts","./src/components/bottombar.tsx","./src/components/contacts.tsx","./src/components/employeeinfo.tsx","./src/components/finance.tsx","./src/components/guestmode.tsx","./src/components/laborsafety.tsx","./src/components/loginwindow.tsx","./src/components/mapviewer.tsx","./src/components/medicine.tsx","./src/components/menucomponent.tsx","./src/components/news.tsx","./src/components/payslipviewer.tsx","./src/components/profile.tsx","./src/components/tradeunion.tsx","./src/components/videobackground.tsx","./src/components/weatherwidget.tsx","./src/components/useactivitytimer.ts","./vite.config.js"],"version":"5.9.3"}
|
||||