print_add
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|||||||
import 'quill/dist/quill.snow.css';
|
import 'quill/dist/quill.snow.css';
|
||||||
import '../App.css';
|
import '../App.css';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
|
import SlidesManager from './SlidesManager';
|
||||||
|
|
||||||
function Editor() {
|
function Editor() {
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
@@ -15,7 +16,6 @@ function Editor() {
|
|||||||
const [editContent, setEditContent] = useState('');
|
const [editContent, setEditContent] = useState('');
|
||||||
const [editPreviewContent, setEditPreviewContent] = useState('');
|
const [editPreviewContent, setEditPreviewContent] = useState('');
|
||||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||||
//const [csrfToken, setCsrfToken] = useState('');
|
|
||||||
const [notify, setNotify] = useState({ message: '', type: '', show: false });
|
const [notify, setNotify] = useState({ message: '', type: '', show: false });
|
||||||
|
|
||||||
const editorRef = useRef(null);
|
const editorRef = useRef(null);
|
||||||
@@ -29,17 +29,6 @@ function Editor() {
|
|||||||
setNotify({ message: '', type: '', show: false });
|
setNotify({ message: '', type: '', show: false });
|
||||||
}, 2200);
|
}, 2200);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
// Fetch CSRF token
|
|
||||||
//useEffect(() => {
|
|
||||||
// fetch('/moders/csrf-token')
|
|
||||||
// .then(res => res.json())
|
|
||||||
// .then(data => {
|
|
||||||
// setCsrfToken(data.csrfToken);
|
|
||||||
//})
|
|
||||||
//.catch(() => {});
|
|
||||||
//}, []);
|
|
||||||
|
|
||||||
// Check session on mount
|
// Check session on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -55,9 +44,9 @@ function Editor() {
|
|||||||
.catch(() => setIsLoggedIn(false));
|
.catch(() => setIsLoggedIn(false));
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const authenticatedFetch = useCallback((url, options = {}) => {
|
const authenticatedFetch = useCallback((url, options = {}) => {
|
||||||
return fetch(url, options);
|
return fetch(url, options);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadBlocks = useCallback(async (tab) => {
|
const loadBlocks = useCallback(async (tab) => {
|
||||||
try {
|
try {
|
||||||
@@ -105,7 +94,7 @@ const authenticatedFetch = useCallback((url, options = {}) => {
|
|||||||
|
|
||||||
const handleTabChange = (tab) => {
|
const handleTabChange = (tab) => {
|
||||||
setActiveTab(tab);
|
setActiveTab(tab);
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn && tab !== 'slides') {
|
||||||
loadBlocks(tab);
|
loadBlocks(tab);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -138,41 +127,35 @@ const authenticatedFetch = useCallback((url, options = {}) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveBlock = async (e) => {
|
const handleSaveBlock = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const content = DOMPurify.sanitize(editContent.trim());
|
const content = DOMPurify.sanitize(editContent.trim());
|
||||||
if (!content || content === '<p><br></p>') return;
|
if (!content || content === '<p><br></p>') return;
|
||||||
|
|
||||||
const url = editingBlockId
|
const url = editingBlockId
|
||||||
? `/moders/content/${activeTab}/${editingBlockId}`
|
? `/moders/content/${activeTab}/${editingBlockId}`
|
||||||
: `/moders/content/${activeTab}`;
|
: `/moders/content/${activeTab}`;
|
||||||
|
|
||||||
const method = editingBlockId ? 'PUT' : 'POST';
|
const method = editingBlockId ? 'PUT' : 'POST';
|
||||||
|
|
||||||
console.log('➡️ Отправка:', method, url, { type: 'text', content });
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ type: 'text', content })
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
if (response.ok) {
|
||||||
const response = await fetch(url, {
|
showNotify(editingBlockId ? 'Блок обновлён' : 'Блок добавлен', 'success');
|
||||||
method,
|
setShowEditModal(false);
|
||||||
headers: { 'Content-Type': 'application/json' },
|
loadBlocks(activeTab);
|
||||||
body: JSON.stringify({ type: 'text', content })
|
} else {
|
||||||
});
|
const data = await response.json();
|
||||||
|
showNotify(`Ошибка: ${data.message || response.status}`, 'error');
|
||||||
const data = await response.json();
|
}
|
||||||
|
} catch (e) {
|
||||||
console.log('⬅️ Ответ сервера:', response.status, data);
|
showNotify('Ошибка сохранения', 'error');
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
showNotify(editingBlockId ? 'Блок обновлён' : 'Блок добавлен', 'success');
|
|
||||||
setShowEditModal(false);
|
|
||||||
loadBlocks(activeTab);
|
|
||||||
} else {
|
|
||||||
showNotify(`Ошибка: ${data.message || response.status}`, 'error');
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
};
|
||||||
console.error('❌ Ошибка при сохранении:', e);
|
|
||||||
showNotify('Ошибка сохранения', 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTryCloseEditModal = () => {
|
const handleTryCloseEditModal = () => {
|
||||||
if (editContent !== originalContentRef.current) {
|
if (editContent !== originalContentRef.current) {
|
||||||
@@ -194,7 +177,6 @@ const authenticatedFetch = useCallback((url, options = {}) => {
|
|||||||
// Initialize Quill when modal opens
|
// Initialize Quill when modal opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showEditModal && editorRef.current && !quillRef.current) {
|
if (showEditModal && editorRef.current && !quillRef.current) {
|
||||||
// Dynamically import Quill
|
|
||||||
import('quill').then((QuillModule) => {
|
import('quill').then((QuillModule) => {
|
||||||
const Quill = QuillModule.default || QuillModule;
|
const Quill = QuillModule.default || QuillModule;
|
||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
@@ -225,51 +207,68 @@ const authenticatedFetch = useCallback((url, options = {}) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup Quill when modal closes
|
|
||||||
if (!showEditModal && quillRef.current) {
|
if (!showEditModal && quillRef.current) {
|
||||||
quillRef.current = null;
|
quillRef.current = null;
|
||||||
}
|
}
|
||||||
}, [showEditModal, editContent]);
|
}, [showEditModal, editContent]);
|
||||||
|
|
||||||
|
// Если не авторизован — показываем форму входа
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<h1>Панель модератора</h1>
|
||||||
|
<div id="login-section">
|
||||||
|
<h2>Вход для модератора</h2>
|
||||||
|
<form id="moder-login-form" onSubmit={handleLogin}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="username">Логин:</label>
|
||||||
|
<input type="text" id="username" name="username" required value={username} onChange={(e) => setUsername(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="password">Пароль:</label>
|
||||||
|
<input type="password" id="password" name="password" required value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<button type="submit">Войти</button>
|
||||||
|
{loginError && <p id="login-error" style={{ color: 'red' }}>{loginError}</p>}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Авторизован — показываем редактор
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div id="notify" className={notify.show ? `show ${notify.type}` : ''}>{notify.message}</div>
|
<div id="notify" className={notify.show ? `show ${notify.type}` : ''}>{notify.message}</div>
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<h1>Панель модератора</h1>
|
<h1>Панель модератора</h1>
|
||||||
{!isLoggedIn ? (
|
<button id="logout-btn" style={{ float: 'right' }} onClick={handleLogout}>Выйти из системы</button>
|
||||||
<div id="login-section">
|
<h2>Редактирование вкладок</h2>
|
||||||
<h2>Вход для модератора</h2>
|
|
||||||
<form id="moder-login-form" onSubmit={handleLogin}>
|
<div className="tab-controls">
|
||||||
<div className="form-group">
|
<button className={`tab-btn ${activeTab === 'info' ? 'active' : ''}`} onClick={() => handleTabChange('info')} data-tab="info">
|
||||||
<label htmlFor="username">Логин:</label>
|
Общая информация
|
||||||
<input type="text" id="username" name="username" required value={username} onChange={(e) => setUsername(e.target.value)} />
|
</button>
|
||||||
</div>
|
<button className={`tab-btn ${activeTab === 'events' ? 'active' : ''}`} onClick={() => handleTabChange('events')} data-tab="events">
|
||||||
<div className="form-group">
|
📅 Мероприятия
|
||||||
<label htmlFor="password">Пароль:</label>
|
</button>
|
||||||
<input type="password" id="password" name="password" required value={password} onChange={(e) => setPassword(e.target.value)} />
|
<button className={`tab-btn ${activeTab === 'help' ? 'active' : ''}`} onClick={() => handleTabChange('help')} data-tab="help">
|
||||||
</div>
|
💰 Материальная помощь
|
||||||
<button type="submit">Войти</button>
|
</button>
|
||||||
{loginError && <p id="login-error" style={{ color: 'red' }}>{loginError}</p>}
|
<button className={`tab-btn ${activeTab === 'housing' ? 'active' : ''}`} onClick={() => handleTabChange('housing')} data-tab="housing">
|
||||||
</form>
|
🏠 Жилищные условия
|
||||||
</div>
|
</button>
|
||||||
|
{/* ✅ НОВАЯ ВКЛАДКА СЛАЙДЫ */}
|
||||||
|
<button className={`tab-btn ${activeTab === 'slides' ? 'active' : ''}`} onClick={() => setActiveTab('slides')} data-tab="slides">
|
||||||
|
📸 Слайды
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Рендеринг контента вкладок */}
|
||||||
|
{activeTab === 'slides' ? (
|
||||||
|
<SlidesManager />
|
||||||
) : (
|
) : (
|
||||||
<div id="editor-section">
|
<>
|
||||||
<button id="logout-btn" style={{ float: 'right' }} onClick={handleLogout}>Выйти из системы</button>
|
|
||||||
<h2>Редактирование вкладок</h2>
|
|
||||||
<div className="tab-controls">
|
|
||||||
<button className={`tab-btn ${activeTab === 'info' ? 'active' : ''}`} onClick={() => handleTabChange('info')} data-tab="info">
|
|
||||||
Общая информация
|
|
||||||
</button>
|
|
||||||
<button className={`tab-btn ${activeTab === 'events' ? 'active' : ''}`} onClick={() => handleTabChange('events')} data-tab="events">
|
|
||||||
📅 Мероприятия
|
|
||||||
</button>
|
|
||||||
<button className={`tab-btn ${activeTab === 'help' ? 'active' : ''}`} onClick={() => handleTabChange('help')} data-tab="help">
|
|
||||||
💰 Материальная помощь
|
|
||||||
</button>
|
|
||||||
<button className={`tab-btn ${activeTab === 'housing' ? 'active' : ''}`} onClick={() => handleTabChange('housing')} data-tab="housing">
|
|
||||||
🏠 Жилищные условия
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div style={{ margin: '18px 0' }}>
|
<div style={{ margin: '18px 0' }}>
|
||||||
<button id="add-block-btn" onClick={handleAddBlock}>Добавить блок</button>
|
<button id="add-block-btn" onClick={handleAddBlock}>Добавить блок</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -283,9 +282,10 @@ const authenticatedFetch = useCallback((url, options = {}) => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Модалка редактирования блоков */}
|
||||||
{showEditModal && (
|
{showEditModal && (
|
||||||
<div id="edit-modal" className="modal" style={{ display: 'block' }} onClick={(e) => {
|
<div id="edit-modal" className="modal" style={{ display: 'block' }} onClick={(e) => {
|
||||||
if (e.target === e.currentTarget) handleTryCloseEditModal();
|
if (e.target === e.currentTarget) handleTryCloseEditModal();
|
||||||
|
|||||||
@@ -0,0 +1,395 @@
|
|||||||
|
// src/components/SlidesManager.jsx
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
|
function SlidesManager() {
|
||||||
|
const [slides, setSlides] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
|
const [editingSlide, setEditingSlide] = useState(null);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
image_url: '',
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
link: '',
|
||||||
|
order: 0,
|
||||||
|
is_active: 1
|
||||||
|
});
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [notify, setNotify] = useState({ message: '', type: '', show: false });
|
||||||
|
|
||||||
|
const showNotify = useCallback((message, type = 'success') => {
|
||||||
|
setNotify({ message, type, show: true });
|
||||||
|
setTimeout(() => {
|
||||||
|
setNotify({ message: '', type: '', show: false });
|
||||||
|
}, 2500);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Загрузка слайдов
|
||||||
|
const loadSlides = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/moders/content/slides/all', {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setSlides(Array.isArray(data) ? data : []);
|
||||||
|
} else {
|
||||||
|
showNotify('Ошибка загрузки слайдов', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showNotify('Ошибка загрузки слайдов', 'error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [showNotify]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSlides();
|
||||||
|
}, [loadSlides]);
|
||||||
|
|
||||||
|
// Открыть модалку для добавления
|
||||||
|
const handleAddSlide = () => {
|
||||||
|
setEditingSlide(null);
|
||||||
|
setFormData({
|
||||||
|
image_url: '',
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
link: '',
|
||||||
|
order: slides.length,
|
||||||
|
is_active: 1
|
||||||
|
});
|
||||||
|
setShowEditModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Открыть модалку для редактирования
|
||||||
|
const handleEditSlide = (slide) => {
|
||||||
|
setEditingSlide(slide);
|
||||||
|
setFormData({
|
||||||
|
image_url: slide.image_url || '',
|
||||||
|
title: slide.title || '',
|
||||||
|
description: slide.description || '',
|
||||||
|
link: slide.link || '',
|
||||||
|
order: slide.order || 0,
|
||||||
|
is_active: slide.is_active !== undefined ? slide.is_active : 1
|
||||||
|
});
|
||||||
|
setShowEditModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Сохранить слайд
|
||||||
|
// В функции handleSaveSlide
|
||||||
|
const handleSaveSlide = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// ✅ ЛОГИ ДЛЯ ОТЛАДКИ
|
||||||
|
console.log('========================================');
|
||||||
|
console.log('📤 ОТПРАВКА ДАННЫХ НА СЕРВЕР');
|
||||||
|
console.log('📤 editingSlide:', editingSlide);
|
||||||
|
console.log('📤 formData:', JSON.stringify(formData, null, 2));
|
||||||
|
console.log('========================================');
|
||||||
|
|
||||||
|
if (!formData.image_url) {
|
||||||
|
showNotify('URL изображения обязателен', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = editingSlide
|
||||||
|
? `/moders/content/slides/${editingSlide.id}`
|
||||||
|
: '/moders/content/slides';
|
||||||
|
|
||||||
|
const method = editingSlide ? 'PUT' : 'POST';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(formData)
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
console.log('📥 ОТВЕТ СЕРВЕРА:', res.status, data);
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showNotify(editingSlide ? 'Слайд обновлён' : 'Слайд добавлен', 'success');
|
||||||
|
setShowEditModal(false);
|
||||||
|
loadSlides();
|
||||||
|
} else {
|
||||||
|
showNotify(data.message || `Ошибка: ${res.status}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('❌ Ошибка:', e);
|
||||||
|
showNotify('Ошибка сохранения', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Удалить слайд
|
||||||
|
const handleDeleteSlide = async (id) => {
|
||||||
|
if (!window.confirm('Удалить этот слайд?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/moders/content/slides/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
showNotify('Слайд удалён', 'success');
|
||||||
|
loadSlides();
|
||||||
|
} else {
|
||||||
|
showNotify('Ошибка удаления', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showNotify('Ошибка удаления', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Загрузка изображения
|
||||||
|
const handleUploadImage = async (file) => {
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/moders/content/slides/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setFormData(prev => ({ ...prev, image_url: data.path }));
|
||||||
|
showNotify('Изображение загружено', 'success');
|
||||||
|
} else {
|
||||||
|
showNotify('Ошибка загрузки изображения', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showNotify('Ошибка загрузки изображения', 'error');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="slides-manager">
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||||
|
<h2>📸 Слайды карусели</h2>
|
||||||
|
<button id="add-slide-btn" onClick={handleAddSlide}>+ Добавить слайд</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Уведомления */}
|
||||||
|
{notify.show && (
|
||||||
|
<div className={`notify ${notify.type}`} style={{
|
||||||
|
padding: '10px 16px',
|
||||||
|
marginBottom: '15px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: notify.type === 'success' ? '#d4edda' : '#f8d7da',
|
||||||
|
color: notify.type === 'success' ? '#155724' : '#721c24',
|
||||||
|
border: `1px solid ${notify.type === 'success' ? '#c3e6cb' : '#f5c6cb'}`
|
||||||
|
}}>
|
||||||
|
{notify.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Список слайдов */}
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px' }}>Загрузка...</div>
|
||||||
|
) : slides.length === 0 ? (
|
||||||
|
<p style={{ color: '#999', textAlign: 'center', padding: '40px' }}>Нет слайдов. Добавьте первый!</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))', gap: '20px' }}>
|
||||||
|
{slides.map(slide => (
|
||||||
|
<div key={slide.id} style={{
|
||||||
|
background: '#fff',
|
||||||
|
borderRadius: '12px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
||||||
|
border: `2px solid ${slide.is_active ? '#28a745' : '#dc3545'}`,
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
<img
|
||||||
|
src={slide.image_url}
|
||||||
|
alt={slide.title || 'Слайд'}
|
||||||
|
style={{ width: '100%', height: '150px', objectFit: 'cover' }}
|
||||||
|
onError={(e) => {
|
||||||
|
e.target.src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="100%25" height="100%25"%3E%3Crect width="100%25" height="100%25" fill="%23ddd"/%3E%3Ctext x="50%25" y="50%25" text-anchor="middle" dy=".3em" fill="%23999" font-size="14"%3E%D0%9D%D0%B5%D1%82%20%D0%B8%D0%B7%D0%BE%D0%B1%D1%80%D0%B0%D0%B6%D0%B5%D0%BD%D0%B8%D1%8F%3C/text%3E%3C/svg%3E';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ padding: '12px 16px' }}>
|
||||||
|
<h4 style={{ margin: '0 0 4px 0', fontSize: '16px' }}>{slide.title || 'Без названия'}</h4>
|
||||||
|
<p style={{ margin: '0 0 8px 0', fontSize: '13px', color: '#666', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||||
|
{slide.description || ''}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', fontSize: '12px', color: '#999', marginBottom: '10px' }}>
|
||||||
|
<span>Порядок: {slide.order}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span style={{ color: slide.is_active ? '#28a745' : '#dc3545' }}>
|
||||||
|
{slide.is_active ? 'Активен' : 'Неактивен'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button className="btn-small" onClick={() => handleEditSlide(slide)}>✏️</button>
|
||||||
|
<button className="btn-small" onClick={() => handleDeleteSlide(slide.id)}>🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Модалка редактирования */}
|
||||||
|
{showEditModal && (
|
||||||
|
<div className="modal" style={{
|
||||||
|
display: 'block',
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: 'rgba(0,0,0,0.5)',
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
zIndex: 2000,
|
||||||
|
padding: '20px',
|
||||||
|
overflowY: 'auto'
|
||||||
|
}} onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) setShowEditModal(false);
|
||||||
|
}}>
|
||||||
|
<div className="modal-content" style={{
|
||||||
|
maxWidth: '700px',
|
||||||
|
margin: '40px auto',
|
||||||
|
background: '#fff',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '30px',
|
||||||
|
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
<button className="close" style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '12px',
|
||||||
|
right: '20px',
|
||||||
|
fontSize: '28px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: '#999'
|
||||||
|
}} onClick={() => setShowEditModal(false)}>×</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.177Z] [INFO] Login successful
|
||||||
[2026-08-18T13:21:48.283Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
[2026-08-18T13:21:48.283Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
[2026-08-18T13:21:48.296Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
[2026-08-18T13:21:48.296Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-19T06:49:14.829Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-19T06:49:14.837Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-19T08:16:23.702Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-19T08:16:23.706Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-19T09:32:26.775Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-19T09:32:26.778Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-19T09:54:13.538Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-19T09:54:13.541Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-19T10:03:57.687Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-19T10:03:57.691Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-19T13:16:59.418Z] [INFO] Login successful
|
||||||
|
[2026-08-19T13:16:59.575Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-19T13:16:59.591Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-19T13:17:34.211Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-08-19T13:17:34.212Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-19T13:17:34.217Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-19T13:17:34.217Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-19T13:17:34.291Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-19T13:17:34.292Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-08-19T13:17:34.293Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-22T10:02:00.127Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-22T10:02:00.130Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-28T13:16:54.329Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-28T13:16:54.345Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-28T13:17:44.305Z] [WARN] Неверный пароль
|
||||||
|
[2026-08-28T13:17:47.842Z] [WARN] Неверный пароль
|
||||||
|
[2026-08-28T13:17:49.122Z] [WARN] Неверный пароль
|
||||||
|
[2026-08-28T13:18:24.580Z] [INFO] Login successful
|
||||||
|
[2026-08-28T13:18:24.735Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-28T13:18:24.756Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-28T13:18:42.365Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-08-28T13:18:42.366Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-28T13:18:42.380Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-28T13:18:42.381Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-28T13:18:42.462Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-28T13:18:42.463Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-08-28T13:18:42.464Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-28T13:21:07.055Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-28T13:21:07.058Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-28T13:21:24.349Z] [ERROR] MulterError: Unexpected field
|
||||||
|
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||||
|
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||||
|
at Multipart.emit (node:events:509:28)
|
||||||
|
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||||
|
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||||
|
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||||
|
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||||
|
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||||
|
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||||
|
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||||
|
[2026-08-28T13:21:28.269Z] [ERROR] MulterError: Unexpected field
|
||||||
|
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||||
|
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||||
|
at Multipart.emit (node:events:509:28)
|
||||||
|
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||||
|
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||||
|
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||||
|
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||||
|
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||||
|
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||||
|
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||||
|
[2026-08-28T13:22:13.764Z] [ERROR] MulterError: Unexpected field
|
||||||
|
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||||
|
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||||
|
at Multipart.emit (node:events:509:28)
|
||||||
|
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||||
|
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||||
|
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||||
|
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||||
|
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||||
|
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||||
|
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||||
|
[2026-08-28T13:25:47.640Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-28T13:25:47.643Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-28T13:25:57.085Z] [ERROR] MulterError: Unexpected field
|
||||||
|
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||||
|
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||||
|
at Multipart.emit (node:events:509:28)
|
||||||
|
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||||
|
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||||
|
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||||
|
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||||
|
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||||
|
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||||
|
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||||
|
[2026-08-28T13:28:40.679Z] [ERROR] MulterError: Unexpected field
|
||||||
|
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||||
|
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||||
|
at Multipart.emit (node:events:509:28)
|
||||||
|
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||||
|
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||||
|
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||||
|
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||||
|
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||||
|
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||||
|
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||||
|
[2026-08-28T13:35:09.914Z] [ERROR] MulterError: Unexpected field
|
||||||
|
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||||
|
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||||
|
at Multipart.emit (node:events:509:28)
|
||||||
|
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||||
|
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||||
|
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||||
|
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||||
|
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||||
|
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||||
|
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||||
|
[2026-08-28T13:36:00.169Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-28T13:36:00.172Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-28T13:37:58.077Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-28T13:37:58.080Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-28T13:38:24.214Z] [ERROR] MulterError: Unexpected field
|
||||||
|
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||||
|
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||||
|
at Multipart.emit (node:events:509:28)
|
||||||
|
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||||
|
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||||
|
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||||
|
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||||
|
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||||
|
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||||
|
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||||
|
[2026-08-28T13:38:54.054Z] [ERROR] MulterError: Unexpected field
|
||||||
|
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||||
|
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||||
|
at Multipart.emit (node:events:509:28)
|
||||||
|
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||||
|
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||||
|
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||||
|
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||||
|
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||||
|
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||||
|
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||||
|
[2026-08-28T13:38:57.735Z] [ERROR] MulterError: Unexpected field
|
||||||
|
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||||
|
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||||
|
at Multipart.emit (node:events:509:28)
|
||||||
|
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||||
|
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||||
|
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||||
|
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||||
|
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||||
|
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||||
|
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||||
|
[2026-08-28T13:40:58.165Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-28T13:40:58.168Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-28T13:41:09.217Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-08-28T13:41:09.218Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-28T13:41:09.220Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-28T13:41:09.220Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-28T13:41:09.298Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-28T13:41:09.299Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-08-28T13:41:09.301Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-28T13:41:18.242Z] [ERROR] MulterError: Unexpected field
|
||||||
|
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||||
|
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||||
|
at Multipart.emit (node:events:509:28)
|
||||||
|
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||||
|
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||||
|
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||||
|
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||||
|
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||||
|
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||||
|
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||||
|
[2026-08-28T13:44:33.109Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-28T13:44:33.112Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-28T13:44:45.327Z] [ERROR] MulterError: Unexpected field
|
||||||
|
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||||
|
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||||
|
at Multipart.emit (node:events:509:28)
|
||||||
|
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||||
|
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||||
|
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||||
|
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||||
|
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||||
|
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||||
|
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||||
|
[2026-08-28T13:47:57.993Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-28T13:47:57.997Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-28T13:52:17.446Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-28T13:52:17.449Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-28T13:53:18.856Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-28T13:53:18.859Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-28T13:53:30.533Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-08-28T13:53:30.534Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-28T13:53:30.546Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-28T13:53:30.547Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-28T13:53:30.646Z] [INFO] [LOGIN] Результат сравнения пароля: false
|
||||||
|
[2026-08-28T13:53:30.646Z] [WARN] [LOGIN] Неверный пароль для пользователя: vova
|
||||||
|
[2026-08-28T13:53:36.962Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-08-28T13:53:36.963Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-28T13:53:36.965Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-28T13:53:36.965Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-28T13:53:37.043Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-28T13:53:37.043Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-08-28T13:53:37.045Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-28T13:54:12.715Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-08-28T13:54:12.715Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-28T13:54:12.717Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-28T13:54:12.718Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-28T13:54:12.803Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-28T13:54:12.804Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-08-28T13:54:12.805Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-28T13:54:29.593Z] [ERROR] MulterError: Unexpected field
|
||||||
|
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||||
|
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||||
|
at Multipart.emit (node:events:509:28)
|
||||||
|
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||||
|
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||||
|
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||||
|
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||||
|
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||||
|
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||||
|
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||||
|
[2026-08-28T13:55:32.725Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-28T13:55:32.728Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-28T13:55:44.526Z] [ERROR] MulterError: Unexpected field
|
||||||
|
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||||
|
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||||
|
at Multipart.emit (node:events:509:28)
|
||||||
|
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||||
|
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||||
|
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||||
|
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||||
|
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||||
|
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||||
|
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||||
|
[2026-08-28T13:57:30.453Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-28T13:57:30.456Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-28T13:57:37.890Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-08-28T13:57:37.891Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-28T13:57:37.893Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-28T13:57:37.893Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-28T13:57:37.973Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-28T13:57:37.973Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-08-28T13:57:37.975Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-28T13:59:04.163Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-28T13:59:04.166Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-28T13:59:18.113Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-08-28T13:59:18.113Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-28T13:59:18.128Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-28T13:59:18.129Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-28T13:59:18.259Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-28T13:59:18.260Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-08-28T13:59:18.262Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-28T14:02:13.224Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-28T14:02:13.226Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-28T14:02:25.923Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-08-28T14:02:25.924Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-28T14:02:25.940Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-28T14:02:25.941Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-28T14:02:26.078Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-28T14:02:26.087Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-08-28T14:02:26.089Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-28T14:03:37.800Z] [ERROR] MulterError: Unexpected field
|
||||||
|
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||||
|
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||||
|
at Multipart.emit (node:events:509:28)
|
||||||
|
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||||
|
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||||
|
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||||
|
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||||
|
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||||
|
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||||
|
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||||
|
[2026-08-28T14:05:00.439Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-28T14:05:00.442Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-28T14:05:47.706Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-08-28T14:05:47.707Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-28T14:05:47.709Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-28T14:05:47.710Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-28T14:05:47.796Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-28T14:05:47.797Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-08-28T14:05:47.798Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-28T14:06:31.202Z] [INFO] [LOGIN] Попытка входа: grivova
|
||||||
|
[2026-08-28T14:06:31.202Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-28T14:06:31.204Z] [INFO] [LOGIN] Результат поиска пользователя: не найден
|
||||||
|
[2026-08-28T14:06:31.204Z] [WARN] [LOGIN] Пользователь не найден: grivova
|
||||||
|
[2026-08-28T14:06:43.404Z] [INFO] [LOGIN] Попытка входа: asutp
|
||||||
|
[2026-08-28T14:06:43.406Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-28T14:06:43.407Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-28T14:06:43.408Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-28T14:06:43.489Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-28T14:06:43.490Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":5,"username":"asutp"}
|
||||||
|
[2026-08-28T14:06:43.491Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-28T14:07:57.016Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-28T14:07:57.018Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-28T14:08:15.541Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-08-28T14:08:15.543Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-28T14:08:15.546Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-28T14:08:15.547Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-28T14:08:15.629Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-28T14:08:15.630Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-08-28T14:08:15.631Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-28T14:09:51.626Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-28T14:09:51.629Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-31T05:58:23.110Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-31T05:58:23.117Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-31T11:41:26.146Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-31T11:41:26.163Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-31T11:41:58.530Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-08-31T11:41:58.531Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-31T11:41:58.554Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-31T11:41:58.562Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-31T11:41:58.657Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-31T11:41:58.661Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-08-31T11:41:58.668Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-31T12:06:00.544Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-31T12:06:00.547Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-31T12:18:40.420Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-31T12:18:40.423Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-31T12:21:29.695Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-31T12:21:29.697Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-31T12:23:29.133Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-31T12:23:29.137Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-31T12:27:21.641Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-31T12:27:21.644Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-31T12:30:33.180Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-31T12:30:33.183Z] [INFO] Card logs initialized successfully
|
||||||
|
|||||||
@@ -9,93 +9,211 @@ const fs = require('fs');
|
|||||||
const uploadDir = path.join(__dirname, '../../uploads');
|
const uploadDir = path.join(__dirname, '../../uploads');
|
||||||
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: (req, file, cb) => cb(null, uploadDir),
|
destination: (req, file, cb) => cb(null, uploadDir),
|
||||||
filename: (req, file, cb) => {
|
filename: (req, file, cb) => {
|
||||||
const ext = path.extname(file.originalname);
|
const ext = path.extname(file.originalname);
|
||||||
const name = Date.now() + '-' + Math.round(Math.random() * 1e6) + ext;
|
const name = Date.now() + '-' + Math.round(Math.random() * 1e6) + ext;
|
||||||
cb(null, name);
|
cb(null, name);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const ALLOWED_MIME_TYPES = [
|
const ALLOWED_MIME_TYPES = [
|
||||||
'image/jpeg', 'image/png',
|
'image/jpeg', 'image/png',
|
||||||
'video/mp4', 'video/quicktime'
|
'video/mp4', 'video/quicktime'
|
||||||
];
|
];
|
||||||
const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20 МБ
|
const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20 МБ
|
||||||
|
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage,
|
storage,
|
||||||
limits: { fileSize: MAX_FILE_SIZE },
|
limits: { fileSize: MAX_FILE_SIZE },
|
||||||
fileFilter: (req, file, cb) => {
|
fileFilter: (req, file, cb) => {
|
||||||
if (ALLOWED_MIME_TYPES.includes(file.mimetype)) {
|
if (ALLOWED_MIME_TYPES.includes(file.mimetype)) {
|
||||||
cb(null, true);
|
cb(null, true);
|
||||||
} else {
|
} else {
|
||||||
cb(new Error('Недопустимый тип файла'));
|
cb(new Error('Недопустимый тип файла'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 📸 СЛАЙДЫ ДЛЯ КАРУСЕЛИ (В САМОМ НАЧАЛЕ)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// Получить все активные слайды
|
||||||
|
router.get('/slides', async (req, res) => {
|
||||||
|
console.log('📸 GET /slides');
|
||||||
|
try {
|
||||||
|
const [rows] = await db.query(
|
||||||
|
'SELECT * FROM slides WHERE is_active = 1 ORDER BY `order` ASC'
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Ошибка получения слайдов:', err);
|
||||||
|
res.status(500).json({ message: 'Ошибка сервера', error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Получить все слайды (для админки)
|
||||||
|
router.get('/slides/all', async (req, res) => {
|
||||||
|
console.log('📸 GET /slides/all');
|
||||||
|
try {
|
||||||
|
const [rows] = await db.query(
|
||||||
|
'SELECT * FROM slides ORDER BY `order` ASC'
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Ошибка получения всех слайдов:', err);
|
||||||
|
res.status(500).json({ message: 'Ошибка сервера', error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Загрузка изображения для слайда
|
||||||
|
router.post('/slides/upload', upload.single('image'), (req, res) => {
|
||||||
|
console.log('📸 POST /slides/upload');
|
||||||
|
console.log('📁 req.file:', req.file);
|
||||||
|
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ message: 'Файл не загружен' });
|
||||||
|
}
|
||||||
|
const relPath = '/uploads/' + req.file.filename;
|
||||||
|
console.log('✅ Изображение загружено:', relPath);
|
||||||
|
res.json({ path: relPath });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Создать слайд
|
||||||
|
router.post('/slides', async (req, res) => {
|
||||||
|
console.log('📸 POST /slides');
|
||||||
|
const { image_url, title, description, link, order, is_active } = req.body;
|
||||||
|
|
||||||
|
if (!image_url) {
|
||||||
|
return res.status(400).json({ message: 'image_url обязателен' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [result] = await db.query(
|
||||||
|
`INSERT INTO slides (image_url, title, description, link, \`order\`, is_active, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())`,
|
||||||
|
[image_url, title || null, description || null, link || null, order || 0, is_active !== undefined ? is_active : 1]
|
||||||
|
);
|
||||||
|
console.log('✅ Слайд создан, ID:', result.insertId);
|
||||||
|
res.json({ id: result.insertId, message: 'Слайд создан' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Ошибка создания слайда:', err);
|
||||||
|
res.status(500).json({ message: 'Ошибка сервера', error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обновить слайд
|
||||||
|
router.put('/slides/:id', async (req, res) => {
|
||||||
|
console.log('📸 PUT /slides/' + req.params.id);
|
||||||
|
const { id } = req.params;
|
||||||
|
const { image_url, title, description, link, order, is_active } = req.body;
|
||||||
|
|
||||||
|
if (!image_url) {
|
||||||
|
return res.status(400).json({ message: 'image_url обязателен' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.query(
|
||||||
|
`UPDATE slides
|
||||||
|
SET image_url = ?,
|
||||||
|
title = ?,
|
||||||
|
description = ?,
|
||||||
|
link = ?,
|
||||||
|
\`order\` = ?,
|
||||||
|
is_active = ?,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = ?`,
|
||||||
|
[image_url, title || null, description || null, link || null, order || 0, is_active !== undefined ? is_active : 1, id]
|
||||||
|
);
|
||||||
|
console.log('✅ Слайд обновлён, ID:', id);
|
||||||
|
res.json({ message: 'Слайд обновлён' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Ошибка обновления слайда:', err);
|
||||||
|
res.status(500).json({ message: 'Ошибка сервера', error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Удалить слайд
|
||||||
|
router.delete('/slides/:id', async (req, res) => {
|
||||||
|
console.log('📸 DELETE /slides/' + req.params.id);
|
||||||
|
const { id } = req.params;
|
||||||
|
try {
|
||||||
|
await db.query('DELETE FROM slides WHERE id = ?', [id]);
|
||||||
|
console.log('✅ Слайд удалён, ID:', id);
|
||||||
|
res.json({ message: 'Слайд удалён' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Ошибка удаления слайда:', err);
|
||||||
|
res.status(500).json({ message: 'Ошибка сервера', error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 📚 ОСТАЛЬНЫЕ МАРШРУТЫ (НИЖЕ)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
// Получить все блоки для вкладки
|
// Получить все блоки для вкладки
|
||||||
router.get('/:tab', async (req, res) => {
|
router.get('/:tab', async (req, res) => {
|
||||||
const { tab } = req.params;
|
const { tab } = req.params;
|
||||||
if (!['info', 'union', 'events', 'help', 'housing'].includes(tab)) return res.status(400).json({ message: 'Некорректная вкладка' });
|
if (!['info', 'union', 'events', 'help', 'housing'].includes(tab)) return res.status(400).json({ message: 'Некорректная вкладка' });
|
||||||
try {
|
try {
|
||||||
const [rows] = await db.query('SELECT * FROM content_blocks WHERE tab = ? ORDER BY created_at ASC', [tab]);
|
const [rows] = await db.query('SELECT * FROM content_blocks WHERE tab = ? ORDER BY created_at ASC', [tab]);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ message: 'Ошибка сервера' });
|
res.status(500).json({ message: 'Ошибка сервера' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Загрузка файла (image/video)
|
// Загрузка файла (image/video)
|
||||||
router.post('/:tab/upload', requireModerator, upload.single('file'), (req, res) => {
|
router.post('/:tab/upload', requireModerator, upload.single('file'), (req, res) => {
|
||||||
if (!req.file) return res.status(400).json({ message: 'Файл не загружен' });
|
if (!req.file) return res.status(400).json({ message: 'Файл не загружен' });
|
||||||
const relPath = '/uploads/' + req.file.filename;
|
const relPath = '/uploads/' + req.file.filename;
|
||||||
res.json({ path: relPath });
|
res.json({ path: relPath });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Добавить новый блок
|
// Добавить новый блок
|
||||||
router.post('/:tab', requireModerator, async (req, res) => {
|
router.post('/:tab', requireModerator, async (req, res) => {
|
||||||
const { tab } = req.params;
|
const { tab } = req.params;
|
||||||
const { content } = req.body;
|
const { content } = req.body;
|
||||||
if (!['info', 'union', 'events', 'help', 'housing'].includes(tab)) return res.status(400).json({ message: 'Некорректная вкладка' });
|
if (!['info', 'union', 'events', 'help', 'housing'].includes(tab)) return res.status(400).json({ message: 'Некорректная вкладка' });
|
||||||
try {
|
try {
|
||||||
const [result] = await db.query(
|
const [result] = await db.query(
|
||||||
'INSERT INTO content_blocks (tab, type, content, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW())',
|
'INSERT INTO content_blocks (tab, type, content, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW())',
|
||||||
[tab, 'text', content, req.session.userId]
|
[tab, 'text', content, req.session.userId]
|
||||||
);
|
);
|
||||||
res.json({ id: result.insertId });
|
res.json({ id: result.insertId });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ message: 'Ошибка сервера' });
|
res.status(500).json({ message: 'Ошибка сервера' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Обновить блок
|
// Обновить блок
|
||||||
router.put('/:tab/:id', requireModerator, async (req, res) => {
|
router.put('/:tab/:id', requireModerator, async (req, res) => {
|
||||||
const { tab, id } = req.params;
|
const { tab, id } = req.params;
|
||||||
const { content } = req.body;
|
const { content } = req.body;
|
||||||
if (!['info', 'union', 'events', 'help', 'housing'].includes(tab)) return res.status(400).json({ message: 'Некорректная вкладка' });
|
if (!['info', 'union', 'events', 'help', 'housing'].includes(tab)) return res.status(400).json({ message: 'Некорректная вкладка' });
|
||||||
try {
|
try {
|
||||||
await db.query(
|
await db.query(
|
||||||
'UPDATE content_blocks SET content = ?, updated_at = NOW() WHERE id = ? AND tab = ?',
|
'UPDATE content_blocks SET content = ?, updated_at = NOW() WHERE id = ? AND tab = ?',
|
||||||
[content, id, tab]
|
[content, id, tab]
|
||||||
);
|
);
|
||||||
res.json({ message: 'Обновлено' });
|
res.json({ message: 'Обновлено' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ message: 'Ошибка сервера' });
|
res.status(500).json({ message: 'Ошибка сервера' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Удалить блок
|
// Удалить блок
|
||||||
router.delete('/:tab/:id', requireModerator, async (req, res) => {
|
router.delete('/:tab/:id', requireModerator, async (req, res) => {
|
||||||
const { tab, id } = req.params;
|
const { tab, id } = req.params;
|
||||||
if (!['info', 'union', 'events', 'help', 'housing'].includes(tab)) return res.status(400).json({ message: 'Некорректная вкладка' });
|
if (!['info', 'union', 'events', 'help', 'housing'].includes(tab)) return res.status(400).json({ message: 'Некорректная вкладка' });
|
||||||
try {
|
try {
|
||||||
await db.query('DELETE FROM content_blocks WHERE id = ? AND tab = ?', [id, tab]);
|
await db.query('DELETE FROM content_blocks WHERE id = ? AND tab = ?', [id, tab]);
|
||||||
res.json({ message: 'Удалено' });
|
res.json({ message: 'Удалено' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ message: 'Ошибка сервера' });
|
res.status(500).json({ message: 'Ошибка сервера' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/section/:section', async (req, res) => {
|
router.get('/section/:section', async (req, res) => {
|
||||||
const { section } = req.params;
|
const { section } = req.params;
|
||||||
if (!['events', 'help', 'housing'].includes(section)) {
|
if (!['events', 'help', 'housing'].includes(section)) {
|
||||||
@@ -106,7 +224,6 @@ router.get('/section/:section', async (req, res) => {
|
|||||||
'SELECT * FROM content_blocks WHERE tab = ? ORDER BY created_at ASC',
|
'SELECT * FROM content_blocks WHERE tab = ? ORDER BY created_at ASC',
|
||||||
[section]
|
[section]
|
||||||
);
|
);
|
||||||
// Если есть блоки — возвращаем их, иначе пустой массив
|
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -114,7 +231,7 @@ router.get('/section/:section', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Сохранить контент раздела (очищаем старые и вставляем новый)
|
// Сохранить контент раздела
|
||||||
router.post('/section/:section', requireModerator, async (req, res) => {
|
router.post('/section/:section', requireModerator, async (req, res) => {
|
||||||
const { section } = req.params;
|
const { section } = req.params;
|
||||||
const { content } = req.body;
|
const { content } = req.body;
|
||||||
@@ -124,10 +241,8 @@ router.post('/section/:section', requireModerator, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Удаляем старые блоки для этого раздела
|
|
||||||
await db.query('DELETE FROM content_blocks WHERE tab = ?', [section]);
|
await db.query('DELETE FROM content_blocks WHERE tab = ?', [section]);
|
||||||
|
|
||||||
// Вставляем новый блок
|
|
||||||
const [result] = await db.query(
|
const [result] = await db.query(
|
||||||
'INSERT INTO content_blocks (tab, type, content, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW())',
|
'INSERT INTO content_blocks (tab, type, content, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW())',
|
||||||
[section, 'text', content, req.session.userId]
|
[section, 'text', content, req.session.userId]
|
||||||
@@ -143,4 +258,5 @@ router.post('/section/:section', requireModerator, async (req, res) => {
|
|||||||
res.status(500).json({ message: 'Ошибка сервера' });
|
res.status(500).json({ message: 'Ошибка сервера' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
module.exports = router;
|
|
||||||
|
module.exports = router;
|
||||||
|
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": {
|
"server": {
|
||||||
"host": "localhost",
|
"host": "localhost",
|
||||||
"port": "3001"
|
"port": "3001"
|
||||||
@@ -51,9 +56,9 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"payslips": [
|
"payslips": [
|
||||||
"Петров_ПП_2026-08.pdf",
|
"001.pdf",
|
||||||
"Сидорова_АС_2026-08.pdf",
|
"002.pdf",
|
||||||
"Иванов_ИИ_2026-08.pdf"
|
"003.pdf"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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) {
|
async function pathExists(filePath) {
|
||||||
|
console.log('🔍 Проверка пути:', filePath);
|
||||||
try {
|
try {
|
||||||
await fsPromises.access(filePath);
|
await fsPromises.access(filePath);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch (err) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,7 +70,9 @@ async function connectToNetworkShare(config) {
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
|
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
app.use(function(req, res, next) {
|
app.use(function(req, res, next) {
|
||||||
@@ -81,7 +84,31 @@ app.use(function(req, res, next) {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use(helmet());
|
// ✅ ИСПРАВЛЕННАЯ НАСТРОЙКА HELMET (разрешаем iframe)
|
||||||
|
app.use(helmet({
|
||||||
|
contentSecurityPolicy: {
|
||||||
|
directives: {
|
||||||
|
defaultSrc: ["'self'"],
|
||||||
|
// РАЗРЕШАЕМ встраивание в iframe с localhost:3000
|
||||||
|
frameAncestors: ["'self'", "http://localhost:3000", "http://127.0.0.1:3000"],
|
||||||
|
// Для PDF файлов
|
||||||
|
frameSrc: ["'self'", "blob:"],
|
||||||
|
// Другие необходимые директивы
|
||||||
|
scriptSrc: ["'self'", "'unsafe-inline'"],
|
||||||
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||||
|
imgSrc: ["'self'", "data:", "blob:"],
|
||||||
|
connectSrc: ["'self'"],
|
||||||
|
fontSrc: ["'self'"],
|
||||||
|
objectSrc: ["'self'", "blob:"],
|
||||||
|
mediaSrc: ["'self'"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Отключаем лишние политики для разработки
|
||||||
|
crossOriginEmbedderPolicy: false,
|
||||||
|
crossOriginOpenerPolicy: false,
|
||||||
|
crossOriginResourcePolicy: { policy: "cross-origin" },
|
||||||
|
}));
|
||||||
|
|
||||||
app.use('/api/', limiter);
|
app.use('/api/', limiter);
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -342,6 +369,269 @@ app.post('/api/rates/update-from-nbrb', async function(req, res) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 📄 РАСЧЕТНЫЕ ЛИСТКИ (ОБНОВЛЕННЫЕ ЭНДПОИНТЫ)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// ✅ НОВАЯ ФУНКЦИЯ: получает путь к файлу с учетом периода (месяца)
|
||||||
|
async function getPayslipFilePath(tnom, period = null) {
|
||||||
|
const configPath = path.join(__dirname, '../config.json');
|
||||||
|
const config = await readJson(configPath);
|
||||||
|
let payslipBasePath = config.payslipsPath;
|
||||||
|
|
||||||
|
if (!path.isAbsolute(payslipBasePath)) {
|
||||||
|
payslipBasePath = path.join(__dirname, '..', payslipBasePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
let filePath;
|
||||||
|
if (period) {
|
||||||
|
// ✅ Если передан период — ищем в подпапке
|
||||||
|
filePath = path.join(payslipBasePath, period, tnom + '.pdf');
|
||||||
|
console.log(`📁 Поиск в подпапке: ${period} -> ${filePath}`);
|
||||||
|
} else {
|
||||||
|
// ✅ Если период не передан — ищем в корне (для совместимости)
|
||||||
|
filePath = path.join(payslipBasePath, tnom + '.pdf');
|
||||||
|
console.log(`📁 Поиск в корне: ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ ОБНОВЛЕННАЯ ПРОВЕРКА наличия расчетного листка (с period)
|
||||||
|
app.get('/api/payslip/check/:tnom', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var tnom = req.params.tnom;
|
||||||
|
var period = req.query.period || null; // ✅ Берем period из query-параметров
|
||||||
|
|
||||||
|
console.log('==========================================');
|
||||||
|
console.log(`📁 [PAYSLIP CHECK] Табельный номер: ${tnom}, Период: ${period || 'не указан'}`);
|
||||||
|
|
||||||
|
const filePath = await getPayslipFilePath(tnom, period);
|
||||||
|
const exists = await pathExists(filePath);
|
||||||
|
|
||||||
|
console.log(`📁 exists: ${exists}`);
|
||||||
|
console.log('==========================================');
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
exists: exists,
|
||||||
|
tnom: tnom,
|
||||||
|
period: period,
|
||||||
|
path: filePath,
|
||||||
|
fileName: tnom + '.pdf'
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Ошибка проверки расчетного листка:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ ОБНОВЛЕННОЕ ПОЛУЧЕНИЕ URL (с period)
|
||||||
|
app.get('/api/payslip/url/:tnom', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var tnom = req.params.tnom;
|
||||||
|
var period = req.query.period || null; // ✅ Берем period из query-параметров
|
||||||
|
|
||||||
|
console.log('==========================================');
|
||||||
|
console.log(`📁 [PAYSLIP URL] Табельный номер: ${tnom}, Период: ${period || 'не указан'}`);
|
||||||
|
|
||||||
|
const filePath = await getPayslipFilePath(tnom, period);
|
||||||
|
console.log(`📁 Полный путь к файлу: ${filePath}`);
|
||||||
|
|
||||||
|
const exists = await pathExists(filePath);
|
||||||
|
console.log(`📁 exists: ${exists}`);
|
||||||
|
console.log('==========================================');
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
// ✅ Формируем URL для получения файла
|
||||||
|
let fileUrl = `/api/payslip/tnom/${tnom}`;
|
||||||
|
if (period) {
|
||||||
|
fileUrl += `?period=${period}`; // ✅ Добавляем period в query
|
||||||
|
}
|
||||||
|
logger.info(`[PAYSLIP URL] Файл найден для ${tnom} (период: ${period || 'корень'})`);
|
||||||
|
res.json({
|
||||||
|
exists: true,
|
||||||
|
url: fileUrl,
|
||||||
|
tnom: tnom,
|
||||||
|
period: period,
|
||||||
|
fileName: tnom + '.pdf'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.warn(`[PAYSLIP URL] Файл не найден для ${tnom} (период: ${period || 'корень'})`);
|
||||||
|
res.json({
|
||||||
|
exists: false,
|
||||||
|
message: 'Расчетный листок не найден',
|
||||||
|
tnom: tnom,
|
||||||
|
period: period
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Ошибка получения URL расчетного листка:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ ОБНОВЛЕННАЯ ОТДАЧА РАСЧЕТНОГО ЛИСТКА (с period)
|
||||||
|
app.get('/api/payslip/tnom/:tnom', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var tnom = req.params.tnom;
|
||||||
|
var period = req.query.period || null; // ✅ Берем period из query-параметров
|
||||||
|
|
||||||
|
console.log('==========================================');
|
||||||
|
console.log(`📁 [PAYSLIP] Запрос к /api/payslip/tnom/${tnom}, Период: ${period || 'не указан'}`);
|
||||||
|
|
||||||
|
const filePath = await getPayslipFilePath(tnom, period);
|
||||||
|
console.log(`📁 Полный путь к файлу: ${filePath}`);
|
||||||
|
|
||||||
|
// ✅ ЯВНО проверяем существование файла
|
||||||
|
try {
|
||||||
|
await fsPromises.access(filePath);
|
||||||
|
console.log('✅ Файл найден, начинаем отправку...');
|
||||||
|
} catch (err) {
|
||||||
|
console.log('❌ Файл НЕ найден:', err.message);
|
||||||
|
return res.status(404).json({
|
||||||
|
message: 'Расчетный листок не найден',
|
||||||
|
tnom: tnom,
|
||||||
|
period: period,
|
||||||
|
path: filePath,
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Добавляем правильные заголовки для PDF
|
||||||
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
|
res.setHeader('Content-Disposition', 'inline; filename="' + tnom + '.pdf"');
|
||||||
|
|
||||||
|
// ✅ Отправляем файл с обработкой ошибок
|
||||||
|
res.sendFile(filePath, function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error('❌ Ошибка при отправке файла:', err);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
return res.status(500).json({
|
||||||
|
message: 'Ошибка при отправке файла',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('✅ Файл успешно отправлен');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Ошибка получения расчетного листка:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 🗄️ СТАРЫЕ ЭНДПОИНТЫ РАСЧЕТНЫХ ЛИСТКОВ (для обратной совместимости)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
app.get('/api/payslip/:period/:fileName', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var period = req.params.period;
|
||||||
|
var fileName = req.params.fileName;
|
||||||
|
var configPath = path.join(__dirname, '../config.json');
|
||||||
|
var config = await readJson(configPath);
|
||||||
|
var payslipBasePath = config.payslipsPath;
|
||||||
|
|
||||||
|
logger.info('Payslip request - Period: ' + period + ', File: ' + fileName);
|
||||||
|
logger.info('Base path from config: ' + payslipBasePath);
|
||||||
|
|
||||||
|
var networkConnected = await connectToNetworkShare(config);
|
||||||
|
logger.info('Network share connection for payslip: ' + networkConnected);
|
||||||
|
|
||||||
|
var finalPayslipPath;
|
||||||
|
if (path.isAbsolute(payslipBasePath)) {
|
||||||
|
finalPayslipPath = path.join(payslipBasePath, period, fileName);
|
||||||
|
} else {
|
||||||
|
var projectRoot = path.resolve(__dirname, '../../');
|
||||||
|
finalPayslipPath = path.join(projectRoot, payslipBasePath, period, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Final payslip path: ' + finalPayslipPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
var basePathExists = await pathExists(payslipBasePath);
|
||||||
|
logger.info('Base path exists: ' + basePathExists);
|
||||||
|
|
||||||
|
if (basePathExists) {
|
||||||
|
var basePathStats = await fsPromises.stat(payslipBasePath);
|
||||||
|
logger.info('Base path stats: ' + JSON.stringify({
|
||||||
|
isDirectory: basePathStats.isDirectory(),
|
||||||
|
permissions: basePathStats.mode,
|
||||||
|
size: basePathStats.size
|
||||||
|
}));
|
||||||
|
|
||||||
|
var basePathContents = await fsPromises.readdir(payslipBasePath);
|
||||||
|
logger.info('Base path contents: ' + JSON.stringify(basePathContents));
|
||||||
|
|
||||||
|
var periodPath = path.join(payslipBasePath, period);
|
||||||
|
var periodPathExists = await pathExists(periodPath);
|
||||||
|
logger.info('Period path exists: ' + periodPathExists + ' (' + periodPath + ')');
|
||||||
|
|
||||||
|
if (periodPathExists) {
|
||||||
|
var periodPathContents = await fsPromises.readdir(periodPath);
|
||||||
|
logger.info('Period path contents: ' + JSON.stringify(periodPathContents));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (basePathError) {
|
||||||
|
logger.error('Error checking base path', {
|
||||||
|
error: basePathError.message,
|
||||||
|
stack: basePathError.stack
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await pathExists(finalPayslipPath)) {
|
||||||
|
logger.info('Payslip found, sending file: ' + finalPayslipPath);
|
||||||
|
res.sendFile(finalPayslipPath);
|
||||||
|
} else {
|
||||||
|
logger.warn('Payslip not found: ' + finalPayslipPath);
|
||||||
|
res.status(404).json({
|
||||||
|
message: 'Расчетный листок не найден',
|
||||||
|
path: finalPayslipPath,
|
||||||
|
period: period,
|
||||||
|
fileName: fileName,
|
||||||
|
basePath: payslipBasePath,
|
||||||
|
networkConnected: networkConnected
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Error getting payslip', { error: err.message, stack: err.stack });
|
||||||
|
res.status(500).json({ message: 'Ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/payslip/:fileName', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var fileName = req.params.fileName;
|
||||||
|
var configPath = path.join(__dirname, '../config.json');
|
||||||
|
var config = await readJson(configPath);
|
||||||
|
var payslipBasePath = config.payslipsPath;
|
||||||
|
|
||||||
|
var finalPayslipPath;
|
||||||
|
if (path.isAbsolute(payslipBasePath)) {
|
||||||
|
finalPayslipPath = path.join(payslipBasePath, fileName);
|
||||||
|
} else {
|
||||||
|
var projectRoot = path.resolve(__dirname, '../../');
|
||||||
|
finalPayslipPath = path.join(projectRoot, payslipBasePath, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Attempting to find payslip at resolved path: ' + finalPayslipPath);
|
||||||
|
|
||||||
|
if (await pathExists(finalPayslipPath)) {
|
||||||
|
res.sendFile(finalPayslipPath);
|
||||||
|
} else {
|
||||||
|
logger.warn('Payslip not found: ' + finalPayslipPath);
|
||||||
|
res.status(404).json({ message: 'Расчетный листок не найден' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Error getting payslip', { error: err.message, stack: err.stack });
|
||||||
|
res.status(500).json({ message: 'Ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 🗄️ ДРУГИЕ ЭНДПОИНТЫ
|
// 🗄️ ДРУГИЕ ЭНДПОИНТЫ
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -459,110 +749,6 @@ app.get('/api/test-network', async function(req, res) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/payslip/:period/:fileName', async function(req, res) {
|
|
||||||
try {
|
|
||||||
var period = req.params.period;
|
|
||||||
var fileName = req.params.fileName;
|
|
||||||
var configPath = path.join(__dirname, '../config.json');
|
|
||||||
var config = await readJson(configPath);
|
|
||||||
var payslipBasePath = config.payslipsPath;
|
|
||||||
|
|
||||||
logger.info('Payslip request - Period: ' + period + ', File: ' + fileName);
|
|
||||||
logger.info('Base path from config: ' + payslipBasePath);
|
|
||||||
|
|
||||||
var networkConnected = await connectToNetworkShare(config);
|
|
||||||
logger.info('Network share connection for payslip: ' + networkConnected);
|
|
||||||
|
|
||||||
var finalPayslipPath;
|
|
||||||
if (path.isAbsolute(payslipBasePath)) {
|
|
||||||
finalPayslipPath = path.join(payslipBasePath, period, fileName);
|
|
||||||
} else {
|
|
||||||
var projectRoot = path.resolve(__dirname, '../../');
|
|
||||||
finalPayslipPath = path.join(projectRoot, payslipBasePath, period, fileName);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('Final payslip path: ' + finalPayslipPath);
|
|
||||||
|
|
||||||
try {
|
|
||||||
var basePathExists = await pathExists(payslipBasePath);
|
|
||||||
logger.info('Base path exists: ' + basePathExists);
|
|
||||||
|
|
||||||
if (basePathExists) {
|
|
||||||
var basePathStats = await fsPromises.stat(payslipBasePath);
|
|
||||||
logger.info('Base path stats: ' + JSON.stringify({
|
|
||||||
isDirectory: basePathStats.isDirectory(),
|
|
||||||
permissions: basePathStats.mode,
|
|
||||||
size: basePathStats.size
|
|
||||||
}));
|
|
||||||
|
|
||||||
var basePathContents = await fsPromises.readdir(payslipBasePath);
|
|
||||||
logger.info('Base path contents: ' + JSON.stringify(basePathContents));
|
|
||||||
|
|
||||||
var periodPath = path.join(payslipBasePath, period);
|
|
||||||
var periodPathExists = await pathExists(periodPath);
|
|
||||||
logger.info('Period path exists: ' + periodPathExists + ' (' + periodPath + ')');
|
|
||||||
|
|
||||||
if (periodPathExists) {
|
|
||||||
var periodPathContents = await fsPromises.readdir(periodPath);
|
|
||||||
logger.info('Period path contents: ' + JSON.stringify(periodPathContents));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (basePathError) {
|
|
||||||
logger.error('Error checking base path', {
|
|
||||||
error: basePathError.message,
|
|
||||||
stack: basePathError.stack
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (await pathExists(finalPayslipPath)) {
|
|
||||||
logger.info('Payslip found, sending file: ' + finalPayslipPath);
|
|
||||||
res.sendFile(finalPayslipPath);
|
|
||||||
} else {
|
|
||||||
logger.warn('Payslip not found: ' + finalPayslipPath);
|
|
||||||
res.status(404).json({
|
|
||||||
message: 'Расчетный листок не найден',
|
|
||||||
path: finalPayslipPath,
|
|
||||||
period: period,
|
|
||||||
fileName: fileName,
|
|
||||||
basePath: payslipBasePath,
|
|
||||||
networkConnected: networkConnected
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('Error getting payslip', { error: err.message, stack: err.stack });
|
|
||||||
res.status(500).json({ message: 'Ошибка сервера' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/payslip/:fileName', async function(req, res) {
|
|
||||||
try {
|
|
||||||
var fileName = req.params.fileName;
|
|
||||||
var configPath = path.join(__dirname, '../config.json');
|
|
||||||
var config = await readJson(configPath);
|
|
||||||
var payslipBasePath = config.payslipsPath;
|
|
||||||
|
|
||||||
var finalPayslipPath;
|
|
||||||
if (path.isAbsolute(payslipBasePath)) {
|
|
||||||
finalPayslipPath = path.join(payslipBasePath, fileName);
|
|
||||||
} else {
|
|
||||||
var projectRoot = path.resolve(__dirname, '../../');
|
|
||||||
finalPayslipPath = path.join(projectRoot, payslipBasePath, fileName);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('Attempting to find payslip at resolved path: ' + finalPayslipPath);
|
|
||||||
|
|
||||||
if (await pathExists(finalPayslipPath)) {
|
|
||||||
res.sendFile(finalPayslipPath);
|
|
||||||
} else {
|
|
||||||
logger.warn('Payslip not found: ' + finalPayslipPath);
|
|
||||||
res.status(404).json({ message: 'Расчетный листок не найден' });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('Error getting payslip', { error: err.message, stack: err.stack });
|
|
||||||
res.status(500).json({ message: 'Ошибка сервера' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/db-status', async function(req, res) {
|
app.get('/api/db-status', async function(req, res) {
|
||||||
try {
|
try {
|
||||||
var db = require('./config/db');
|
var db = require('./config/db');
|
||||||
@@ -583,6 +769,13 @@ app.get('/api/time', function(req, res) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 📄 РАСЧЕТНЫЙ ЛИСТОК (ФОРМА ДЛЯ ПЕЧАТИ)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const payslipFormRoutes = require('./routes/payslipForm');
|
||||||
|
app.use('/api/payslip', payslipFormRoutes);
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 🚀 ЗАПУСК СЕРВЕРА
|
// 🚀 ЗАПУСК СЕРВЕРА
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -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",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
"@types/react": "^19.2.0",
|
"@types/react": "^19.2.0",
|
||||||
"@types/react-dom": "^19.2.0",
|
"@types/react-dom": "^19.2.0",
|
||||||
"axios": "^1.19.0",
|
"axios": "^1.20.0",
|
||||||
"date-fns": "^2.30.0",
|
"date-fns": "^2.30.0",
|
||||||
"dompurify": "^3.4.13",
|
"dompurify": "^3.4.13",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"electron-is-dev": "^2.0.0",
|
"electron-is-dev": "^2.0.0",
|
||||||
"framer-motion": "^13.1.0",
|
"framer-motion": "^13.1.0",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
|
"pdf-to-printer": "^5.8.1",
|
||||||
"pdfjs-dist": "^3.11.174",
|
"pdfjs-dist": "^3.11.174",
|
||||||
|
"pdfkit": "^0.20.2",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-icons": "^4.11.0",
|
"react-icons": "^4.11.0",
|
||||||
"react-pdf": "^8.0.0",
|
"react-pdf": "^8.0.0",
|
||||||
"react-zoom-pan-pinch": "^3.3.0",
|
"react-zoom-pan-pinch": "^3.3.0",
|
||||||
|
"sharp": "^0.35.4",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
"web-vitals": "^2.1.4"
|
"web-vitals": "^2.1.4",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "vite",
|
"start": "vite",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 187 KiB |
@@ -3,11 +3,11 @@ const path = require('path');
|
|||||||
require('dotenv').config({path: path.join(__dirname, 'config.env')})
|
require('dotenv').config({path: path.join(__dirname, 'config.env')})
|
||||||
const isDev = require('electron-is-dev');
|
const isDev = require('electron-is-dev');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const COM_PORT = process.env.COM_PORT;
|
|
||||||
const COM_SPEED = process.env.COM_SPEED;
|
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const { logger } = require('../../backend/src/config/logger');
|
const { logger } = require('../../backend/src/config/logger');
|
||||||
const config = require('../src/config.json')
|
const config = require('../src/config.json')
|
||||||
|
const { print } = require('pdf-to-printer');
|
||||||
|
|
||||||
protocol.registerSchemesAsPrivileged([
|
protocol.registerSchemesAsPrivileged([
|
||||||
{
|
{
|
||||||
scheme: 'app',
|
scheme: 'app',
|
||||||
@@ -64,13 +64,12 @@ function createWindow() {
|
|||||||
|
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
kiosk: true,
|
kiosk: true,
|
||||||
titleBarStyle:'hidden',
|
titleBarStyle: 'hidden',
|
||||||
alwaysOnTop: false,
|
alwaysOnTop: false,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: preloadPath,
|
preload: preloadPath,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false
|
nodeIntegration: false
|
||||||
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -91,6 +90,98 @@ function createWindow() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 📄 ПЕЧАТЬ PDF ЧЕРЕЗ pdf-to-printer
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
ipcMain.handle('print-pdf', async (event, data = {}) => {
|
||||||
|
let tempFile = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!data.pdfUrl) {
|
||||||
|
throw new Error('URL файла PDF не передан');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanUrl = data.pdfUrl.split('#')[0];
|
||||||
|
logger.info('[PRINT] Загрузка PDF:', cleanUrl);
|
||||||
|
|
||||||
|
// ✅ Скачиваем PDF
|
||||||
|
const response = await axios.get(cleanUrl, {
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
timeout: 30000
|
||||||
|
});
|
||||||
|
|
||||||
|
const pdfBuffer = Buffer.from(response.data);
|
||||||
|
logger.info('[PRINT] Размер PDF:', pdfBuffer.length, 'байт');
|
||||||
|
|
||||||
|
if (pdfBuffer.length < 100) {
|
||||||
|
throw new Error('PDF слишком маленький или пустой');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Сохраняем во временный файл
|
||||||
|
const tempDir = app.getPath('temp');
|
||||||
|
tempFile = path.join(tempDir, `print_${Date.now()}.pdf`);
|
||||||
|
fs.writeFileSync(tempFile, pdfBuffer);
|
||||||
|
logger.info('[PRINT] Временный файл создан:', tempFile);
|
||||||
|
|
||||||
|
// ✅ ПЕЧАТАЕМ ЧЕРЕЗ pdf-to-printer
|
||||||
|
const printerName = 'GP-80250 Series';
|
||||||
|
logger.info('[PRINT] Отправляем на печать через pdf-to-printer:', printerName);
|
||||||
|
|
||||||
|
// ✅ Опции печати
|
||||||
|
const options = {
|
||||||
|
printer: printerName,
|
||||||
|
// scale: 'fit', // Масштабирование
|
||||||
|
// paperSize: 'A4',
|
||||||
|
// landscape: false,
|
||||||
|
// copies: 1,
|
||||||
|
// silent: true // Тихая печать
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await print(tempFile, options);
|
||||||
|
logger.info('[PRINT] Результат печати:', result);
|
||||||
|
|
||||||
|
// ✅ Удаляем временный файл
|
||||||
|
try {
|
||||||
|
if (tempFile && fs.existsSync(tempFile)) {
|
||||||
|
fs.unlinkSync(tempFile);
|
||||||
|
logger.info('[PRINT] Временный файл удален');
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
logger.info('[PRINT] ✅ Документ отправлен на печать');
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[PRINT] ❌ Ошибка:', error.message);
|
||||||
|
try {
|
||||||
|
if (tempFile && fs.existsSync(tempFile)) {
|
||||||
|
fs.unlinkSync(tempFile);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 📄 ПОЛУЧЕНИЕ СПИСКА ПРИНТЕРОВ
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
ipcMain.handle('get-printers', async () => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'GP-80250 Series',
|
||||||
|
displayName: 'GP-80250 Series',
|
||||||
|
isDefault: true,
|
||||||
|
status: 'ready'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 🚀 ЗАПУСК
|
||||||
|
// ============================================
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
createWindow();
|
createWindow();
|
||||||
global.allowMinimize = false;
|
global.allowMinimize = false;
|
||||||
@@ -112,14 +203,13 @@ app.whenReady().then(() => {
|
|||||||
}
|
}
|
||||||
}, 10);
|
}, 10);
|
||||||
});
|
});
|
||||||
|
|
||||||
globalShortcut.register('Control+Alt+Enter', () => {
|
globalShortcut.register('Control+Alt+Enter', () => {
|
||||||
global.allowClose = true;
|
global.allowClose = true;
|
||||||
app.quit();
|
app.quit();
|
||||||
});
|
});
|
||||||
|
|
||||||
globalShortcut.register('F11', () => {
|
globalShortcut.register('F11', () => {});
|
||||||
});
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
@@ -138,12 +228,6 @@ app.on('will-quit', () => {
|
|||||||
globalShortcut.unregisterAll();
|
globalShortcut.unregisterAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
//считыватель
|
|
||||||
|
|
||||||
ipcMain.handle('get-app-config', async (event) => {
|
ipcMain.handle('get-app-config', async (event) => {
|
||||||
console.log('[IPC] Renderer requested app config. Sending:', appConfig);
|
return appConfig;
|
||||||
return appConfig;
|
|
||||||
});
|
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
const { logger } = require('../../backend/src/config/logger');
|
|
||||||
logger.info('[Preload] Script started.');
|
|
||||||
const { contextBridge, ipcRenderer } = require('electron');
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { logger } = require('../../backend/src/config/logger');
|
||||||
|
logger.info('[Preload] Script started.');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Preload] Логгер не загрузился, но едем дальше.', e);
|
||||||
|
}
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld(
|
contextBridge.exposeInMainWorld(
|
||||||
'electronAPI',
|
'electronAPI',
|
||||||
{
|
{
|
||||||
@@ -8,12 +14,13 @@ contextBridge.exposeInMainWorld(
|
|||||||
onCardDetected: (callback) => {
|
onCardDetected: (callback) => {
|
||||||
ipcRenderer.on('card-detected', (event, uid) => callback(uid));
|
ipcRenderer.on('card-detected', (event, uid) => callback(uid));
|
||||||
},
|
},
|
||||||
|
|
||||||
removeCardDetectedListeners: () => {
|
removeCardDetectedListeners: () => {
|
||||||
ipcRenderer.removeAllListeners('card-detected');
|
ipcRenderer.removeAllListeners('card-detected');
|
||||||
}
|
},
|
||||||
|
printPdf: (options) => ipcRenderer.invoke('print-pdf', options),
|
||||||
|
getPrinters: () => ipcRenderer.invoke('get-printers'),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.log('[Preload] Script finished. electronAPI exposed.');
|
console.log('[Preload] Script finished. electronAPI exposed.');
|
||||||
window.dispatchEvent(new Event('electronApiReady'));
|
window.dispatchEvent(new Event('electronApiReady'));
|
||||||
@@ -45,6 +45,7 @@ function App() {
|
|||||||
const [currentSection, setCurrentSection] = useState<string | null>(null);
|
const [currentSection, setCurrentSection] = useState<string | null>(null);
|
||||||
const [showPayslip, setShowPayslip] = useState(false);
|
const [showPayslip, setShowPayslip] = useState(false);
|
||||||
const [selectedPeriod, setSelectedPeriod] = useState('');
|
const [selectedPeriod, setSelectedPeriod] = useState('');
|
||||||
|
const [payslipFileUrl, setPayslipFileUrl] = useState<string>('');
|
||||||
|
|
||||||
// 📊 Состояния для таймера
|
// 📊 Состояния для таймера
|
||||||
const [showInactiveWarning, setShowInactiveWarning] = useState(false);
|
const [showInactiveWarning, setShowInactiveWarning] = useState(false);
|
||||||
@@ -64,6 +65,7 @@ function App() {
|
|||||||
setCurrentSection(null);
|
setCurrentSection(null);
|
||||||
setShowPayslip(false);
|
setShowPayslip(false);
|
||||||
setSelectedPeriod('');
|
setSelectedPeriod('');
|
||||||
|
setPayslipFileUrl('');
|
||||||
setShowInactiveWarning(false);
|
setShowInactiveWarning(false);
|
||||||
setLoginError('');
|
setLoginError('');
|
||||||
|
|
||||||
@@ -138,6 +140,7 @@ function App() {
|
|||||||
setCurrentSection(null);
|
setCurrentSection(null);
|
||||||
setShowPayslip(false);
|
setShowPayslip(false);
|
||||||
setSelectedPeriod('');
|
setSelectedPeriod('');
|
||||||
|
setPayslipFileUrl('');
|
||||||
handleUserActivity();
|
handleUserActivity();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -146,8 +149,8 @@ function App() {
|
|||||||
handleUserActivity();
|
handleUserActivity();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleViewPayslip = (period: string) => {
|
const handleViewPayslip = (fileUrl: string) => {
|
||||||
setSelectedPeriod(period);
|
setPayslipFileUrl(fileUrl);
|
||||||
setShowPayslip(true);
|
setShowPayslip(true);
|
||||||
handleUserActivity();
|
handleUserActivity();
|
||||||
};
|
};
|
||||||
@@ -155,14 +158,10 @@ function App() {
|
|||||||
const handlePayslipBack = () => {
|
const handlePayslipBack = () => {
|
||||||
setShowPayslip(false);
|
setShowPayslip(false);
|
||||||
setSelectedPeriod('');
|
setSelectedPeriod('');
|
||||||
|
setPayslipFileUrl('');
|
||||||
handleUserActivity();
|
handleUserActivity();
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPayslipUrl = (employee: Employee, period: string) => {
|
|
||||||
const fileName = `${employee.tnom}.pdf`;
|
|
||||||
return `${config.backendUrl}/api/payslip/${period}/${fileName}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// ⚠️ Предупреждение о неактивности
|
// ⚠️ Предупреждение о неактивности
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -279,7 +278,7 @@ function App() {
|
|||||||
if (showPayslip) {
|
if (showPayslip) {
|
||||||
return (
|
return (
|
||||||
<PayslipViewer
|
<PayslipViewer
|
||||||
fileUrl={employee && selectedPeriod ? getPayslipUrl(employee, selectedPeriod) : "/payslips/test.pdf"}
|
fileUrl={payslipFileUrl || "/payslips/test.pdf"}
|
||||||
onBack={handlePayslipBack}
|
onBack={handlePayslipBack}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -316,7 +315,7 @@ function App() {
|
|||||||
// 🚀 Рендер приложения
|
// 🚀 Рендер приложения
|
||||||
// ============================================
|
// ============================================
|
||||||
const showTopBar = !showVideo && !showPayslip;
|
const showTopBar = !showVideo && !showPayslip;
|
||||||
const topBarHeight = 70; // высота виджета
|
const topBarHeight = 70;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -331,9 +330,9 @@ function App() {
|
|||||||
{/* ⚠️ Предупреждение о неактивности */}
|
{/* ⚠️ Предупреждение о неактивности */}
|
||||||
{renderWarning()}
|
{renderWarning()}
|
||||||
|
|
||||||
{/* 🌤️ ВЕРХНИЙ ВИДЖЕТ: ПОГОДА + ВРЕМЯ + ВЫХОД */}
|
{/* 🌤️ ВЕРХНИЙ ВИДЖЕТ: ПОГОДА + ВРЕМЯ + ПРИВЕТСТВИЕ + ВЫХОД */}
|
||||||
{showTopBar && (
|
{showTopBar && (
|
||||||
<WeatherWidget onLogout={handleBack} />
|
<WeatherWidget onLogout={handleBack} employee={employee || undefined} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 📄 Основной контент с отступом сверху */}
|
{/* 📄 Основной контент с отступом сверху */}
|
||||||
@@ -348,7 +347,7 @@ function App() {
|
|||||||
{renderContent()}
|
{renderContent()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 📊 НИЖНЯЯ ПАНЕЛЬ: КУРСЫ ВАЛЮТ */}
|
{/* 📊 НИЖНЯЯ ПАНЕЛЬ: ТОЛЬКО КУРСЫ ВАЛЮТ (БЕЗ ПРИВЕТСТВИЯ) */}
|
||||||
{showTopBar && (
|
{showTopBar && (
|
||||||
<BottomBar />
|
<BottomBar />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback, memo } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { FaArrowLeft, FaSearch, FaArrowUp, FaArrowDown } from 'react-icons/fa';
|
import { FaArrowLeft, FaSearch, FaArrowUp, FaArrowDown } from 'react-icons/fa';
|
||||||
import MapViewer from './MapViewer';
|
import MapViewer from './MapViewer';
|
||||||
@@ -75,7 +75,7 @@ function parseHandbookBlocks(html: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 📚 Функции рендеринга с подсветкой и поиском
|
// 📚 Функции рендеринга
|
||||||
// ============================================
|
// ============================================
|
||||||
const ADMIN_SERVER_URL = config.adminServerUrl || 'http://localhost:3005';
|
const ADMIN_SERVER_URL = config.adminServerUrl || 'http://localhost:3005';
|
||||||
|
|
||||||
@@ -85,185 +85,164 @@ function patchMediaSrc(html: string): string {
|
|||||||
.replace(/<video([^>]+)src=["'](\/uploads\/[^"']+)["']/g, `<video$1src="${ADMIN_SERVER_URL}$2"`);
|
.replace(/<video([^>]+)src=["'](\/uploads\/[^"']+)["']/g, `<video$1src="${ADMIN_SERVER_URL}$2"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripHtml(html: string): string {
|
// ✅ Простая подсветка через replace (без DOM-обхода)
|
||||||
const tmp = document.createElement('div');
|
function highlightText(html: string, searchTerm: string): string {
|
||||||
tmp.innerHTML = html;
|
if (!searchTerm.trim()) return html;
|
||||||
return tmp.textContent || tmp.innerText || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ Подсветка текста внутри HTML с сохранением разметки
|
|
||||||
function highlightTextInHtml(html: string, searchTerm: string): { html: string; count: number } {
|
|
||||||
if (!searchTerm.trim()) return { html, count: 0 };
|
|
||||||
|
|
||||||
const term = searchTerm.trim();
|
const term = searchTerm.trim();
|
||||||
const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
const regex = new RegExp(`(${escapedTerm})`, 'gi');
|
const regex = new RegExp(`(${escapedTerm})`, 'gi');
|
||||||
|
|
||||||
const temp = document.createElement('div');
|
return html.replace(/>([^<]*)</g, (match: string, text: string) => {
|
||||||
temp.innerHTML = html;
|
const highlighted = text.replace(regex, (m: string) =>
|
||||||
let matchCount = 0;
|
`<mark style="background: #ffdd57; color: #1a3c34; padding: 0 4px; border-radius: 3px; font-weight: 600;">${m}</mark>`
|
||||||
|
|
||||||
function walkNodes(node: Node): void {
|
|
||||||
if (node.nodeType === Node.TEXT_NODE) {
|
|
||||||
const text = node.textContent || '';
|
|
||||||
if (text.toLowerCase().includes(term.toLowerCase())) {
|
|
||||||
const fragment = document.createDocumentFragment();
|
|
||||||
let lastIndex = 0;
|
|
||||||
let match;
|
|
||||||
const tempRegex = new RegExp(escapedTerm, 'gi');
|
|
||||||
|
|
||||||
while ((match = tempRegex.exec(text)) !== null) {
|
|
||||||
if (match.index > lastIndex) {
|
|
||||||
fragment.appendChild(document.createTextNode(text.substring(lastIndex, match.index)));
|
|
||||||
}
|
|
||||||
const mark = document.createElement('mark');
|
|
||||||
mark.style.cssText = 'background: #ffdd57; color: #1a3c34; padding: 0 4px; border-radius: 3px; font-weight: 600;';
|
|
||||||
mark.dataset.matchIndex = String(matchCount);
|
|
||||||
mark.textContent = match[0];
|
|
||||||
fragment.appendChild(mark);
|
|
||||||
lastIndex = match.index + match[0].length;
|
|
||||||
matchCount++;
|
|
||||||
}
|
|
||||||
if (lastIndex < text.length) {
|
|
||||||
fragment.appendChild(document.createTextNode(text.substring(lastIndex)));
|
|
||||||
}
|
|
||||||
node.parentNode?.replaceChild(fragment, node);
|
|
||||||
}
|
|
||||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
||||||
const children = Array.from(node.childNodes);
|
|
||||||
for (const child of children) {
|
|
||||||
walkNodes(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
walkNodes(temp);
|
|
||||||
return { html: temp.innerHTML, count: matchCount };
|
|
||||||
}
|
|
||||||
|
|
||||||
function blockMatchesSearch(block: any, searchTerm: string): boolean {
|
|
||||||
if (!searchTerm.trim()) return true;
|
|
||||||
const term = searchTerm.trim().toLowerCase();
|
|
||||||
if (typeof block === 'string') {
|
|
||||||
return stripHtml(block).toLowerCase().includes(term);
|
|
||||||
} else if (block.type === 'phones') {
|
|
||||||
return block.rows.some((row: { desc: string; internal: string; city: string }) =>
|
|
||||||
row.desc.toLowerCase().includes(term) ||
|
|
||||||
row.internal.includes(term) ||
|
|
||||||
row.city.includes(term)
|
|
||||||
);
|
);
|
||||||
}
|
return `>${highlighted}<`;
|
||||||
return true;
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function countMatches(html: string, searchTerm: string): number {
|
||||||
|
if (!searchTerm.trim()) return 0;
|
||||||
|
const term = searchTerm.trim();
|
||||||
|
const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const regex = new RegExp(escapedTerm, 'gi');
|
||||||
|
const matches = html.match(regex);
|
||||||
|
return matches ? matches.length : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 📦 Компонент для рендеринга справочника
|
// 📦 Компонент для рендеринга справочника (ОПТИМИЗИРОВАННЫЙ)
|
||||||
// ============================================
|
// ============================================
|
||||||
const HandbookRenderer: React.FC<{
|
const HandbookRenderer = memo(({
|
||||||
blocks: any[];
|
blocks,
|
||||||
searchTerm: string;
|
searchTerm,
|
||||||
|
onMatchCount
|
||||||
|
}: {
|
||||||
|
blocks: any[];
|
||||||
|
searchTerm: string;
|
||||||
onMatchCount: (count: number) => void;
|
onMatchCount: (count: number) => void;
|
||||||
}> = ({ blocks, searchTerm, onMatchCount }) => {
|
}) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [renderedHtml, setRenderedHtml] = useState<{ html: string; count: number }[]>([]);
|
const [renderedBlocks, setRenderedBlocks] = useState<any[]>([]);
|
||||||
const [totalMatches, setTotalMatches] = useState(0);
|
const [totalMatches, setTotalMatches] = useState(0);
|
||||||
const hasSearch = searchTerm.trim().length > 0;
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const processingRef = useRef(false);
|
||||||
|
|
||||||
|
// ✅ Обработка блоков с задержкой через requestAnimationFrame
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!blocks.length) return;
|
if (!blocks.length) return;
|
||||||
|
|
||||||
const result: { html: string; count: number }[] = [];
|
// ✅ Если идет обработка — пропускаем
|
||||||
let total = 0;
|
if (processingRef.current) return;
|
||||||
|
processingRef.current = true;
|
||||||
|
|
||||||
blocks.forEach(block => {
|
// ✅ Показываем индикатор поиска
|
||||||
if (typeof block === 'string') {
|
setIsSearching(true);
|
||||||
const content = patchMediaSrc(block);
|
|
||||||
if (hasSearch) {
|
|
||||||
const highlighted = highlightTextInHtml(content, searchTerm);
|
|
||||||
result.push({ html: highlighted.html, count: highlighted.count });
|
|
||||||
total += highlighted.count;
|
|
||||||
} else {
|
|
||||||
result.push({ html: content, count: 0 });
|
|
||||||
}
|
|
||||||
} else if (block.type === 'phones') {
|
|
||||||
const term = searchTerm.trim().toLowerCase();
|
|
||||||
let rows = block.rows;
|
|
||||||
if (hasSearch && term) {
|
|
||||||
rows = rows.filter((row: { desc: string; internal: string; city: string }) =>
|
|
||||||
row.desc.toLowerCase().includes(term) ||
|
|
||||||
row.internal.includes(term) ||
|
|
||||||
row.city.includes(term)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (rows.length === 0) {
|
|
||||||
result.push({ html: '', count: 0 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let tableHtml = `<table class="handbook-table">`;
|
// ✅ Используем requestAnimationFrame для отложенной обработки
|
||||||
tableHtml += `
|
requestAnimationFrame(() => {
|
||||||
<thead>
|
// ✅ Еще одна задержка через setTimeout для накопления изменений
|
||||||
<tr>
|
const timeoutId = setTimeout(() => {
|
||||||
<th>Описание</th>
|
const processed = blocks.map(block => {
|
||||||
<th>АТС предприятия</th>
|
if (typeof block === 'string') {
|
||||||
<th>Городская АТС</th>
|
const content = patchMediaSrc(block);
|
||||||
</tr>
|
if (searchTerm.trim()) {
|
||||||
</thead>
|
return highlightText(content, searchTerm);
|
||||||
<tbody>`;
|
}
|
||||||
|
return content;
|
||||||
|
} else if (block.type === 'phones') {
|
||||||
|
const term = searchTerm.trim().toLowerCase();
|
||||||
|
let rows = block.rows;
|
||||||
|
if (term) {
|
||||||
|
rows = rows.filter((row: { desc: string; internal: string; city: string }) =>
|
||||||
|
row.desc.toLowerCase().includes(term) ||
|
||||||
|
row.internal.includes(term) ||
|
||||||
|
row.city.includes(term)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
rows.forEach((row: { desc: string; internal: string; city: string }) => {
|
let tableHtml = `<table class="handbook-table">`;
|
||||||
let descHtml = row.desc;
|
tableHtml += `
|
||||||
let internalHtml = row.internal;
|
<thead>
|
||||||
let cityHtml = row.city;
|
<tr>
|
||||||
let rowHasMatch = false;
|
<th>Описание</th>
|
||||||
|
<th>АТС предприятия</th>
|
||||||
|
<th>Городская АТС</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>`;
|
||||||
|
|
||||||
if (hasSearch && term) {
|
rows.forEach((row: { desc: string; internal: string; city: string }) => {
|
||||||
const descResult = highlightTextInHtml(row.desc, searchTerm);
|
let descHtml = row.desc;
|
||||||
descHtml = descResult.html;
|
let internalHtml = row.internal;
|
||||||
if (descResult.count > 0) rowHasMatch = true;
|
let cityHtml = row.city;
|
||||||
const internalResult = highlightTextInHtml(row.internal, searchTerm);
|
|
||||||
internalHtml = internalResult.html;
|
if (term) {
|
||||||
if (internalResult.count > 0) rowHasMatch = true;
|
descHtml = highlightText(row.desc, searchTerm);
|
||||||
const cityResult = highlightTextInHtml(row.city, searchTerm);
|
internalHtml = highlightText(row.internal, searchTerm);
|
||||||
cityHtml = cityResult.html;
|
cityHtml = highlightText(row.city, searchTerm);
|
||||||
if (cityResult.count > 0) rowHasMatch = true;
|
}
|
||||||
total += descResult.count + internalResult.count + cityResult.count;
|
|
||||||
|
tableHtml += `
|
||||||
|
<tr>
|
||||||
|
<td>${descHtml}</td>
|
||||||
|
<td>${internalHtml}</td>
|
||||||
|
<td>${cityHtml}</td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
tableHtml += `</tbody></table>`;
|
||||||
|
return tableHtml;
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
}).filter(Boolean);
|
||||||
|
|
||||||
tableHtml += `
|
setRenderedBlocks(processed);
|
||||||
<tr>
|
setIsSearching(false);
|
||||||
<td>${descHtml}</td>
|
processingRef.current = false;
|
||||||
<td>${internalHtml}</td>
|
|
||||||
<td>${cityHtml}</td>
|
|
||||||
</tr>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
tableHtml += `</tbody></table>`;
|
// ✅ Подсчет совпадений
|
||||||
result.push({ html: tableHtml, count: 0 });
|
if (searchTerm.trim()) {
|
||||||
}
|
let total = 0;
|
||||||
|
processed.forEach(block => {
|
||||||
|
if (typeof block === 'string') {
|
||||||
|
total += countMatches(block, searchTerm);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setTotalMatches(total);
|
||||||
|
onMatchCount(total);
|
||||||
|
} else {
|
||||||
|
setTotalMatches(0);
|
||||||
|
onMatchCount(0);
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
});
|
});
|
||||||
|
|
||||||
setRenderedHtml(result);
|
|
||||||
setTotalMatches(total);
|
|
||||||
onMatchCount(total);
|
|
||||||
}, [blocks, searchTerm, onMatchCount]);
|
}, [blocks, searchTerm, onMatchCount]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!containerRef.current) return;
|
|
||||||
const marks = containerRef.current.querySelectorAll('mark');
|
|
||||||
marks.forEach((el, idx) => {
|
|
||||||
el.dataset.matchIndex = String(idx);
|
|
||||||
});
|
|
||||||
}, [renderedHtml]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="handbook-content">
|
<div ref={containerRef} className="handbook-content">
|
||||||
{renderedHtml.map((item, idx) => (
|
{isSearching && (
|
||||||
item.html ? <div key={idx} style={{ marginBottom: 16 }} dangerouslySetInnerHTML={{ __html: item.html }} /> : null
|
<div style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '8px',
|
||||||
|
color: '#636e72',
|
||||||
|
fontSize: '13px',
|
||||||
|
borderBottom: '1px solid #f1f2f6',
|
||||||
|
marginBottom: '16px'
|
||||||
|
}}>
|
||||||
|
<span style={{ display: 'inline-block', animation: 'spin 1s linear infinite', marginRight: '8px' }}>⟳</span>
|
||||||
|
Поиск...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{renderedBlocks.map((block, idx) => (
|
||||||
|
<div key={idx} style={{ marginBottom: 16 }} dangerouslySetInnerHTML={{ __html: block }} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 🏠 Основной компонент Contacts
|
// 🏠 Основной компонент Contacts
|
||||||
@@ -279,6 +258,10 @@ const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
|
|||||||
const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
|
const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// ✅ Debounce для поиска (300ms)
|
||||||
|
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('');
|
||||||
|
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
const MAP_FILE_URL = '/assets/maps/enterprise-map.tiff';
|
const MAP_FILE_URL = '/assets/maps/enterprise-map.tiff';
|
||||||
|
|
||||||
@@ -288,6 +271,26 @@ const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
|
|||||||
}
|
}
|
||||||
}, [activeTab]);
|
}, [activeTab]);
|
||||||
|
|
||||||
|
// ✅ DEBOUNCE: обновляем debouncedSearchTerm с задержкой 300ms
|
||||||
|
useEffect(() => {
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
debounceTimerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
debounceTimerRef.current = setTimeout(() => {
|
||||||
|
setDebouncedSearchTerm(searchTerm);
|
||||||
|
debounceTimerRef.current = null;
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
debounceTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [searchTerm]);
|
||||||
|
|
||||||
const loadHandbook = async () => {
|
const loadHandbook = async () => {
|
||||||
setLoadingHandbook(true);
|
setLoadingHandbook(true);
|
||||||
setErrorHandbook(null);
|
setErrorHandbook(null);
|
||||||
@@ -329,10 +332,10 @@ const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
|
|||||||
const container = contentRef.current;
|
const container = contentRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const marks = container.querySelectorAll('mark[data-match-index]');
|
const marks = container.querySelectorAll('mark');
|
||||||
if (marks.length === 0) return;
|
if (marks.length === 0) return;
|
||||||
|
|
||||||
const targetMark = Array.from(marks).find((el: Element) => el.getAttribute('data-match-index') === String(index));
|
const targetMark = marks[index];
|
||||||
if (!targetMark) return;
|
if (!targetMark) return;
|
||||||
|
|
||||||
const containerRect = container.getBoundingClientRect();
|
const containerRect = container.getBoundingClientRect();
|
||||||
@@ -629,7 +632,6 @@ const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ✅ БЕЛЫЙ ФОН, ТЁМНЫЙ ТЕКСТ */}
|
|
||||||
<div
|
<div
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
style={{
|
style={{
|
||||||
@@ -676,10 +678,8 @@ const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
|
|||||||
) : blocks.length > 0 ? (
|
) : blocks.length > 0 ? (
|
||||||
<HandbookRenderer
|
<HandbookRenderer
|
||||||
blocks={blocks}
|
blocks={blocks}
|
||||||
searchTerm={searchTerm}
|
searchTerm={debouncedSearchTerm}
|
||||||
onMatchCount={(count) => {
|
onMatchCount={setMatchCount}
|
||||||
setMatchCount(count);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p style={{ textAlign: 'center', color: '#b2bec3' }}>Нет данных для отображения</p>
|
<p style={{ textAlign: 'center', color: '#b2bec3' }}>Нет данных для отображения</p>
|
||||||
@@ -744,7 +744,6 @@ const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ✅ ТЁМНЫЙ ТЕКСТ ДЛЯ СПРАВОЧНИКА */}
|
|
||||||
<style>
|
<style>
|
||||||
{`
|
{`
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
@@ -800,10 +799,6 @@ const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
|
|||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
transition: outline 0.3s ease;
|
|
||||||
}
|
|
||||||
mark[data-match-index] {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { FaArrowLeft, FaSpinner, FaEye, FaPrint, FaTimes, FaExpand, FaCompress, FaChevronUp, FaChevronDown, FaCheckCircle, FaExclamationCircle } from 'react-icons/fa';
|
||||||
|
import config from '../config.json';
|
||||||
|
|
||||||
interface Employee {
|
interface Employee {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -31,7 +34,22 @@ const EmployeeInfo: React.FC<EmployeeInfoProps> = ({
|
|||||||
return `${month}${year}`;
|
return `${month}${year}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Генерируем список периодов за последние 12 месяцев
|
const [showPdfPreview, setShowPdfPreview] = useState(false);
|
||||||
|
const [pdfLoading, setPdfLoading] = useState(false);
|
||||||
|
const [pdfError, setPdfError] = useState<string | null>(null);
|
||||||
|
const [payslipUrl, setPayslipUrl] = useState<string | null>(null);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
|
const [hasPayslip, setHasPayslip] = useState<boolean | null>(null);
|
||||||
|
const [checkingPayslip, setCheckingPayslip] = useState(false);
|
||||||
|
|
||||||
|
const [isPrinting, setIsPrinting] = useState(false);
|
||||||
|
const [printStatus, setPrintStatus] = useState<'idle' | 'success' | 'error' | 'sending'>('idle');
|
||||||
|
const [printMessage, setPrintMessage] = useState('');
|
||||||
|
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
|
|
||||||
const generatePeriods = () => {
|
const generatePeriods = () => {
|
||||||
const periods = [];
|
const periods = [];
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -50,52 +68,677 @@ const EmployeeInfo: React.FC<EmployeeInfoProps> = ({
|
|||||||
const monthNames = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
|
const monthNames = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
|
||||||
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'];
|
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'];
|
||||||
|
|
||||||
return (
|
const getFullUrl = (url: string) => {
|
||||||
<div className="employee-info">
|
return url.startsWith('http') ? url : `http://localhost:3001${url}`;
|
||||||
<div className="top-bar">
|
};
|
||||||
<button className="back-button" onClick={onBack}>
|
|
||||||
← Назад
|
const checkPayslip = async () => {
|
||||||
</button>
|
if (!employee?.tnom) return;
|
||||||
</div>
|
|
||||||
|
try {
|
||||||
|
setCheckingPayslip(true);
|
||||||
|
const backendUrl = config.backendUrl || 'http://localhost:3001';
|
||||||
|
const response = await fetch(`${backendUrl}/api/payslip/url/${employee.tnom}?period=${selectedPeriod}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setHasPayslip(data.exists);
|
||||||
|
if (data.exists && data.url) {
|
||||||
|
setPayslipUrl(data.url);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setHasPayslip(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка проверки расчетного листка:', error);
|
||||||
|
setHasPayslip(false);
|
||||||
|
} finally {
|
||||||
|
setCheckingPayslip(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkPayslip();
|
||||||
|
}, [selectedPeriod, employee]);
|
||||||
|
|
||||||
|
const handlePreviewPdf = async () => {
|
||||||
|
if (!employee?.tnom) return;
|
||||||
|
|
||||||
|
setShowPdfPreview(true);
|
||||||
|
setPdfLoading(true);
|
||||||
|
setPdfError(null);
|
||||||
|
setIsFullscreen(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const backendUrl = config.backendUrl || 'http://localhost:3001';
|
||||||
|
const response = await fetch(`${backendUrl}/api/payslip/url/${employee.tnom}?period=${selectedPeriod}`);
|
||||||
|
|
||||||
<div className="employee-header">
|
if (response.ok) {
|
||||||
<div className="employee-photo">
|
const data = await response.json();
|
||||||
<div className="photo-placeholder">Фото</div>
|
if (data.exists && data.url) {
|
||||||
</div>
|
const fullUrl = data.url.startsWith('http') ? data.url : `${backendUrl}${data.url}`;
|
||||||
<div className="employee-details">
|
setPayslipUrl(fullUrl);
|
||||||
<h2>{`${employee.famaly} ${employee.ima} ${employee.otch}`}</h2>
|
setPdfLoading(false);
|
||||||
<p>Табельный номер: {employee.tnom}</p>
|
} else {
|
||||||
{employee.category?.trim() && <p>Категория: {employee.category}</p>}
|
setPdfError('Расчетный листок не найден');
|
||||||
{employee.departmentName && (
|
setPdfLoading(false);
|
||||||
<p>Подразделение: {employee.departmentName}</p>
|
}
|
||||||
)}
|
} else {
|
||||||
<p>Организация: {employee.organization}</p>
|
setPdfError('Ошибка при проверке расчетного листка');
|
||||||
{employee.profession && <p>Профессия: {employee.profession}</p>}
|
setPdfLoading(false);
|
||||||
</div>
|
}
|
||||||
</div>
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки PDF:', error);
|
||||||
|
setPdfError('Не удалось загрузить расчетный листок');
|
||||||
|
setPdfLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClosePreview = () => {
|
||||||
|
setShowPdfPreview(false);
|
||||||
|
setPdfLoading(false);
|
||||||
|
setPdfError(null);
|
||||||
|
setIsFullscreen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrint = async () => {
|
||||||
|
try {
|
||||||
|
setIsPrinting(true);
|
||||||
|
setPrintStatus('sending');
|
||||||
|
setPrintMessage('Отправка документа на печать...');
|
||||||
|
|
||||||
<div className="payslip-section">
|
const urlToPrint = payslipUrl ; // payslipUrl для Employee/Finance, fileUrl для Viewer
|
||||||
<h3>Расчётный лист</h3>
|
if (!urlToPrint) throw new Error('URL документа отсутствует');
|
||||||
<div className="period-selector">
|
|
||||||
<label htmlFor="period-select">Выберите период:</label>
|
const fullUrl = urlToPrint.startsWith('http') ? urlToPrint : `http://localhost:3001${urlToPrint}`;
|
||||||
<select
|
// 🚀 ВАРИАНТ 1: Мы в десктопе (Electron)
|
||||||
id="period-select"
|
if ((window as any).electronAPI && (window as any).electronAPI.printPdf) {
|
||||||
value={selectedPeriod}
|
console.log('🖨️ [PRINT] Печатаем тихо через Electron API');
|
||||||
onChange={(e) => setSelectedPeriod(e.target.value)}
|
await (window as any).electronAPI.printPdf({
|
||||||
>
|
pdfUrl: fullUrl,
|
||||||
{periods.map(period => (
|
deviceName: '' // <-- Оставляем пустым! Electron сам плюнет на дефолтный принтер
|
||||||
<option key={period} value={period}>
|
});
|
||||||
{monthNames[parseInt(period.substring(0, 2)) - 1]} {period.substring(2)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button onClick={() => onViewPayslip(selectedPeriod)}>
|
setPrintStatus('success');
|
||||||
Посмотреть расчётный лист
|
setPrintMessage('✅ Отправлено на принтер');
|
||||||
</button>
|
}
|
||||||
</div>
|
// 🌐 ВАРИАНТ 2: Мы в браузере (Chrome/Edge)
|
||||||
</div>
|
else {
|
||||||
|
console.log('🖨️ [PRINT] Electron API нет. Обходим CORS и печатаем через браузер');
|
||||||
|
|
||||||
|
// Скачиваем PDF как Blob, чтобы сделать его "своим" для браузера
|
||||||
|
const response = await fetch(fullUrl);
|
||||||
|
if (!response.ok) throw new Error('Не удалось скачать PDF с сервера');
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
// Создаем невидимый iframe
|
||||||
|
const printIframe = document.createElement('iframe');
|
||||||
|
printIframe.style.display = 'none';
|
||||||
|
printIframe.src = blobUrl;
|
||||||
|
document.body.appendChild(printIframe);
|
||||||
|
|
||||||
|
printIframe.onload = () => {
|
||||||
|
try {
|
||||||
|
printIframe.contentWindow?.focus();
|
||||||
|
printIframe.contentWindow?.print();
|
||||||
|
setPrintStatus('success');
|
||||||
|
setPrintMessage('✅ Документ отправлен на печать');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Ошибка iframe печати:', e);
|
||||||
|
throw new Error('Браузер заблокировал печать');
|
||||||
|
} finally {
|
||||||
|
// Чистим за собой мусор через 5 секунд
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.removeChild(printIframe);
|
||||||
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [PRINT] Ошибка печати:', error);
|
||||||
|
setPrintStatus('error');
|
||||||
|
setPrintMessage('❌ Ошибка: ' + (error instanceof Error ? error.message : 'Неизвестная ошибка'));
|
||||||
|
} finally {
|
||||||
|
setIsPrinting(false);
|
||||||
|
setTimeout(() => {
|
||||||
|
setPrintStatus('idle');
|
||||||
|
setPrintMessage('');
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFullscreen = () => {
|
||||||
|
setIsFullscreen(!isFullscreen);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
style={{
|
||||||
|
padding: '20px',
|
||||||
|
maxWidth: '1200px',
|
||||||
|
margin: '0 auto',
|
||||||
|
paddingBottom: '80px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '25px',
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
background: '#6c757d',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = '#5a6268';
|
||||||
|
e.currentTarget.style.transform = 'translateX(-2px)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = '#6c757d';
|
||||||
|
e.currentTarget.style.transform = 'translateX(0)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
← Назад
|
||||||
|
</button>
|
||||||
|
<h3 style={{
|
||||||
|
color: 'black',
|
||||||
|
fontSize: '25px',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}>
|
||||||
|
📋 Расчетный лист
|
||||||
|
</h3>
|
||||||
|
<div style={{ width: '100px' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ delay: 0.1 }}
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '25px 30px',
|
||||||
|
color: 'white',
|
||||||
|
marginBottom: '30px',
|
||||||
|
boxShadow: '0 10px 30px rgba(102, 126, 234, 0.3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '25px', flexWrap: 'wrap' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '22px', fontWeight: 500 }}>
|
||||||
|
{`${employee.famaly} ${employee.ima} ${employee.otch}`}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', opacity: 0.9, marginTop: '5px' }}>
|
||||||
|
Табельный номер: {employee.tnom}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '25px',
|
||||||
|
boxShadow: '0 4px 20px rgba(0,0,0,0.06)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ margin: '0 0 20px 0', color: '#2d3436', fontSize: '18px', fontWeight: 500 }}>
|
||||||
|
📄 Расчетный листок
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '15px',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '20px',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
|
<label style={{ fontSize: '14px', color: '#636e72' }}>
|
||||||
|
Выберите период:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedPeriod}
|
||||||
|
onChange={(e) => setSelectedPeriod(e.target.value)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 14px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid #dfe6e9',
|
||||||
|
fontSize: '14px',
|
||||||
|
background: 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{periods.map(period => {
|
||||||
|
const month = parseInt(period.substring(0, 2)) - 1;
|
||||||
|
const year = period.substring(2);
|
||||||
|
return (
|
||||||
|
<option key={period} value={period}>
|
||||||
|
{monthNames[month]} {year}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{checkingPayslip ? (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
padding: '12px 16px',
|
||||||
|
background: '#f8f9fa',
|
||||||
|
borderRadius: '10px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
}}>
|
||||||
|
<FaSpinner style={{ animation: 'spin 1s linear infinite', fontSize: '18px', color: '#636e72' }} />
|
||||||
|
<span style={{ color: '#636e72', fontSize: '14px' }}>Проверка наличия расчетного листка...</span>
|
||||||
|
</div>
|
||||||
|
) : hasPayslip ? (
|
||||||
|
<div style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
background: '#f0fdf4',
|
||||||
|
borderRadius: '10px',
|
||||||
|
border: '1px solid #bbf7d0',
|
||||||
|
marginBottom: '16px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
}}>
|
||||||
|
<FaCheckCircle style={{ color: '#22c55e', fontSize: '18px' }} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#166534', fontWeight: 500 }}>
|
||||||
|
✅ Расчетный листок доступен
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '13px', color: '#14532d' }}>
|
||||||
|
Табельный номер: <strong>{employee?.tnom}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : hasPayslip === false ? (
|
||||||
|
<div style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
background: '#fef2f2',
|
||||||
|
borderRadius: '10px',
|
||||||
|
border: '1px solid #fecaca',
|
||||||
|
marginBottom: '16px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
}}>
|
||||||
|
<FaExclamationCircle style={{ color: '#ef4444', fontSize: '18px' }} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#991b1b', fontWeight: 500 }}>
|
||||||
|
❌ Расчетный листок не найден
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '13px', color: '#991b1b', opacity: 0.8 }}>
|
||||||
|
Для табельного номера <strong>{employee?.tnom}</strong> файл отсутствует
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handlePreviewPdf}
|
||||||
|
disabled={!hasPayslip}
|
||||||
|
style={{
|
||||||
|
padding: '10px 25px',
|
||||||
|
background: hasPayslip ? 'linear-gradient(135deg, #00b894, #00cec9)' : '#dfe6e9',
|
||||||
|
color: hasPayslip ? 'white' : '#b2bec3',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '10px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: hasPayslip ? 'pointer' : 'not-allowed',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
boxShadow: hasPayslip ? '0 4px 15px rgba(0, 206, 201, 0.3)' : 'none',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (hasPayslip) {
|
||||||
|
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 6px 20px rgba(0, 206, 201, 0.4)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (hasPayslip) {
|
||||||
|
e.currentTarget.style.transform = 'translateY(0)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 4px 15px rgba(0, 206, 201, 0.3)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaEye />
|
||||||
|
Предпросмотр
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{printStatus !== 'idle' && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -50, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: -50, scale: 0.9 }}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: '20px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: 99999,
|
||||||
|
padding: '16px 32px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
background: printStatus === 'success' ? '#10b981' :
|
||||||
|
printStatus === 'error' ? '#ef4444' :
|
||||||
|
'#f59e0b',
|
||||||
|
color: 'white',
|
||||||
|
boxShadow: '0 10px 40px rgba(0,0,0,0.2)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 500,
|
||||||
|
minWidth: '300px',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{printStatus === 'sending' && <FaSpinner style={{ animation: 'spin 1s linear infinite' }} />}
|
||||||
|
{printStatus === 'success' && <FaCheckCircle />}
|
||||||
|
{printStatus === 'error' && <FaExclamationCircle />}
|
||||||
|
{printMessage}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{showPdfPreview && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: 'rgba(0, 0, 0, 0.85)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
WebkitBackdropFilter: 'blur(8px)',
|
||||||
|
zIndex: 9999,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '20px',
|
||||||
|
}}
|
||||||
|
onClick={handleClosePreview}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.9, y: 30, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, y: 0, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.9, y: 30, opacity: 0 }}
|
||||||
|
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||||
|
style={{
|
||||||
|
background: '#1a1a2e',
|
||||||
|
borderRadius: '24px',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: isFullscreen ? '100%' : '900px',
|
||||||
|
height: isFullscreen ? '100%' : '90vh',
|
||||||
|
maxHeight: isFullscreen ? '100%' : '90vh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
boxShadow: '0 50px 100px rgba(0,0,0,0.5)',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
padding: '16px 24px',
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
minHeight: '60px',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<span style={{ fontSize: '20px' }}>📄</span>
|
||||||
|
<span style={{ color: 'white', fontSize: '16px', fontWeight: 500 }}>
|
||||||
|
Расчетный листок
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
color: 'rgba(255,255,255,0.5)',
|
||||||
|
fontSize: '12px',
|
||||||
|
background: 'rgba(255,255,255,0.15)',
|
||||||
|
padding: '2px 10px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
}}>
|
||||||
|
{employee?.tnom}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<button
|
||||||
|
onClick={handlePrint}
|
||||||
|
disabled={isPrinting}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
background: isPrinting ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.2)',
|
||||||
|
color: 'white',
|
||||||
|
border: '1px solid rgba(255,255,255,0.3)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
cursor: isPrinting ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
opacity: isPrinting ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isPrinting) {
|
||||||
|
e.currentTarget.style.background = 'rgba(255,255,255,0.3)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!isPrinting) {
|
||||||
|
e.currentTarget.style.background = 'rgba(255,255,255,0.2)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPrinting ? <FaSpinner style={{ animation: 'spin 1s linear infinite' }} /> : <FaPrint size={16} />}
|
||||||
|
{isPrinting ? 'Отправка...' : 'Печать'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={toggleFullscreen}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '36px',
|
||||||
|
height: '36px',
|
||||||
|
background: 'rgba(255,255,255,0.2)',
|
||||||
|
color: 'white',
|
||||||
|
border: '1px solid rgba(255,255,255,0.3)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isFullscreen ? <FaCompress size={16} /> : <FaExpand size={16} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleClosePreview}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '36px',
|
||||||
|
height: '36px',
|
||||||
|
background: 'rgba(255,255,255,0.2)',
|
||||||
|
color: 'white',
|
||||||
|
border: '1px solid rgba(255,255,255,0.3)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaTimes size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '20px',
|
||||||
|
overflow: 'auto',
|
||||||
|
background: '#0a0a0a',
|
||||||
|
position: 'relative',
|
||||||
|
WebkitOverflowScrolling: 'touch',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
{pdfLoading && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '20px',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: '60px',
|
||||||
|
height: '60px',
|
||||||
|
border: '4px solid #2d2d2d',
|
||||||
|
borderTop: '4px solid #667eea',
|
||||||
|
borderRadius: '50%',
|
||||||
|
animation: 'spin 1s linear infinite',
|
||||||
|
}} />
|
||||||
|
<span style={{ color: '#888', fontSize: '16px' }}>
|
||||||
|
Загрузка документа...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!pdfLoading && payslipUrl && !pdfError && (
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
src={`${payslipUrl}#toolbar=0&navpanes=0&scrollbar=1&view=FitH`}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
minHeight: '600px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '12px',
|
||||||
|
background: '#0a0a0a',
|
||||||
|
boxShadow: '0 2px 12px rgba(0,0,0,0.3)',
|
||||||
|
}}
|
||||||
|
title="PDF Preview"
|
||||||
|
onError={() => {
|
||||||
|
setPdfLoading(false);
|
||||||
|
setPdfError('Не удалось загрузить PDF');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pdfError && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '15px',
|
||||||
|
padding: '40px',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '64px' }}>📄</div>
|
||||||
|
<div style={{ fontSize: '18px', color: '#e74c3c', fontWeight: 500 }}>
|
||||||
|
{pdfError}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setPdfError(null);
|
||||||
|
setPdfLoading(true);
|
||||||
|
handlePreviewPdf();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '10px 30px',
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🔄 Попробовать снова
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
padding: '12px 24px',
|
||||||
|
background: 'rgba(255,255,255,0.05)',
|
||||||
|
borderTop: '1px solid rgba(255,255,255,0.05)',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
fontSize: '13px',
|
||||||
|
color: 'rgba(255,255,255,0.5)',
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
👤 {employee?.famaly} {employee?.ima}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
📋 Табельный номер: <strong style={{ color: 'rgba(255,255,255,0.7)' }}>{employee?.tnom}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
🏢 {employee?.organization}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,30 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { FaArrowLeft, FaSpinner, FaEye, FaPrint, FaTimes, FaExpand, FaCompress, FaCheckCircle, FaExclamationCircle } from 'react-icons/fa';
|
||||||
|
import config from '../config.json';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
interface FinanceProps {
|
interface FinanceProps {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onViewPayslip: (period: string) => void;
|
onViewPayslip: (fileUrl: string) => void;
|
||||||
employee: any;
|
employee: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FinancialIndicator {
|
|
||||||
title: string;
|
|
||||||
value: string;
|
|
||||||
change?: number;
|
|
||||||
unit?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Finance: React.FC<FinanceProps> = ({ onBack, onViewPayslip, employee }) => {
|
const Finance: React.FC<FinanceProps> = ({ onBack, onViewPayslip, employee }) => {
|
||||||
|
const [hasPayslip, setHasPayslip] = useState<boolean | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [payslipUrl, setPayslipUrl] = useState<string | null>(null);
|
||||||
|
const [showPdfPreview, setShowPdfPreview] = useState(false);
|
||||||
|
const [pdfLoading, setPdfLoading] = useState(false);
|
||||||
|
const [pdfError, setPdfError] = useState<string | null>(null);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
|
const [isPrinting, setIsPrinting] = useState(false);
|
||||||
|
const [printStatus, setPrintStatus] = useState<'idle' | 'success' | 'error' | 'sending'>('idle');
|
||||||
|
const [printMessage, setPrintMessage] = useState('');
|
||||||
|
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
|
|
||||||
const [selectedPeriod, setSelectedPeriod] = useState(() => {
|
const [selectedPeriod, setSelectedPeriod] = useState(() => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const year = now.getFullYear();
|
const year = now.getFullYear();
|
||||||
@@ -22,13 +32,6 @@ const Finance: React.FC<FinanceProps> = ({ onBack, onViewPayslip, employee }) =>
|
|||||||
return `${month}${year}`;
|
return `${month}${year}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const [indicators] = useState<FinancialIndicator[]>([
|
|
||||||
{ title: 'Средняя ЗП по предприятию', value: '1 850', unit: 'руб.' },
|
|
||||||
{ title: 'Премия по итогам месяца', value: '15', unit: '%' },
|
|
||||||
{ title: 'Материальная помощь к отпуску', value: '500', unit: 'руб.' },
|
|
||||||
{ title: 'Выплаты к Новому году', value: '300', unit: 'руб.' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const generatePeriods = () => {
|
const generatePeriods = () => {
|
||||||
const periods = [];
|
const periods = [];
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -40,122 +43,667 @@ const Finance: React.FC<FinanceProps> = ({ onBack, onViewPayslip, employee }) =>
|
|||||||
}
|
}
|
||||||
return periods;
|
return periods;
|
||||||
};
|
};
|
||||||
|
|
||||||
const periods = generatePeriods();
|
const periods = generatePeriods();
|
||||||
const monthNames = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
|
const monthNames = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
|
||||||
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'];
|
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'];
|
||||||
|
|
||||||
const formatPeriod = (period: string) => {
|
useEffect(() => {
|
||||||
const month = parseInt(period.substring(0, 2)) - 1;
|
const checkPayslip = async () => {
|
||||||
const year = period.substring(2);
|
if (!employee?.tnom) {
|
||||||
return `${monthNames[month]} ${year}`;
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const backendUrl = config.backendUrl || 'http://localhost:3001';
|
||||||
|
const response = await fetch(`${backendUrl}/api/payslip/url/${employee.tnom}?period=${selectedPeriod}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setHasPayslip(data.exists);
|
||||||
|
if (data.exists && data.url) {
|
||||||
|
setPayslipUrl(data.url);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setHasPayslip(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка проверки расчетного листка:', error);
|
||||||
|
setHasPayslip(false);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkPayslip();
|
||||||
|
}, [employee, selectedPeriod]);
|
||||||
|
|
||||||
|
const handlePreviewPdf = () => {
|
||||||
|
if (payslipUrl) {
|
||||||
|
setShowPdfPreview(true);
|
||||||
|
setPdfLoading(true);
|
||||||
|
setPdfError(null);
|
||||||
|
setIsFullscreen(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const handleClosePreview = () => {
|
||||||
<motion.div
|
setShowPdfPreview(false);
|
||||||
initial={{ opacity: 0, y: 20 }}
|
setPdfLoading(false);
|
||||||
animate={{ opacity: 1, y: 0 }}
|
setPdfError(null);
|
||||||
exit={{ opacity: 0, y: -20 }}
|
setIsFullscreen(false);
|
||||||
transition={{ duration: 0.3 }}
|
};
|
||||||
className="finance-section"
|
|
||||||
style={{ padding: '20px', maxWidth: '1200px', margin: '0 auto', paddingBottom: '80px' }}
|
|
||||||
>
|
|
||||||
<div className="top-bar" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '25px', flexWrap: 'wrap', gap: '10px' }}>
|
|
||||||
<button
|
|
||||||
className="back-button"
|
|
||||||
onClick={onBack}
|
|
||||||
style={{ padding: '10px 20px', background: '#6c757d', color: 'white', border: 'none', borderRadius: '8px', cursor: 'pointer', fontSize: '14px', transition: 'all 0.3s ease' }}
|
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.background = '#5a6268'; e.currentTarget.style.transform = 'translateX(-2px)'; }}
|
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.background = '#6c757d'; e.currentTarget.style.transform = 'translateX(0)'; }}
|
|
||||||
>
|
|
||||||
← Назад
|
|
||||||
</button>
|
|
||||||
<h2 style={{ margin: 0, color: '#2d3436', fontSize: '24px', fontWeight: 300, display: 'flex', alignItems: 'center', gap: '12px' }}>
|
|
||||||
💰 Финансы
|
|
||||||
</h2>
|
|
||||||
<div style={{ width: '100px' }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} transition={{ delay: 0.1 }}
|
const handlePrint = async () => {
|
||||||
style={{ background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', borderRadius: '16px', padding: '25px 30px', color: 'white', marginBottom: '30px', boxShadow: '0 10px 30px rgba(102, 126, 234, 0.3)' }}>
|
try {
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap' }}>
|
setIsPrinting(true);
|
||||||
<div>
|
setPrintStatus('sending');
|
||||||
<div style={{ fontSize: '14px', opacity: 0.8, marginBottom: '5px' }}>{employee.organization}</div>
|
setPrintMessage('Отправка документа на печать...');
|
||||||
<div style={{ fontSize: '20px', fontWeight: 500 }}>{employee.famaly} {employee.ima} {employee.otch}</div>
|
|
||||||
<div style={{ fontSize: '14px', opacity: 0.9, marginTop: '5px' }}>Табельный номер: {employee.tnom}</div>
|
const urlToPrint = payslipUrl; // payslipUrl для Employee/Finance, fileUrl для Viewer
|
||||||
</div>
|
if (!urlToPrint) throw new Error('URL документа отсутствует');
|
||||||
<div style={{ textAlign: 'right' }}>
|
|
||||||
<div style={{ fontSize: '12px', opacity: 0.7 }}>Доступно</div>
|
const fullUrl = urlToPrint.startsWith('http') ? urlToPrint : `http://localhost:3001${urlToPrint}`;
|
||||||
<div style={{ fontSize: '28px', fontWeight: 600 }}>0.00</div>
|
|
||||||
<div style={{ fontSize: '12px', opacity: 0.7 }}>руб.</div>
|
if ((window as any).electronAPI && (window as any).electronAPI.printPdf) {
|
||||||
</div>
|
console.log('🖨️ [PRINT] Печатаем тихо через Electron API');
|
||||||
|
await (window as any).electronAPI.printPdf({
|
||||||
|
pdfUrl: fullUrl,
|
||||||
|
deviceName: '' // <-- Оставляем пустым! Electron сам плюнет на дефолтный принтер
|
||||||
|
});
|
||||||
|
|
||||||
|
setPrintStatus('success');
|
||||||
|
setPrintMessage('✅ Отправлено на принтер');
|
||||||
|
}
|
||||||
|
// 🌐 ВАРИАНТ 2: Мы в браузере (Chrome/Edge)
|
||||||
|
else {
|
||||||
|
console.log('🖨️ [PRINT] Electron API нет. Обходим CORS и печатаем через браузер');
|
||||||
|
|
||||||
|
// Скачиваем PDF как Blob, чтобы сделать его "своим" для браузера
|
||||||
|
const response = await fetch(fullUrl);
|
||||||
|
if (!response.ok) throw new Error('Не удалось скачать PDF с сервера');
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
// Создаем невидимый iframe
|
||||||
|
const printIframe = document.createElement('iframe');
|
||||||
|
printIframe.style.display = 'none';
|
||||||
|
printIframe.src = blobUrl;
|
||||||
|
document.body.appendChild(printIframe);
|
||||||
|
|
||||||
|
printIframe.onload = () => {
|
||||||
|
try {
|
||||||
|
printIframe.contentWindow?.focus();
|
||||||
|
printIframe.contentWindow?.print();
|
||||||
|
setPrintStatus('success');
|
||||||
|
setPrintMessage('✅ Документ отправлен на печать');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Ошибка iframe печати:', e);
|
||||||
|
throw new Error('Браузер заблокировал печать');
|
||||||
|
} finally {
|
||||||
|
// Чистим за собой мусор через 5 секунд
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.removeChild(printIframe);
|
||||||
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [PRINT] Ошибка печати:', error);
|
||||||
|
setPrintStatus('error');
|
||||||
|
setPrintMessage('❌ Ошибка: ' + (error instanceof Error ? error.message : 'Неизвестная ошибка'));
|
||||||
|
} finally {
|
||||||
|
setIsPrinting(false);
|
||||||
|
setTimeout(() => {
|
||||||
|
setPrintStatus('idle');
|
||||||
|
setPrintMessage('');
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFullscreen = () => {
|
||||||
|
setIsFullscreen(!isFullscreen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFullUrl = (url: string) => {
|
||||||
|
return url.startsWith('http') ? url : `http://localhost:3001${url}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const financialIndicators = [
|
||||||
|
{ title: 'Средняя ЗП по предприятию', value: '1 850', unit: 'руб.' },
|
||||||
|
{ title: 'Премия по итогам месяца', value: '15', unit: '%' },
|
||||||
|
{ title: 'Материальная помощь к отпуску', value: '500', unit: 'руб.' },
|
||||||
|
{ title: 'Выплаты к Новому году', value: '300', unit: 'руб.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
style={{
|
||||||
|
padding: '20px',
|
||||||
|
maxWidth: '1200px',
|
||||||
|
margin: '0 auto',
|
||||||
|
paddingBottom: '80px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '25px', flexWrap: 'wrap', gap: '10px' }}>
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
background: '#6c757d',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
← Назад
|
||||||
|
</button>
|
||||||
|
<h2 style={{
|
||||||
|
margin: 0,
|
||||||
|
color: '#2d3436',
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: 300,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
}}>
|
||||||
|
💰 Финансы
|
||||||
|
</h2>
|
||||||
|
<div style={{ width: '100px' }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ delay: 0.1 }}
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '25px 30px',
|
||||||
|
color: 'white',
|
||||||
|
marginBottom: '30px',
|
||||||
|
boxShadow: '0 10px 30px rgba(102, 126, 234, 0.3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '14px', opacity: 0.8, marginBottom: '5px' }}>
|
||||||
|
{employee?.organization || 'Организация'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '20px', fontWeight: 500 }}>
|
||||||
|
{employee?.famaly} {employee?.ima} {employee?.otch}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', opacity: 0.9, marginTop: '5px' }}>
|
||||||
|
Табельный номер: {employee?.tnom}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div style={{ fontSize: '12px', opacity: 0.7 }}>Доступно</div>
|
||||||
|
<div style={{ fontSize: '28px', fontWeight: 600 }}>0.00</div>
|
||||||
|
<div style={{ fontSize: '12px', opacity: 0.7 }}>руб.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '25px',
|
||||||
|
boxShadow: '0 4px 20px rgba(0,0,0,0.06)',
|
||||||
|
marginBottom: '25px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ margin: '0 0 20px 0', color: '#2d3436', fontSize: '18px', fontWeight: 500 }}>
|
||||||
|
📊 Финансово-экономические показатели
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '15px' }}>
|
||||||
|
{financialIndicators.map((item, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={index}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.1 * index }}
|
||||||
|
style={{
|
||||||
|
background: '#f8f9fa',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '16px 20px',
|
||||||
|
border: '1px solid #f1f2f6',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '13px', color: '#636e72', marginBottom: '8px' }}>{item.title}</div>
|
||||||
|
<div style={{ fontSize: '24px', fontWeight: 600, color: '#2d3436' }}>
|
||||||
|
{item.value}
|
||||||
|
{item.unit && (
|
||||||
|
<span style={{ fontSize: '14px', fontWeight: 400, color: '#636e72', marginLeft: '4px' }}>
|
||||||
|
{item.unit}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.3 }}
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '25px',
|
||||||
|
boxShadow: '0 4px 20px rgba(0,0,0,0.06)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ margin: '0 0 20px 0', color: '#2d3436', fontSize: '18px', fontWeight: 500 }}>
|
||||||
|
📄 Расчетный листок
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '15px',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '20px',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
|
<label style={{ fontSize: '14px', color: '#636e72' }}>
|
||||||
|
Выберите период:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedPeriod}
|
||||||
|
onChange={(e) => setSelectedPeriod(e.target.value)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 14px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid #dfe6e9',
|
||||||
|
fontSize: '14px',
|
||||||
|
background: 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{periods.map(period => {
|
||||||
|
const month = parseInt(period.substring(0, 2)) - 1;
|
||||||
|
const year = period.substring(2);
|
||||||
|
return (
|
||||||
|
<option key={period} value={period}>
|
||||||
|
{monthNames[month]} {year}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '30px', gap: '12px' }}>
|
||||||
|
<FaSpinner style={{ animation: 'spin 1s linear infinite', fontSize: '24px' }} />
|
||||||
|
<span>Проверка наличия расчетного листка...</span>
|
||||||
|
</div>
|
||||||
|
) : hasPayslip ? (
|
||||||
|
<div style={{ display: 'flex', gap: '15px', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '16px 20px',
|
||||||
|
background: '#f0fdf4',
|
||||||
|
borderRadius: '10px',
|
||||||
|
border: '1px solid #bbf7d0',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '13px', color: '#166534', marginBottom: '4px' }}>
|
||||||
|
✅ Расчетный листок доступен
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#14532d' }}>
|
||||||
|
Табельный номер: <strong>{employee?.tnom}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handlePreviewPdf}
|
||||||
|
style={{
|
||||||
|
padding: '12px 30px',
|
||||||
|
background: 'linear-gradient(135deg, #00b894, #00cec9)',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '10px',
|
||||||
|
fontSize: '15px',
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
boxShadow: '0 4px 15px rgba(0, 206, 201, 0.3)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaEye />
|
||||||
|
Предпросмотр
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
padding: '16px 20px',
|
||||||
|
background: '#fef2f2',
|
||||||
|
borderRadius: '10px',
|
||||||
|
border: '1px solid #fecaca',
|
||||||
|
color: '#991b1b',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '13px', marginBottom: '4px' }}>
|
||||||
|
❌ Расчетный листок не найден
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '13px', opacity: 0.8 }}>
|
||||||
|
Для табельного номера <strong>{employee?.tnom}</strong> файл отсутствует.
|
||||||
|
{employee?.tnom && ` Файл должен называться: ${employee.tnom}.pdf`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
<style>{`
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }}
|
<AnimatePresence>
|
||||||
style={{ background: 'white', borderRadius: '16px', padding: '25px', boxShadow: '0 4px 20px rgba(0,0,0,0.06)', marginBottom: '25px' }}>
|
{printStatus !== 'idle' && (
|
||||||
<h3 style={{ margin: '0 0 20px 0', color: '#2d3436', fontSize: '18px', fontWeight: 500, display: 'flex', alignItems: 'center', gap: '10px' }}>
|
<motion.div
|
||||||
📊 Финансово-экономические показатели
|
initial={{ opacity: 0, y: -50, scale: 0.9 }}
|
||||||
</h3>
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '15px' }}>
|
exit={{ opacity: 0, y: -50, scale: 0.9 }}
|
||||||
{indicators.map((item, index) => (
|
style={{
|
||||||
<motion.div key={index} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 * index }}
|
position: 'fixed',
|
||||||
style={{ background: '#f8f9fa', borderRadius: '12px', padding: '16px 20px', border: '1px solid #f1f2f6' }}>
|
top: '20px',
|
||||||
<div style={{ fontSize: '13px', color: '#636e72', marginBottom: '8px' }}>{item.title}</div>
|
left: '50%',
|
||||||
<div style={{ fontSize: '24px', fontWeight: 600, color: '#2d3436' }}>
|
transform: 'translateX(-50%)',
|
||||||
{item.value}
|
zIndex: 99999,
|
||||||
{item.unit && <span style={{ fontSize: '14px', fontWeight: 400, color: '#636e72', marginLeft: '4px' }}>{item.unit}</span>}
|
padding: '16px 32px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
background: printStatus === 'success' ? '#10b981' :
|
||||||
|
printStatus === 'error' ? '#ef4444' :
|
||||||
|
'#f59e0b',
|
||||||
|
color: 'white',
|
||||||
|
boxShadow: '0 10px 40px rgba(0,0,0,0.2)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 500,
|
||||||
|
minWidth: '300px',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{printStatus === 'sending' && <FaSpinner style={{ animation: 'spin 1s linear infinite' }} />}
|
||||||
|
{printStatus === 'success' && <FaCheckCircle />}
|
||||||
|
{printStatus === 'error' && <FaExclamationCircle />}
|
||||||
|
{printMessage}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{showPdfPreview && payslipUrl && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: 'rgba(0, 0, 0, 0.85)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
WebkitBackdropFilter: 'blur(8px)',
|
||||||
|
zIndex: 9999,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '20px',
|
||||||
|
}}
|
||||||
|
onClick={handleClosePreview}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.9, y: 30, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, y: 0, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.9, y: 30, opacity: 0 }}
|
||||||
|
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||||
|
style={{
|
||||||
|
background: '#1a1a2e',
|
||||||
|
borderRadius: '24px',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: isFullscreen ? '100%' : '900px',
|
||||||
|
height: isFullscreen ? '100%' : '90vh',
|
||||||
|
maxHeight: isFullscreen ? '100%' : '90vh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
boxShadow: '0 50px 100px rgba(0,0,0,0.5)',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
padding: '16px 24px',
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
minHeight: '60px',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<span style={{ fontSize: '20px' }}>📄</span>
|
||||||
|
<span style={{ color: 'white', fontSize: '16px', fontWeight: 500 }}>
|
||||||
|
Расчетный листок
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
color: 'rgba(255,255,255,0.5)',
|
||||||
|
fontSize: '12px',
|
||||||
|
background: 'rgba(255,255,255,0.15)',
|
||||||
|
padding: '2px 10px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
}}>
|
||||||
|
{employee?.tnom}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{item.change !== undefined && (
|
|
||||||
<div style={{ fontSize: '12px', color: item.change >= 0 ? '#2ecc71' : '#e74c3c', marginTop: '4px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
{item.change >= 0 ? '↑' : '↓'} {Math.abs(item.change)}%
|
<button
|
||||||
|
onClick={handlePrint}
|
||||||
|
disabled={isPrinting}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
background: isPrinting ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.2)',
|
||||||
|
color: 'white',
|
||||||
|
border: '1px solid rgba(255,255,255,0.3)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
cursor: isPrinting ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
opacity: isPrinting ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPrinting ? <FaSpinner style={{ animation: 'spin 1s linear infinite' }} /> : <FaPrint size={16} />}
|
||||||
|
{isPrinting ? 'Отправка...' : 'Печать'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={toggleFullscreen}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '36px',
|
||||||
|
height: '36px',
|
||||||
|
background: 'rgba(255,255,255,0.2)',
|
||||||
|
color: 'white',
|
||||||
|
border: '1px solid rgba(255,255,255,0.3)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isFullscreen ? <FaCompress size={16} /> : <FaExpand size={16} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleClosePreview}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '36px',
|
||||||
|
height: '36px',
|
||||||
|
background: 'rgba(255,255,255,0.2)',
|
||||||
|
color: 'white',
|
||||||
|
border: '1px solid rgba(255,255,255,0.3)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaTimes size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '20px',
|
||||||
|
overflow: 'auto',
|
||||||
|
background: '#0a0a0a',
|
||||||
|
position: 'relative',
|
||||||
|
WebkitOverflowScrolling: 'touch',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
{pdfLoading && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '20px',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: '60px',
|
||||||
|
height: '60px',
|
||||||
|
border: '4px solid #2d2d2d',
|
||||||
|
borderTop: '4px solid #667eea',
|
||||||
|
borderRadius: '50%',
|
||||||
|
animation: 'spin 1s linear infinite',
|
||||||
|
}} />
|
||||||
|
<span style={{ color: '#888', fontSize: '16px' }}>
|
||||||
|
Загрузка документа...
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.3 }}
|
<iframe
|
||||||
style={{ background: 'white', borderRadius: '16px', padding: '25px', boxShadow: '0 4px 20px rgba(0,0,0,0.06)' }}>
|
ref={iframeRef}
|
||||||
<h3 style={{ margin: '0 0 20px 0', color: '#2d3436', fontSize: '18px', fontWeight: 500, display: 'flex', alignItems: 'center', gap: '10px' }}>
|
src={`${getFullUrl(payslipUrl)}#toolbar=0&navpanes=0&scrollbar=1&view=FitH`}
|
||||||
📄 Расчётный лист
|
style={{
|
||||||
</h3>
|
width: '100%',
|
||||||
<div style={{ display: 'flex', gap: '15px', flexWrap: 'wrap', alignItems: 'center' }}>
|
height: '100%',
|
||||||
<div style={{ flex: 1, minWidth: '200px' }}>
|
minHeight: '600px',
|
||||||
<label htmlFor="period-select" style={{ display: 'block', fontSize: '13px', color: '#636e72', marginBottom: '6px' }}>
|
border: 'none',
|
||||||
📅 Выберите период:
|
borderRadius: '12px',
|
||||||
</label>
|
background: '#0a0a0a',
|
||||||
<select
|
display: pdfLoading ? 'none' : 'block',
|
||||||
id="period-select"
|
boxShadow: '0 2px 12px rgba(0,0,0,0.3)',
|
||||||
value={selectedPeriod}
|
}}
|
||||||
onChange={(e) => setSelectedPeriod(e.target.value)}
|
title="PDF Preview"
|
||||||
style={{ width: '100%', padding: '12px 16px', border: '2px solid #dfe6e9', borderRadius: '10px', fontSize: '15px', background: 'white', cursor: 'pointer', outline: 'none', transition: 'border-color 0.3s ease' }}
|
onLoad={() => setPdfLoading(false)}
|
||||||
onFocus={(e) => e.target.style.borderColor = '#6c5ce7'}
|
onError={() => {
|
||||||
onBlur={(e) => e.target.style.borderColor = '#dfe6e9'}
|
setPdfLoading(false);
|
||||||
>
|
setPdfError('Не удалось загрузить PDF');
|
||||||
{periods.map(period => (
|
}}
|
||||||
<option key={period} value={period}>{formatPeriod(period)}</option>
|
/>
|
||||||
))}
|
|
||||||
</select>
|
{pdfError && (
|
||||||
</div>
|
<div style={{
|
||||||
<button
|
display: 'flex',
|
||||||
onClick={() => onViewPayslip(selectedPeriod)}
|
flexDirection: 'column',
|
||||||
style={{ padding: '12px 30px', background: 'linear-gradient(135deg, #6c5ce7, #a29bfe)', color: 'white', border: 'none', borderRadius: '10px', fontSize: '15px', fontWeight: 500, cursor: 'pointer', transition: 'all 0.3s ease', boxShadow: '0 4px 15px rgba(108, 92, 231, 0.3)', display: 'flex', alignItems: 'center', gap: '8px' }}
|
alignItems: 'center',
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.transform = 'translateY(-2px)'; e.currentTarget.style.boxShadow = '0 6px 20px rgba(108, 92, 231, 0.4)'; }}
|
justifyContent: 'center',
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 4px 15px rgba(108, 92, 231, 0.3)'; }}
|
gap: '15px',
|
||||||
>
|
padding: '40px',
|
||||||
📄 Просмотреть
|
textAlign: 'center',
|
||||||
</button>
|
}}>
|
||||||
</div>
|
<div style={{ fontSize: '64px' }}>📄</div>
|
||||||
<div style={{ marginTop: '20px', padding: '16px 20px', background: '#fff5f5', borderRadius: '10px', border: '1px solid #fde8e8' }}>
|
<div style={{ fontSize: '18px', color: '#e74c3c', fontWeight: 500 }}>
|
||||||
<div style={{ fontSize: '13px', color: '#636e72', marginBottom: '5px' }}>💡 Информация о премии</div>
|
{pdfError}
|
||||||
<div style={{ fontSize: '14px', color: '#2d3436' }}>
|
</div>
|
||||||
Премия по итогам работы за {formatPeriod(selectedPeriod)} составляет 15% от оклада.
|
<button
|
||||||
</div>
|
onClick={() => {
|
||||||
</div>
|
setPdfError(null);
|
||||||
</motion.div>
|
setPdfLoading(true);
|
||||||
</motion.div>
|
const iframe = document.querySelector('iframe[title="PDF Preview"]') as HTMLIFrameElement;
|
||||||
|
if (iframe) {
|
||||||
|
iframe.src = `${getFullUrl(payslipUrl)}#toolbar=0&navpanes=0&scrollbar=1&view=FitH`;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '10px 30px',
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🔄 Попробовать снова
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
padding: '12px 24px',
|
||||||
|
background: 'rgba(255,255,255,0.05)',
|
||||||
|
borderTop: '1px solid rgba(255,255,255,0.05)',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
fontSize: '13px',
|
||||||
|
color: 'rgba(255,255,255,0.5)',
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
👤 {employee?.famaly} {employee?.ima}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
📋 Табельный номер: <strong style={{ color: 'rgba(255,255,255,0.7)' }}>{employee?.tnom}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
🏢 {employee?.organization}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const MainMenu: React.FC<MainMenuProps> = ({ onSectionSelect, employee }) => {
|
|||||||
console.log('🔵 MainMenu компонент рендерится!');
|
console.log('🔵 MainMenu компонент рендерится!');
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ id: 'employee-info', emoji: '📋' }
|
{ id: 'employee-info', emoji: '📋', description: 'Просмотр и печать' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const menuItems_2 = [
|
const menuItems_2 = [
|
||||||
@@ -29,97 +29,173 @@ const MainMenu: React.FC<MainMenuProps> = ({ onSectionSelect, employee }) => {
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
padding: '10px',
|
padding: '20px 20px',
|
||||||
marginTop: '-5vh',
|
marginTop: '-20px',
|
||||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundImage: 'url(/assets/images/background.jpg)',
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
}}>
|
}}>
|
||||||
{employee && (
|
{/* ✅ Полупрозрачный оверлей для читаемости текста
|
||||||
<div style={{
|
<div style={{
|
||||||
color: '#fff',
|
position: 'absolute',
|
||||||
fontSize: '1.5rem',
|
top: 0,
|
||||||
marginBottom: '10px',
|
left: 0,
|
||||||
}}>
|
right: 0,
|
||||||
Приветствуем Вас, {employee.ima} {employee.famaly}
|
bottom: 0,
|
||||||
</div>
|
background: 'rgba(232, 240, 254, 0.2)',
|
||||||
)}
|
backdropFilter: 'blur(4px)',
|
||||||
<h1 style={{
|
WebkitBackdropFilter: 'blur(4px)',
|
||||||
color: '#fff',
|
zIndex: 0,
|
||||||
fontSize: '2.5rem',
|
}} /> */}
|
||||||
marginBottom: '40px',
|
|
||||||
fontWeight: 300,
|
|
||||||
}}>
|
|
||||||
Выберите раздел
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
|
{/* ✅ Декоративные голубые размытые круги (поверх оверлея) */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-25%',
|
||||||
|
right: '-10%',
|
||||||
|
width: '55%',
|
||||||
|
height: '70%',
|
||||||
|
background: 'radial-gradient(ellipse, rgba(66, 133, 244, 0.08) 0%, rgba(66, 133, 244, 0.02) 60%, transparent 80%)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 0,
|
||||||
|
}} />
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '-20%',
|
||||||
|
left: '-15%',
|
||||||
|
width: '50%',
|
||||||
|
height: '60%',
|
||||||
|
background: 'radial-gradient(ellipse, rgba(52, 152, 219, 0.06) 0%, transparent 70%)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 0,
|
||||||
|
}} />
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '30%',
|
||||||
|
left: '25%',
|
||||||
|
width: '40%',
|
||||||
|
height: '50%',
|
||||||
|
background: 'radial-gradient(ellipse, rgba(41, 128, 185, 0.04) 0%, transparent 70%)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 0,
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* ✅ КНОПКА "РАСЧЕТНЫЙ ЛИСТ" */}
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 870px))',
|
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 900px))',
|
||||||
gap: '25px',
|
gap: '30px',
|
||||||
maxWidth: '900px',
|
maxWidth: '960px',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
//height: '100%',
|
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
|
marginBottom: '20px',
|
||||||
}}>
|
}}>
|
||||||
|
|
||||||
{menuItems.map((item) => (
|
{menuItems.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => onSectionSelect(item.id)}
|
onClick={() => onSectionSelect(item.id)}
|
||||||
style={{
|
style={{
|
||||||
background: 'linear-gradient(135deg, #1b718b 0%, #7ec87b 100%)',
|
background: 'linear-gradient(145deg, rgba(255,255,255,0.92) 0%, rgba(235, 245, 255, 0.92) 100%)',
|
||||||
borderRadius: '16px',
|
backdropFilter: 'blur(8px)',
|
||||||
padding: '25px 30px',
|
WebkitBackdropFilter: 'blur(8px)',
|
||||||
marginBottom: '25px',
|
borderRadius: '28px',
|
||||||
|
padding: '58px 36px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'all 0.3s ease',
|
transition: 'all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||||
boxShadow: '0 4px 20px rgba(198, 231, 212, 0.25)',
|
boxShadow: '0 8px 32px rgba(66, 133, 244, 0.10), 0 2px 8px rgba(66, 133, 244, 0.04), inset 0 1px 0 rgba(255,255,255,0.9)',
|
||||||
display: 'inline-flex',
|
display: 'flex',
|
||||||
justifyContent: 'flex-start',
|
justifyContent: 'flex-start',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
minHeight: '80px',
|
minHeight: '110px',
|
||||||
fontSize: '6.0rem',
|
gap: '20px',
|
||||||
|
border: '1px solid rgba(255,255,255,0.5)',
|
||||||
|
width: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.transform = 'scale(1.05)';
|
e.currentTarget.style.transform = 'translateY(-4px) scale(1.01)';
|
||||||
e.currentTarget.style.boxShadow = '0 12px 40px rgba(0,0,0,0.2)';
|
e.currentTarget.style.boxShadow = '0 24px 64px rgba(66, 133, 244, 0.18), 0 8px 24px rgba(66, 133, 244, 0.08), inset 0 1px 0 rgba(255,255,255,0.95)';
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(66, 133, 244, 0.2)';
|
||||||
|
e.currentTarget.style.background = 'linear-gradient(145deg, rgba(255,255,255,0.96) 0%, rgba(235, 245, 255, 0.96) 100%)';
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.transform = 'scale(1)';
|
e.currentTarget.style.transform = 'translateY(0) scale(1)';
|
||||||
e.currentTarget.style.boxShadow = '0 8px 30px rgba(0,0,0,0.12)';
|
e.currentTarget.style.boxShadow = '0 8px 32px rgba(66, 133, 244, 0.10), 0 2px 8px rgba(66, 133, 244, 0.04), inset 0 1px 0 rgba(255,255,255,0.9)';
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.5)';
|
||||||
|
e.currentTarget.style.background = 'linear-gradient(145deg, rgba(255,255,255,0.92) 0%, rgba(235, 245, 255, 0.92) 100%)';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>{item.emoji}</div>
|
<div style={{
|
||||||
<span style={{
|
position: 'absolute',
|
||||||
fontSize: '1rem',
|
left: 0,
|
||||||
fontWeight: 500,
|
top: 0,
|
||||||
color: '#2d3436',
|
bottom: 0,
|
||||||
textAlign: 'center',
|
width: '6px',
|
||||||
}}>
|
background: 'linear-gradient(180deg, #4285f4 0%, #5b9bd5 50%, #7cb9e8 100%)',
|
||||||
<div>
|
borderRadius: '0 3px 3px 0',
|
||||||
|
opacity: 0.7,
|
||||||
|
}} />
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
fontSize: '4.5rem',
|
||||||
|
marginLeft: '10px',
|
||||||
|
filter: 'drop-shadow(0 4px 12px rgba(66, 133, 244, 0.08))',
|
||||||
|
}}>{item.emoji}</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, textAlign: 'left' }}>
|
||||||
<h3 style={{
|
<h3 style={{
|
||||||
color: 'white',
|
color: '#1a3a5c',
|
||||||
fontSize: '25px',
|
fontSize: '32px',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
|
margin: 0,
|
||||||
|
letterSpacing: '-0.3px',
|
||||||
}}>
|
}}>
|
||||||
Расчетный лист
|
Расчетный лист
|
||||||
</h3>
|
</h3>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '16px',
|
||||||
|
color: '#6a8fb1',
|
||||||
|
fontWeight: 400,
|
||||||
|
letterSpacing: '0.2px',
|
||||||
|
}}>
|
||||||
|
{item.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
fontSize: '20px',
|
||||||
|
color: 'rgba(66, 133, 244, 0.3)',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
padding: '8px 14px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
background: 'rgba(66, 133, 244, 0.05)',
|
||||||
|
}}>
|
||||||
|
→
|
||||||
</div>
|
</div>
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ✅ СЕТКА КНОПОК */}
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 200px))',
|
gridTemplateColumns: 'repeat(auto-fit, minmax(170px, 210px))',
|
||||||
gap: '15px',
|
gap: '22px',
|
||||||
maxWidth: '900px',
|
maxWidth: '960px',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
//height: '100%',
|
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
marginTop: '15px'
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
}}>
|
}}>
|
||||||
|
|
||||||
{menuItems_2.map((item) => (
|
{menuItems_2.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
@@ -129,38 +205,108 @@ const MainMenu: React.FC<MainMenuProps> = ({ onSectionSelect, employee }) => {
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
gap: '12px',
|
gap: '14px',
|
||||||
padding: '5px 5px',
|
padding: '30px 16px 26px',
|
||||||
background: 'rgba(255,255,255,0.95)',
|
background: 'rgba(255,255,255,0.85)',
|
||||||
border: 'none',
|
backdropFilter: 'blur(8px)',
|
||||||
borderRadius: '16px',
|
WebkitBackdropFilter: 'blur(8px)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.5)',
|
||||||
|
borderRadius: '24px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
boxShadow: '0 8px 30px rgba(0,0,0,0.12)',
|
boxShadow: '0 6px 24px rgba(66, 133, 244, 0.06), 0 2px 8px rgba(66, 133, 244, 0.03), inset 0 1px 0 rgba(255,255,255,0.9)',
|
||||||
minHeight: '140px',
|
minHeight: '190px',
|
||||||
fontSize: '3.5rem',
|
fontSize: '4rem',
|
||||||
transition: 'all 0.3s ease',
|
transition: 'all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||||
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.transform = 'scale(1.05)';
|
e.currentTarget.style.transform = 'translateY(-6px) scale(1.02)';
|
||||||
e.currentTarget.style.boxShadow = '0 12px 40px rgba(0,0,0,0.2)';
|
e.currentTarget.style.boxShadow = '0 24px 64px rgba(66, 133, 244, 0.12), 0 8px 24px rgba(66, 133, 244, 0.06), inset 0 1px 0 rgba(255,255,255,0.95)';
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(66, 133, 244, 0.15)';
|
||||||
|
e.currentTarget.style.background = 'rgba(255,255,255,0.92)';
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.transform = 'scale(1)';
|
e.currentTarget.style.transform = 'translateY(0) scale(1)';
|
||||||
e.currentTarget.style.boxShadow = '0 8px 30px rgba(0,0,0,0.12)';
|
e.currentTarget.style.boxShadow = '0 6px 24px rgba(66, 133, 244, 0.06), 0 2px 8px rgba(66, 133, 244, 0.03), inset 0 1px 0 rgba(255,255,255,0.9)';
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.5)';
|
||||||
|
e.currentTarget.style.background = 'rgba(255,255,255,0.85)';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>{item.emoji}</div>
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: '15%',
|
||||||
|
right: '15%',
|
||||||
|
height: '3px',
|
||||||
|
background: 'linear-gradient(90deg, transparent, rgba(66, 133, 244, 0.15), transparent)',
|
||||||
|
borderRadius: '0 0 3px 3px',
|
||||||
|
transition: 'all 0.4s ease',
|
||||||
|
}} />
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
fontSize: '4.2rem',
|
||||||
|
filter: 'drop-shadow(0 4px 12px rgba(66, 133, 244, 0.04))',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
marginBottom: '2px',
|
||||||
|
}}>
|
||||||
|
{item.emoji}
|
||||||
|
</div>
|
||||||
|
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: '1rem',
|
display: 'block',
|
||||||
|
whiteSpace: 'normal',
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
fontSize: '18px',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
color: '#2d3436',
|
color: '#2a4a6a',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
|
letterSpacing: '0.2px',
|
||||||
|
lineHeight: 1.3,
|
||||||
|
maxWidth: '100%',
|
||||||
}}>
|
}}>
|
||||||
{item.title}
|
{item.title}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '8px',
|
||||||
|
left: '25%',
|
||||||
|
right: '25%',
|
||||||
|
height: '2px',
|
||||||
|
background: 'linear-gradient(90deg, transparent, rgba(66, 133, 244, 0.2), transparent)',
|
||||||
|
borderRadius: '2px',
|
||||||
|
opacity: 0,
|
||||||
|
transition: 'all 0.4s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
const indicator = e.currentTarget.parentElement?.querySelector('div:last-child');
|
||||||
|
if (indicator) {
|
||||||
|
(indicator as HTMLElement).style.opacity = '1';
|
||||||
|
(indicator as HTMLElement).style.background = 'linear-gradient(90deg, transparent, rgba(66, 133, 244, 0.3), transparent)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
const indicator = e.currentTarget.parentElement?.querySelector('div:last-child');
|
||||||
|
if (indicator) {
|
||||||
|
(indicator as HTMLElement).style.opacity = '0';
|
||||||
|
(indicator as HTMLElement).style.background = 'linear-gradient(90deg, transparent, rgba(66, 133, 244, 0.2), transparent)';
|
||||||
|
}
|
||||||
|
}} />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
marginTop: '30px',
|
||||||
|
fontSize: '13px',
|
||||||
|
color: 'rgba(66, 133, 244, 0.08)',
|
||||||
|
letterSpacing: '2px',
|
||||||
|
fontWeight: 300,
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
|
}}>
|
||||||
|
⋆ ﹒ ˚ ﹒ ⋆ ﹒ ˚ ﹒ ⋆
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import React from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import { Document, Page, pdfjs } from 'react-pdf';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { FaArrowLeft, FaPrint, FaSpinner, FaCheckCircle, FaExclamationCircle } from 'react-icons/fa';
|
||||||
// ИСПОЛЬЗОВАТЬ CDN - ЭТО РАБОТАЕТ
|
|
||||||
pdfjs.GlobalWorkerOptions.workerSrc =
|
|
||||||
'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
|
||||||
|
|
||||||
interface PayslipViewerProps {
|
interface PayslipViewerProps {
|
||||||
fileUrl: string;
|
fileUrl: string;
|
||||||
@@ -11,36 +8,228 @@ interface PayslipViewerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PayslipViewer: React.FC<PayslipViewerProps> = ({ fileUrl, onBack }) => {
|
const PayslipViewer: React.FC<PayslipViewerProps> = ({ fileUrl, onBack }) => {
|
||||||
const handlePrint = () => {
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
const win = window.open(fileUrl, '_blank');
|
const fullUrl = fileUrl.startsWith('http') ? fileUrl : `http://localhost:3001${fileUrl}`;
|
||||||
if (win) {
|
|
||||||
win.focus();
|
const [isPrinting, setIsPrinting] = useState(false);
|
||||||
win.print();
|
const [printStatus, setPrintStatus] = useState<'idle' | 'success' | 'error' | 'sending'>('idle');
|
||||||
|
const [printMessage, setPrintMessage] = useState('');
|
||||||
|
|
||||||
|
// ✅ ИСПРАВЛЕННЫЙ МЕТОД ПЕЧАТИ
|
||||||
|
const handlePrint = async () => {
|
||||||
|
try {
|
||||||
|
setIsPrinting(true);
|
||||||
|
setPrintStatus('sending');
|
||||||
|
setPrintMessage('Отправка документа на печать...');
|
||||||
|
|
||||||
|
const urlToPrint = fileUrl; // payslipUrl для Employee/Finance, fileUrl для Viewer
|
||||||
|
if (!urlToPrint) throw new Error('URL документа отсутствует');
|
||||||
|
|
||||||
|
const fullUrl = urlToPrint.startsWith('http') ? urlToPrint : `http://localhost:3001${urlToPrint}`;
|
||||||
|
|
||||||
|
// 🚀 ВАРИАНТ 1: Мы в десктопе (Electron)
|
||||||
|
if ((window as any).electronAPI && (window as any).electronAPI.printPdf) {
|
||||||
|
console.log('🖨️ [PRINT] Печатаем тихо через Electron API');
|
||||||
|
await (window as any).electronAPI.printPdf({
|
||||||
|
pdfUrl: fullUrl,
|
||||||
|
deviceName: '' // <-- Оставляем пустым! Electron сам плюнет на дефолтный принтер
|
||||||
|
});
|
||||||
|
|
||||||
|
setPrintStatus('success');
|
||||||
|
setPrintMessage('✅ Отправлено на принтер');
|
||||||
|
}
|
||||||
|
// 🌐 ВАРИАНТ 2: Мы в браузере (Chrome/Edge)
|
||||||
|
else {
|
||||||
|
console.log('🖨️ [PRINT] Electron API нет. Обходим CORS и печатаем через браузер');
|
||||||
|
|
||||||
|
// Скачиваем PDF как Blob, чтобы сделать его "своим" для браузера
|
||||||
|
const response = await fetch(fullUrl);
|
||||||
|
if (!response.ok) throw new Error('Не удалось скачать PDF с сервера');
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
// Создаем невидимый iframe
|
||||||
|
const printIframe = document.createElement('iframe');
|
||||||
|
printIframe.style.display = 'none';
|
||||||
|
printIframe.src = blobUrl;
|
||||||
|
document.body.appendChild(printIframe);
|
||||||
|
|
||||||
|
printIframe.onload = () => {
|
||||||
|
try {
|
||||||
|
printIframe.contentWindow?.focus();
|
||||||
|
printIframe.contentWindow?.print();
|
||||||
|
setPrintStatus('success');
|
||||||
|
setPrintMessage('✅ Документ отправлен на печать');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Ошибка iframe печати:', e);
|
||||||
|
throw new Error('Браузер заблокировал печать');
|
||||||
|
} finally {
|
||||||
|
// Чистим за собой мусор через 5 секунд
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.removeChild(printIframe);
|
||||||
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [PRINT] Ошибка печати:', error);
|
||||||
|
setPrintStatus('error');
|
||||||
|
setPrintMessage('❌ Ошибка: ' + (error instanceof Error ? error.message : 'Неизвестная ошибка'));
|
||||||
|
} finally {
|
||||||
|
setIsPrinting(false);
|
||||||
|
setTimeout(() => {
|
||||||
|
setPrintStatus('idle');
|
||||||
|
setPrintMessage('');
|
||||||
|
}, 3000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="payslip-viewer">
|
<motion.div
|
||||||
<div className="top-bar">
|
initial={{ opacity: 0, y: 20 }}
|
||||||
<button className="back-button" onClick={onBack}>
|
animate={{ opacity: 1, y: 0 }}
|
||||||
← Назад
|
exit={{ opacity: 0, y: -20 }}
|
||||||
</button>
|
transition={{ duration: 0.3 }}
|
||||||
<button className="print-button" onClick={handlePrint}>
|
style={{
|
||||||
🖨️ Печать
|
padding: '20px',
|
||||||
</button>
|
maxWidth: '1200px',
|
||||||
</div>
|
margin: '0 auto',
|
||||||
<div className="pdf-container" style={{
|
height: 'calc(100vh - 80px)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'center',
|
flexDirection: 'column',
|
||||||
width: '100%'
|
background: '#f5f6fa',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AnimatePresence>
|
||||||
|
{printStatus !== 'idle' && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -50, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: -50, scale: 0.9 }}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: '20px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: 9999,
|
||||||
|
padding: '16px 32px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
background: printStatus === 'success' ? '#10b981' :
|
||||||
|
printStatus === 'error' ? '#ef4444' :
|
||||||
|
'#f59e0b',
|
||||||
|
color: 'white',
|
||||||
|
boxShadow: '0 10px 40px rgba(0,0,0,0.2)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 500,
|
||||||
|
minWidth: '300px',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{printStatus === 'sending' && <FaSpinner style={{ animation: 'spin 1s linear infinite' }} />}
|
||||||
|
{printStatus === 'success' && <FaCheckCircle />}
|
||||||
|
{printStatus === 'error' && <FaExclamationCircle />}
|
||||||
|
{printMessage}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: '20px',
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: '0 4px',
|
||||||
}}>
|
}}>
|
||||||
<Document file={fileUrl} loading="Загрузка PDF...">
|
<button
|
||||||
<div style={{ margin: '0 auto' }}>
|
onClick={onBack}
|
||||||
<Page pageNumber={1} />
|
style={{
|
||||||
</div>
|
display: 'flex',
|
||||||
</Document>
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '10px 20px',
|
||||||
|
background: '#6c757d',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaArrowLeft />
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h2 style={{
|
||||||
|
margin: 0,
|
||||||
|
color: '#2d3436',
|
||||||
|
fontSize: '22px',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}>
|
||||||
|
📄 Расчетный листок
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handlePrint}
|
||||||
|
disabled={isPrinting}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '10px 30px',
|
||||||
|
background: isPrinting ? '#b2bec3' : 'linear-gradient(135deg, #00b894, #00cec9)',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '10px',
|
||||||
|
cursor: isPrinting ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '15px',
|
||||||
|
fontWeight: 500,
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
boxShadow: isPrinting ? 'none' : '0 4px 15px rgba(0, 206, 201, 0.3)',
|
||||||
|
opacity: isPrinting ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPrinting ? <FaSpinner style={{ animation: 'spin 1s linear infinite' }} /> : <FaPrint />}
|
||||||
|
{isPrinting ? 'Отправка...' : 'Печать'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
background: '#f5f6fa',
|
||||||
|
borderRadius: '12px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
minHeight: 0,
|
||||||
|
border: 'none',
|
||||||
|
}}>
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
src={`${fullUrl}#toolbar=0&navpanes=0&scrollbar=1&view=FitH`}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
border: 'none',
|
||||||
|
display: 'block',
|
||||||
|
background: '#f5f6fa',
|
||||||
|
}}
|
||||||
|
title="PDF Viewer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<style>{`
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</motion.div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,49 +1,197 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
|
||||||
|
|
||||||
interface VideoBackgroundProps {
|
interface VideoBackgroundProps {
|
||||||
onComplete: () => void;
|
onComplete: () => void;
|
||||||
videoSrc?: string;
|
videoSrc?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Slide {
|
||||||
|
id: number;
|
||||||
|
image_url: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
link?: string;
|
||||||
|
order: number;
|
||||||
|
is_active: number;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const VideoBackground: React.FC<VideoBackgroundProps> = ({
|
const VideoBackground: React.FC<VideoBackgroundProps> = ({
|
||||||
onComplete,
|
onComplete,
|
||||||
videoSrc = '/assets/videos/flag.mp4'
|
videoSrc = '/assets/videos/flag.mp4'
|
||||||
}) => {
|
}) => {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const [slides, setSlides] = useState<Slide[]>([]);
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isAutoPlay, setIsAutoPlay] = useState(true);
|
||||||
|
const autoPlayInterval = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const ADMIN_SERVER_URL = 'http://localhost:3005';
|
||||||
|
|
||||||
|
// ✅ Загрузка слайдов с сервера модератора
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSlides = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch(`${ADMIN_SERVER_URL}/moders/content/slides`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const sortedSlides = Array.isArray(data)
|
||||||
|
? data.sort((a: Slide, b: Slide) => (a.order || 0) - (b.order || 0))
|
||||||
|
: [];
|
||||||
|
setSlides(sortedSlides);
|
||||||
|
} else {
|
||||||
|
console.warn('Не удалось загрузить слайды, используем тестовые');
|
||||||
|
setSlides(getDefaultSlides());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Ошибка загрузки слайдов:', error);
|
||||||
|
setSlides(getDefaultSlides());
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchSlides();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ✅ Тестовые слайды (если модератор не настроен)
|
||||||
|
const getDefaultSlides = (): Slide[] => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
image_url: 'https://picsum.photos/1920/1080?random=1',
|
||||||
|
title: 'Добро пожаловать!',
|
||||||
|
description: 'Инфокиоск для сотрудников',
|
||||||
|
order: 1,
|
||||||
|
is_active: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
image_url: 'https://picsum.photos/1920/1080?random=2',
|
||||||
|
title: 'Новости предприятия',
|
||||||
|
description: 'Будьте в курсе событий',
|
||||||
|
order: 2,
|
||||||
|
is_active: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
image_url: 'https://picsum.photos/1920/1080?random=3',
|
||||||
|
title: 'С днем рождения!',
|
||||||
|
description: 'Поздравляем именинников',
|
||||||
|
order: 3,
|
||||||
|
is_active: 1
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
// ✅ Видео плеер
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (videoRef.current) {
|
if (videoRef.current) {
|
||||||
videoRef.current.play().catch(err => {
|
videoRef.current.play().catch(err => {
|
||||||
console.warn('Видео не воспроизвелось автоматически:', err);
|
console.warn('Видео не воспроизвелось автоматически:', err);
|
||||||
// Если видео не запустилось — показываем экран логина через 5 секунд
|
|
||||||
setTimeout(onComplete, 5000);
|
setTimeout(onComplete, 5000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ❌ Убираем таймер, который закрывал видео через 30 секунд
|
|
||||||
// Теперь видео будет крутиться бесконечно (loop=true)
|
|
||||||
// Только клик или касание пользователя завершают видео
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
// Очистка не нужна, так как таймера больше нет
|
|
||||||
};
|
|
||||||
}, [onComplete]);
|
}, [onComplete]);
|
||||||
|
|
||||||
// ❌ Убираем handleVideoEnd — не нужен, так как loop=true
|
// ✅ Автопрокрутка слайдов
|
||||||
|
useEffect(() => {
|
||||||
|
if (slides.length <= 1 || !isAutoPlay) return;
|
||||||
|
|
||||||
|
autoPlayInterval.current = setInterval(() => {
|
||||||
|
goToNext();
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (autoPlayInterval.current) {
|
||||||
|
clearInterval(autoPlayInterval.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [slides.length, isAutoPlay, currentIndex]);
|
||||||
|
|
||||||
|
// ✅ Навигация
|
||||||
|
const goToNext = () => {
|
||||||
|
setCurrentIndex((prev) => (prev + 1) % slides.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToPrev = () => {
|
||||||
|
setCurrentIndex((prev) => (prev - 1 + slides.length) % slides.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToSlide = (index: number) => {
|
||||||
|
setCurrentIndex(index);
|
||||||
|
resetAutoPlay();
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetAutoPlay = () => {
|
||||||
|
if (autoPlayInterval.current) {
|
||||||
|
clearInterval(autoPlayInterval.current);
|
||||||
|
}
|
||||||
|
if (slides.length > 1) {
|
||||||
|
autoPlayInterval.current = setInterval(() => {
|
||||||
|
goToNext();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
setIsAutoPlay(false);
|
||||||
|
if (autoPlayInterval.current) {
|
||||||
|
clearInterval(autoPlayInterval.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
setIsAutoPlay(true);
|
||||||
|
resetAutoPlay();
|
||||||
|
};
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
onComplete(); // По клику переходим дальше
|
onComplete();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
zIndex: 9999,
|
||||||
|
background: '#000',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: '60px',
|
||||||
|
height: '60px',
|
||||||
|
border: '4px solid #2d2d2d',
|
||||||
|
borderTop: '4px solid #667eea',
|
||||||
|
borderRadius: '50%',
|
||||||
|
animation: 'spin 1s linear infinite',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSlides = slides.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
className="video-splash"
|
|
||||||
onClick={handleClick}
|
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
top: 0,
|
top: 0,
|
||||||
@@ -56,34 +204,264 @@ const VideoBackground: React.FC<VideoBackgroundProps> = ({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
|
{/* ✅ ВИДЕО (фон) */}
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
src={videoSrc}
|
src={videoSrc}
|
||||||
autoPlay
|
autoPlay
|
||||||
muted
|
muted
|
||||||
playsInline
|
playsInline
|
||||||
loop // ✅ Бесконечное воспроизведение
|
loop
|
||||||
style={{
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
objectFit: 'cover',
|
objectFit: 'cover',
|
||||||
|
zIndex: 0,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* ✅ ТЕМНЫЙ ОВЕРЛЕЙ */}
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: '60px',
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: 'rgba(0, 0, 0, 0.35)',
|
||||||
|
zIndex: 1,
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* ✅ КАРУСЕЛЬ ИЗОБРАЖЕНИЙ */}
|
||||||
|
{hasSlides && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
zIndex: 2,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={currentIndex}
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
style={{
|
||||||
|
width: '80%',
|
||||||
|
maxWidth: '900px',
|
||||||
|
maxHeight: '70vh',
|
||||||
|
borderRadius: '20px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
boxShadow: '0 30px 80px rgba(0,0,0,0.5)',
|
||||||
|
position: 'relative',
|
||||||
|
background: '#1a1a2e',
|
||||||
|
border: '1px solid rgba(255,255,255,0.05)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`${ADMIN_SERVER_URL}${slides[currentIndex].image_url}`}
|
||||||
|
alt={slides[currentIndex].title || 'Слайд'}
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
maxHeight: '70vh',
|
||||||
|
objectFit: 'cover',
|
||||||
|
display: 'block',
|
||||||
|
}}
|
||||||
|
onError={(e) => {
|
||||||
|
console.error('❌ Ошибка загрузки:', `${ADMIN_SERVER_URL}${slides[currentIndex].image_url}`);
|
||||||
|
(e.target as HTMLImageElement).src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="100%25" height="100%25"%3E%3Crect width="100%25" height="100%25" fill="%232d3436"/%3E%3Ctext x="50%25" y="50%25" text-anchor="middle" dy=".3em" fill="%23636e72" font-size="24" font-family="sans-serif"%3EИзображение не найдено%3C/text%3E%3C/svg%3E';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ✅ Текст поверх изображения */}
|
||||||
|
{(slides[currentIndex].title || slides[currentIndex].description) && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
padding: '30px 40px 40px',
|
||||||
|
background: 'linear-gradient(transparent, rgba(0,0,0,0.7))',
|
||||||
|
color: 'white',
|
||||||
|
}}>
|
||||||
|
{slides[currentIndex].title && (
|
||||||
|
<h2 style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '28px',
|
||||||
|
fontWeight: 600,
|
||||||
|
textShadow: '0 2px 10px rgba(0,0,0,0.3)',
|
||||||
|
}}>
|
||||||
|
{slides[currentIndex].title}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
{slides[currentIndex].description && (
|
||||||
|
<p style={{
|
||||||
|
margin: '8px 0 0',
|
||||||
|
fontSize: '16px',
|
||||||
|
opacity: 0.8,
|
||||||
|
textShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
||||||
|
}}>
|
||||||
|
{slides[currentIndex].description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* ✅ КНОПКИ НАВИГАЦИИ
|
||||||
|
{slides.length > 1 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
goToPrev();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '20px',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
width: '50px',
|
||||||
|
height: '50px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'rgba(255,255,255,0.1)',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.15)',
|
||||||
|
color: 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '20px',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = 'rgba(255,255,255,0.2)';
|
||||||
|
e.currentTarget.style.transform = 'translateY(-50%) scale(1.1)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'rgba(255,255,255,0.1)';
|
||||||
|
e.currentTarget.style.transform = 'translateY(-50%) scale(1)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaChevronLeft />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
goToNext();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: '20px',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
width: '50px',
|
||||||
|
height: '50px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'rgba(255,255,255,0.1)',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.15)',
|
||||||
|
color: 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '20px',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = 'rgba(255,255,255,0.2)';
|
||||||
|
e.currentTarget.style.transform = 'translateY(-50%) scale(1.1)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'rgba(255,255,255,0.1)';
|
||||||
|
e.currentTarget.style.transform = 'translateY(-50%) scale(1)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaChevronRight />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)} */}
|
||||||
|
|
||||||
|
{/* ✅ ИНДИКАТОРЫ СЛАЙДОВ */}
|
||||||
|
{slides.length > 1 && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '30px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '10px',
|
||||||
|
zIndex: 10,
|
||||||
|
}}>
|
||||||
|
{slides.map((_, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
goToSlide(index);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: index === currentIndex ? '12px' : '8px',
|
||||||
|
height: '8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: index === currentIndex
|
||||||
|
? 'rgba(255,255,255,0.9)'
|
||||||
|
: 'rgba(255,255,255,0.3)',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ✅ ПОДСКАЗКА */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '20px',
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
color: '#fff',
|
color: 'rgba(255,255,255,0.3)',
|
||||||
fontSize: '14px',
|
fontSize: '13px',
|
||||||
opacity: 0.6,
|
zIndex: 3,
|
||||||
textShadow: '0 2px 4px rgba(0,0,0,0.5)',
|
textShadow: '0 2px 4px rgba(0,0,0,0.5)',
|
||||||
|
letterSpacing: '1px',
|
||||||
|
pointerEvents: 'none',
|
||||||
}}>
|
}}>
|
||||||
Нажмите для продолжения
|
{hasSlides ? 'Нажмите для входа · Для просмотра используйте стрелки' : 'Нажмите для продолжения'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
interface WeatherDay {
|
interface WeatherDay {
|
||||||
date: string;
|
date: string;
|
||||||
@@ -12,9 +12,13 @@ interface WeatherDay {
|
|||||||
|
|
||||||
interface WeatherWidgetProps {
|
interface WeatherWidgetProps {
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
|
employee?: {
|
||||||
|
ima: string;
|
||||||
|
famaly: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const WeatherWidget: React.FC<WeatherWidgetProps> = ({ onLogout }) => {
|
const WeatherWidget: React.FC<WeatherWidgetProps> = ({ onLogout, employee }) => {
|
||||||
const [weatherData, setWeatherData] = useState<WeatherDay[]>([]);
|
const [weatherData, setWeatherData] = useState<WeatherDay[]>([]);
|
||||||
const [currentWeather, setCurrentWeather] = useState<WeatherDay | null>(null);
|
const [currentWeather, setCurrentWeather] = useState<WeatherDay | null>(null);
|
||||||
const [isOnline, setIsOnline] = useState(navigator.onLine);
|
const [isOnline, setIsOnline] = useState(navigator.onLine);
|
||||||
@@ -57,7 +61,6 @@ const WeatherWidget: React.FC<WeatherWidgetProps> = ({ onLogout }) => {
|
|||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
// fallback
|
|
||||||
setCurrentWeather({
|
setCurrentWeather({
|
||||||
date: new Date().toISOString().split('T')[0],
|
date: new Date().toISOString().split('T')[0],
|
||||||
dayOfWeek: 'Сегодня',
|
dayOfWeek: 'Сегодня',
|
||||||
@@ -160,6 +163,20 @@ const WeatherWidget: React.FC<WeatherWidgetProps> = ({ onLogout }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const forecastDays = weatherData.slice(1, 7);
|
||||||
|
const doubledForecast = [...forecastDays, ...forecastDays, ...forecastDays];
|
||||||
|
|
||||||
|
const getGreeting = () => {
|
||||||
|
if (!employee) return '';
|
||||||
|
const hour = currentTime.getHours();
|
||||||
|
let greeting = '';
|
||||||
|
if (hour >= 5 && hour < 12) greeting = 'Доброе утро';
|
||||||
|
else if (hour >= 12 && hour < 17) greeting = 'Добрый день';
|
||||||
|
else if (hour >= 17 && hour < 22) greeting = 'Добрый вечер';
|
||||||
|
else greeting = 'Доброй ночи';
|
||||||
|
return `${greeting}, ${employee.ima}`;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ y: -100, opacity: 0 }}
|
initial={{ y: -100, opacity: 0 }}
|
||||||
@@ -171,37 +188,42 @@ const WeatherWidget: React.FC<WeatherWidgetProps> = ({ onLogout }) => {
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
background: 'rgba(44, 62, 80, 0.75)', // ✅ полупрозрачный
|
background: 'linear-gradient(135deg, #1b718b 65%, #473f72 100%)',
|
||||||
backdropFilter: 'blur(12px)',
|
backdropFilter: 'blur(16px)',
|
||||||
padding: '5px 15px',
|
WebkitBackdropFilter: 'blur(16px)',
|
||||||
borderBottom: '1px solid rgba(255,255,255,0.08)',
|
padding: '6px 20px',
|
||||||
|
borderBottom: '1px solid rgba(255,255,255,0.06)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
minHeight: '70px',
|
minHeight: '75px',
|
||||||
boxShadow: '0 2px 20px rgba(0,0,0,0.15)',
|
gap: '8px',
|
||||||
|
flexWrap: 'nowrap',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Левая часть: Время */}
|
{/* ===== ЛЕВАЯ ЧАСТЬ: ВРЕМЯ + ПОГОДА СЕГОДНЯ ===== */}
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '12px',
|
gap: '16px',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
minWidth: '140px',
|
zIndex: 2,
|
||||||
}}>
|
}}>
|
||||||
|
{/* Время */}
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
|
minWidth: '90px',
|
||||||
}}>
|
}}>
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: '30px',
|
fontSize: '38px',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
letterSpacing: '0.5px',
|
letterSpacing: '0.5px',
|
||||||
lineHeight: 1,
|
lineHeight: 1.1,
|
||||||
fontVariantNumeric: 'tabular-nums',
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
textShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||||
}}>
|
}}>
|
||||||
{currentTime.toLocaleString('ru-RU', {
|
{currentTime.toLocaleString('ru-RU', {
|
||||||
timeZone: 'Europe/Minsk',
|
timeZone: 'Europe/Minsk',
|
||||||
@@ -211,10 +233,11 @@ const WeatherWidget: React.FC<WeatherWidgetProps> = ({ onLogout }) => {
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: '12px',
|
fontSize: '11px',
|
||||||
color: 'rgba(255,255,255,0.5)',
|
color: 'rgba(255,255,255,0.4)',
|
||||||
letterSpacing: '0.3px',
|
letterSpacing: '0.3px',
|
||||||
fontWeight: 400,
|
fontWeight: 400,
|
||||||
|
textTransform: 'uppercase',
|
||||||
}}>
|
}}>
|
||||||
{currentTime.toLocaleString('ru-RU', {
|
{currentTime.toLocaleString('ru-RU', {
|
||||||
timeZone: 'Europe/Minsk',
|
timeZone: 'Europe/Minsk',
|
||||||
@@ -224,128 +247,180 @@ const WeatherWidget: React.FC<WeatherWidgetProps> = ({ onLogout }) => {
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Разделитель */}
|
||||||
|
<div style={{
|
||||||
|
width: '1px',
|
||||||
|
height: '32px',
|
||||||
|
background: 'rgba(255,255,255,0.06)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* Погода сегодня */}
|
||||||
|
{isOnline && !loading && currentWeather && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
background: 'rgba(255,255,255,0.05)',
|
||||||
|
padding: '4px 16px 4px 12px',
|
||||||
|
borderRadius: '30px',
|
||||||
|
border: '1px solid rgba(255,255,255,0.04)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '26px', lineHeight: 1 }}>
|
||||||
|
{getWeatherIcon(currentWeather.icon)}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', lineHeight: 1.2 }}>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#fff',
|
||||||
|
}}>
|
||||||
|
{Math.round(currentWeather.tempMax)}°
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
color: 'rgba(255,255,255,0.35)',
|
||||||
|
textTransform: 'capitalize',
|
||||||
|
}}>
|
||||||
|
{currentWeather.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Центральная часть: Погода */}
|
{/* ===== ЦЕНТРАЛЬНАЯ ЧАСТЬ: ПРИВЕТСТВИЕ ===== */}
|
||||||
<div style={{
|
<div style={{
|
||||||
flex: 1,
|
flex: '1 1 0%',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
padding: '0 16px',
|
height: '100%',
|
||||||
minHeight: '56px',
|
minWidth: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
}}>
|
}}>
|
||||||
<AnimatePresence mode="wait">
|
{employee && (
|
||||||
{isOnline && !loading && currentWeather ? (
|
<motion.div
|
||||||
<motion.div
|
initial={{ opacity: 0, y: -5 }}
|
||||||
key="weather"
|
animate={{ opacity: 1, y: 0 }}
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
transition={{ duration: 0.6, delay: 0.2 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
style={{
|
||||||
exit={{ opacity: 0, scale: 0.9 }}
|
fontSize: 'clamp(18px, 1.6vw, 28px)',
|
||||||
style={{
|
fontWeight: 500,
|
||||||
display: 'flex',
|
color: 'rgba(255,255,255,0.92)',
|
||||||
alignItems: 'center',
|
textAlign: 'center',
|
||||||
gap: '16px',
|
whiteSpace: 'nowrap',
|
||||||
}}
|
letterSpacing: '0.8px',
|
||||||
>
|
textShadow: '0 2px 12px rgba(0,0,0,0.15)',
|
||||||
<div style={{
|
background: 'rgba(255,255,255,0.04)',
|
||||||
display: 'flex',
|
padding: '4px 24px',
|
||||||
alignItems: 'center',
|
borderRadius: '30px',
|
||||||
gap: '10px',
|
border: '1px solid rgba(255,255,255,0.04)',
|
||||||
background: 'rgba(255,255,255,0.08)',
|
maxWidth: '100%',
|
||||||
padding: '4px 16px 4px 10px',
|
overflow: 'hidden',
|
||||||
borderRadius: '30px',
|
textOverflow: 'ellipsis',
|
||||||
border: '1px solid rgba(255,255,255,0.06)',
|
}}
|
||||||
}}>
|
>
|
||||||
<span style={{ fontSize: '32px', lineHeight: 1 }}>
|
{getGreeting()}
|
||||||
{getWeatherIcon(currentWeather.icon)}
|
</motion.div>
|
||||||
</span>
|
)}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
|
||||||
<span style={{
|
|
||||||
fontSize: '18px',
|
|
||||||
fontWeight: 600,
|
|
||||||
color: '#fff',
|
|
||||||
}}>
|
|
||||||
{Math.round(currentWeather.tempMax)}°
|
|
||||||
</span>
|
|
||||||
<span style={{
|
|
||||||
fontSize: '11px',
|
|
||||||
color: 'rgba(255,255,255,0.5)',
|
|
||||||
}}>
|
|
||||||
{currentWeather.description}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: '10px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}>
|
|
||||||
{weatherData.slice(1, 7).map((day, index) => (
|
|
||||||
<motion.div
|
|
||||||
key={day.date}
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: index * 0.05 }}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '2px',
|
|
||||||
padding: '2px 8px',
|
|
||||||
borderRadius: '6px',
|
|
||||||
background: 'rgba(255,255,255,0.04)',
|
|
||||||
minWidth: '40px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{
|
|
||||||
fontSize: '9px',
|
|
||||||
color: 'rgba(255,255,255,0.5)',
|
|
||||||
fontWeight: 500,
|
|
||||||
}}>
|
|
||||||
{day.dayOfWeek}
|
|
||||||
</span>
|
|
||||||
<span style={{ fontSize: '16px', lineHeight: 1 }}>
|
|
||||||
{getWeatherIcon(day.icon)}
|
|
||||||
</span>
|
|
||||||
<span style={{
|
|
||||||
fontSize: '10px',
|
|
||||||
fontWeight: 600,
|
|
||||||
color: 'rgba(255,255,255,0.8)',
|
|
||||||
}}>
|
|
||||||
{Math.round(day.tempMax)}°
|
|
||||||
</span>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
) : (
|
|
||||||
<motion.div
|
|
||||||
key="placeholder"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
style={{
|
|
||||||
color: 'rgba(255,255,255,0.3)',
|
|
||||||
fontSize: '13px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Правая часть: Кнопка выхода */}
|
{/* ===== ПРАВАЯ ЧАСТЬ: ПРОГНОЗ С ПРОКРУТКОЙ ===== */}
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '8px',
|
gap: '12px',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
minWidth: '80px',
|
maxWidth: '380px',
|
||||||
justifyContent: 'flex-end',
|
minWidth: '140px',
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
maskImage: 'linear-gradient(to right, transparent, black 3%, black 97%, transparent)',
|
||||||
|
WebkitMaskImage: 'linear-gradient(to right, transparent, black 3%, black 97%, transparent)',
|
||||||
|
}}>
|
||||||
|
<motion.div
|
||||||
|
animate={{
|
||||||
|
x: ['0%', '-33.33%'],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 20,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: 'linear',
|
||||||
|
repeatType: 'loop',
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '14px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
willChange: 'transform',
|
||||||
|
transform: 'translateZ(0)',
|
||||||
|
backfaceVisibility: 'hidden',
|
||||||
|
WebkitFontSmoothing: 'antialiased',
|
||||||
|
MozOsxFontSmoothing: 'grayscale',
|
||||||
|
textRendering: 'geometricPrecision',
|
||||||
|
paddingRight: '14px',
|
||||||
|
height: '100%',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{doubledForecast.map((day, index) => (
|
||||||
|
<div
|
||||||
|
key={`${day.date}-${index}`}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: '4px 12px',
|
||||||
|
borderRadius: '20px',
|
||||||
|
background: 'rgba(255,255,255,0.04)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.03)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
color: 'rgba(255,255,255,0.4)',
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: '0.3px',
|
||||||
|
minWidth: '20px',
|
||||||
|
}}>
|
||||||
|
{day.dayOfWeek}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: '18px', lineHeight: 1 }}>
|
||||||
|
{getWeatherIcon(day.icon)}
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'rgba(255,255,255,0.85)',
|
||||||
|
minWidth: '24px',
|
||||||
|
textAlign: 'right',
|
||||||
|
}}>
|
||||||
|
{Math.round(day.tempMax)}°
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== КНОПКА ВЫХОДА ===== */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
marginLeft: '4px',
|
||||||
|
zIndex: 2,
|
||||||
}}>
|
}}>
|
||||||
{onLogout && (
|
{onLogout && (
|
||||||
<motion.button
|
<motion.button
|
||||||
@@ -353,38 +428,34 @@ const WeatherWidget: React.FC<WeatherWidgetProps> = ({ onLogout }) => {
|
|||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
style={{
|
style={{
|
||||||
padding: '6px 16px',
|
padding: '6px 18px',
|
||||||
background: 'rgba(231, 76, 60, 0.7)',
|
background: 'rgba(231, 76, 60, 0.5)',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
border: 'none',
|
border: '1px solid rgba(255,255,255,0.06)',
|
||||||
borderRadius: '8px',
|
borderRadius: '30px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '13px',
|
fontSize: '16px',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
transition: 'all 0.2s',
|
transition: 'all 0.25s ease',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
minHeight: '34px',
|
minHeight: '32px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
gap: '6px',
|
gap: '6px',
|
||||||
backdropFilter: 'blur(4px)',
|
backdropFilter: 'blur(4px)',
|
||||||
borderWidth: '1px',
|
boxShadow: '0 2px 12px rgba(231, 76, 60, 0.1)',
|
||||||
borderStyle: 'solid',
|
|
||||||
borderColor: 'rgba(255,255,255,0.1)',
|
|
||||||
boxShadow: '0 2px 10px rgba(231, 76, 60, 0.2)',
|
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.background = 'rgba(231, 76, 60, 1)';
|
e.currentTarget.style.background = 'rgba(231, 76, 60, 0.8)';
|
||||||
e.currentTarget.style.boxShadow = '0 4px 20px rgba(231, 76, 60, 0.4)';
|
e.currentTarget.style.boxShadow = '0 4px 20px rgba(231, 76, 60, 0.25)';
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.background = 'rgba(231, 76, 60, 0.7)';
|
e.currentTarget.style.background = 'rgba(231, 76, 60, 0.5)';
|
||||||
e.currentTarget.style.boxShadow = '0 2px 10px rgba(231, 76, 60, 0.2)';
|
e.currentTarget.style.boxShadow = '0 2px 12px rgba(231, 76, 60, 0.1)';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: '16px' }}>⏻</span>
|
<span style={{ display: 'inline', padding: '5px 10px' }}>ВЫЙТИ</span>
|
||||||
Выйти
|
|
||||||
</motion.button>
|
</motion.button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1 +1,15 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
export {};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
electronAPI?: {
|
||||||
|
getAppConfig: () => Promise<any>;
|
||||||
|
onCardDetected: (callback: (uid: string) => void) => void;
|
||||||
|
removeCardDetectedListeners: () => void;
|
||||||
|
printPdf: (options?: { pdfUrl?: string; deviceName?: string }) => Promise<boolean>;
|
||||||
|
getPrinters: () => Promise<any[]>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
{"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"}
|
||||||