Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b2f0903b1 | ||
|
|
70a20665ae | ||
|
|
1fe0d67731 | ||
|
|
49c7fd306a | ||
|
|
f052486e71 | ||
|
|
c8168ff8d7 | ||
|
|
f3f26b697a | ||
|
|
204e6a37ed | ||
|
|
e52bfc8f84 | ||
|
|
b0754e8cdb | ||
|
|
b0ee07823e |
@@ -6,3 +6,4 @@
|
|||||||
/backend/logs
|
/backend/logs
|
||||||
/admin-server1/node_modules
|
/admin-server1/node_modules
|
||||||
/admin-frontend/node_modules
|
/admin-frontend/node_modules
|
||||||
|
/node_modules
|
||||||
@@ -30,6 +30,10 @@
|
|||||||
- [x] 5.5 Удалить react-scripts
|
- [x] 5.5 Удалить react-scripts
|
||||||
|
|
||||||
## Этап 6: Проверка
|
## Этап 6: Проверка
|
||||||
- [ ] 6.1 npm install для backend и frontend
|
- [x] 6.1 npm install для backend и frontend
|
||||||
- [ ] 6.2 npm run build для frontend
|
- [x] 6.2 npm run build для frontend (Vite 8, 503ms)
|
||||||
- [ ] 6.3 Запуск backend
|
- [x] 6.3 Запуск backend
|
||||||
|
|
||||||
|
## Этап 7: Синхронизация версий Vite (после реструктуризации)
|
||||||
|
- [x] 7.1 frontend обновлён до Vite 8.2.1 + plugin-react 6.0.5 (совпадение с admin-frontend)
|
||||||
|
- [x] 7.2 Сборка frontend подтверждена (621 kB, gzip 187 kB, 503ms)
|
||||||
|
|||||||
@@ -21,3 +21,4 @@
|
|||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
/node_modules
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="stylesheet" href="https://cdn.quilljs.com/1.3.6/quill.snow.css" />
|
||||||
<title>Admin Frontend</title>
|
<title>Admin Frontend</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"dompurify": "^3.4.13",
|
"dompurify": "^3.4.13",
|
||||||
"quill": "^2.0.3",
|
"quill": "^2.0.3",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8"
|
"react-dom": "^19.2.8",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^6.0.5",
|
"@vitejs/plugin-react": "^6.0.5",
|
||||||
@@ -441,6 +442,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/adler-32": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ansi-regex": {
|
"node_modules/ansi-regex": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
@@ -471,6 +481,40 @@
|
|||||||
"dequal": "^2.0.3"
|
"dequal": "^2.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cfb": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"crc-32": "~1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/codepage": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/crc-32": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"crc32": "bin/crc32.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/css.escape": {
|
"node_modules/css.escape": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
||||||
@@ -541,6 +585,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/frac": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -1074,6 +1127,18 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ssf": {
|
||||||
|
"version": "0.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||||
|
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"frac": "~1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/strip-indent": {
|
"node_modules/strip-indent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
||||||
@@ -1180,6 +1245,45 @@
|
|||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wmf": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/word": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xlsx": {
|
||||||
|
"version": "0.18.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||||
|
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"cfb": "~1.2.1",
|
||||||
|
"codepage": "~1.15.0",
|
||||||
|
"crc-32": "~1.2.1",
|
||||||
|
"ssf": "~0.11.2",
|
||||||
|
"wmf": "~1.0.1",
|
||||||
|
"word": "~0.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"xlsx": "bin/xlsx.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,11 @@
|
|||||||
"dompurify": "^3.4.13",
|
"dompurify": "^3.4.13",
|
||||||
"quill": "^2.0.3",
|
"quill": "^2.0.3",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8"
|
"react-dom": "^19.2.8",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"rebuild": "rm -rf dist && npm run build",
|
||||||
"start": "vite",
|
"start": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import 'quill/dist/quill.snow.css';
|
||||||
import '../App.css';
|
import '../App.css';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
|
import SlidesManager from './SlidesManager';
|
||||||
|
import FoodManager from './FoodManager';
|
||||||
|
|
||||||
function Editor() {
|
function Editor() {
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
@@ -14,7 +17,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,16 +31,6 @@ function Editor() {
|
|||||||
}, 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(() => {
|
||||||
fetch('/moders/check-session', { credentials: 'include' })
|
fetch('/moders/check-session', { credentials: 'include' })
|
||||||
@@ -54,12 +46,8 @@ function Editor() {
|
|||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const authenticatedFetch = useCallback((url, options = {}) => {
|
const authenticatedFetch = useCallback((url, options = {}) => {
|
||||||
if (!options.headers) options.headers = {};
|
|
||||||
if (options.method && ['POST', 'PUT', 'DELETE'].includes(options.method.toUpperCase())) {
|
|
||||||
options.headers['x-csrf-token'] = csrfToken;
|
|
||||||
}
|
|
||||||
return fetch(url, options);
|
return fetch(url, options);
|
||||||
}, [csrfToken]);
|
}, []);
|
||||||
|
|
||||||
const loadBlocks = useCallback(async (tab) => {
|
const loadBlocks = useCallback(async (tab) => {
|
||||||
try {
|
try {
|
||||||
@@ -107,7 +95,7 @@ function Editor() {
|
|||||||
|
|
||||||
const handleTabChange = (tab) => {
|
const handleTabChange = (tab) => {
|
||||||
setActiveTab(tab);
|
setActiveTab(tab);
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn && tab !== 'slides' && tab !== 'food') {
|
||||||
loadBlocks(tab);
|
loadBlocks(tab);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -144,24 +132,27 @@ function Editor() {
|
|||||||
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
|
||||||
|
? `/moders/content/${activeTab}/${editingBlockId}`
|
||||||
|
: `/moders/content/${activeTab}`;
|
||||||
|
|
||||||
|
const method = editingBlockId ? 'PUT' : 'POST';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (editingBlockId) {
|
const response = await fetch(url, {
|
||||||
await authenticatedFetch(`/moders/content/${activeTab}/${editingBlockId}`, {
|
method,
|
||||||
method: 'PUT',
|
headers: { 'Content-Type': 'application/json' },
|
||||||
headers: { 'Content-Type': 'application/json' },
|
body: JSON.stringify({ type: 'text', content })
|
||||||
body: JSON.stringify({ type: 'text', content })
|
});
|
||||||
});
|
|
||||||
showNotify('Блок обновлён', 'success');
|
if (response.ok) {
|
||||||
|
showNotify(editingBlockId ? 'Блок обновлён' : 'Блок добавлен', 'success');
|
||||||
|
setShowEditModal(false);
|
||||||
|
loadBlocks(activeTab);
|
||||||
} else {
|
} else {
|
||||||
await authenticatedFetch(`/moders/content/${activeTab}`, {
|
const data = await response.json();
|
||||||
method: 'POST',
|
showNotify(`Ошибка: ${data.message || response.status}`, 'error');
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ type: 'text', content })
|
|
||||||
});
|
|
||||||
showNotify('Блок добавлен', 'success');
|
|
||||||
}
|
}
|
||||||
setShowEditModal(false);
|
|
||||||
loadBlocks(activeTab);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showNotify('Ошибка сохранения', 'error');
|
showNotify('Ошибка сохранения', 'error');
|
||||||
}
|
}
|
||||||
@@ -187,7 +178,6 @@ function Editor() {
|
|||||||
// 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) {
|
||||||
@@ -218,41 +208,73 @@ function Editor() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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>
|
||||||
|
<button className={`tab-btn ${activeTab === 'food' ? 'active' : ''}`} onClick={() => setActiveTab('food')} data-tab="food">
|
||||||
|
🍔 Питание
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Рендеринг контента вкладок */}
|
||||||
|
{activeTab === 'slides' ? (
|
||||||
|
<SlidesManager />
|
||||||
|
) : activeTab === 'food' ? (
|
||||||
|
<FoodManager />
|
||||||
) : (
|
) : (
|
||||||
<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 === 'union' ? 'active' : ''}`} onClick={() => handleTabChange('union')} data-tab="union">Профсоюз</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>
|
||||||
@@ -266,9 +288,10 @@ function Editor() {
|
|||||||
</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,477 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
|
||||||
|
function FoodManager() {
|
||||||
|
const [activeSubTab, setActiveSubTab] = useState('orders');
|
||||||
|
|
||||||
|
// --- ЗАКАЗЫ ---
|
||||||
|
const [orders, setOrders] = useState([]);
|
||||||
|
const [loadingOrders, setLoadingOrders] = useState(false);
|
||||||
|
const [orderStatus, setOrderStatus] = useState('all'); // all, new, accepted, done, cancelled
|
||||||
|
|
||||||
|
// --- МЕНЮ ---
|
||||||
|
const [menuData, setMenuData] = useState({ company: '', categories: [] });
|
||||||
|
const [loadingMenu, setLoadingMenu] = useState(false);
|
||||||
|
const [showItemModal, setShowItemModal] = useState(false);
|
||||||
|
const [editingItem, setEditingItem] = useState(null);
|
||||||
|
const [uploadingImage, setUploadingImage] = useState(false);
|
||||||
|
|
||||||
|
const [itemForm, setItemForm] = useState({
|
||||||
|
category_id: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
price: '',
|
||||||
|
weight_grams: '',
|
||||||
|
image_url: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
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 }), 3000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ЗАКАЗЫ
|
||||||
|
// ============================================
|
||||||
|
const fetchOrders = useCallback(async () => {
|
||||||
|
setLoadingOrders(true);
|
||||||
|
try {
|
||||||
|
const url = orderStatus === 'all'
|
||||||
|
? '/moders/food/orders'
|
||||||
|
: `/moders/food/orders?status=${orderStatus}`;
|
||||||
|
const res = await fetch(url, { credentials: 'include' });
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setOrders(Array.isArray(data) ? data : []);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showNotify('Ошибка загрузки заказов', 'error');
|
||||||
|
} finally {
|
||||||
|
setLoadingOrders(false);
|
||||||
|
}
|
||||||
|
}, [orderStatus, showNotify]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeSubTab === 'orders') fetchOrders();
|
||||||
|
}, [activeSubTab, fetchOrders]);
|
||||||
|
|
||||||
|
const updateOrderStatus = async (id, status) => {
|
||||||
|
// Если это отмена — спрашиваем подтверждение
|
||||||
|
if (status === 'cancelled') {
|
||||||
|
if (!window.confirm('Вы уверены? Заказ будет отменён, деньги вернутся на баланс сотрудника. Это действие нельзя отменить.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/moders/food/orders/${id}/status`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ status })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
if (data.refunded) {
|
||||||
|
showNotify(`Заказ отменён. Возвращено: ${data.refund.toFixed(2)} BYN`, 'success');
|
||||||
|
} else {
|
||||||
|
showNotify('Статус обновлён', 'success');
|
||||||
|
}
|
||||||
|
fetchOrders();
|
||||||
|
} else {
|
||||||
|
showNotify(data.error || 'Ошибка обновления', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showNotify('Ошибка обновления', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Экспорт в Excel
|
||||||
|
const exportToExcel = () => {
|
||||||
|
if (orders.length === 0) {
|
||||||
|
showNotify('Нет заказов для экспорта', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Формируем плоскую таблицу
|
||||||
|
const rows = [];
|
||||||
|
orders.forEach(order => {
|
||||||
|
if (!order.items || order.items.length === 0) {
|
||||||
|
rows.push({
|
||||||
|
'№ Заказа': order.id,
|
||||||
|
'Дата': new Date(order.createdAt).toLocaleString('ru-RU'),
|
||||||
|
'Сотрудник': order.employee || 'Неизвестно',
|
||||||
|
'Табельный': order.tnom,
|
||||||
|
'Статус': order.status,
|
||||||
|
'Блюдо': '(пусто)',
|
||||||
|
'Кол-во': '',
|
||||||
|
'Цена за шт.': '',
|
||||||
|
'Сумма позиции': '',
|
||||||
|
'Итого по заказу': order.totalAmount
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
order.items.forEach((item, idx) => {
|
||||||
|
rows.push({
|
||||||
|
'№ Заказа': idx === 0 ? order.id : '', // Пишем ID только в первой строке
|
||||||
|
'Дата': idx === 0 ? new Date(order.createdAt).toLocaleString('ru-RU') : '',
|
||||||
|
'Сотрудник': idx === 0 ? (order.employee || 'Неизвестно') : '',
|
||||||
|
'Табельный': idx === 0 ? order.tnom : '',
|
||||||
|
'Статус': idx === 0 ? order.status : '',
|
||||||
|
'Блюдо': item.name,
|
||||||
|
'Кол-во': item.quantity,
|
||||||
|
'Цена за шт.': item.unitPrice,
|
||||||
|
'Сумма позиции': item.subtotal,
|
||||||
|
'Итого по заказу': idx === 0 ? order.totalAmount : ''
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const ws = XLSX.utils.json_to_sheet(rows);
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, 'Заказы');
|
||||||
|
XLSX.writeFile(wb, `Заказы_питания_${new Date().toLocaleDateString('ru-RU')}.xlsx`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// МЕНЮ (БЛЮДА)
|
||||||
|
// ============================================
|
||||||
|
const fetchMenu = useCallback(async () => {
|
||||||
|
setLoadingMenu(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/moders/food/items', { credentials: 'include' });
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setMenuData({
|
||||||
|
company: data.company,
|
||||||
|
categories: data.categories || []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showNotify('Ошибка загрузки меню', 'error');
|
||||||
|
} finally {
|
||||||
|
setLoadingMenu(false);
|
||||||
|
}
|
||||||
|
}, [showNotify]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeSubTab === 'menu') fetchMenu();
|
||||||
|
}, [activeSubTab, fetchMenu]);
|
||||||
|
|
||||||
|
const openAddModal = () => {
|
||||||
|
setEditingItem(null);
|
||||||
|
setItemForm({
|
||||||
|
category_id: menuData.categories[0]?.id || '',
|
||||||
|
name: '', description: '', price: '', weight_grams: '', image_url: ''
|
||||||
|
});
|
||||||
|
setShowItemModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditModal = (item, catId) => {
|
||||||
|
setEditingItem({ ...item, category_id: catId });
|
||||||
|
setItemForm({
|
||||||
|
category_id: catId,
|
||||||
|
name: item.name,
|
||||||
|
description: item.description || '',
|
||||||
|
price: item.price,
|
||||||
|
weight_grams: item.weightGrams || '',
|
||||||
|
image_url: item.imageUrl || ''
|
||||||
|
});
|
||||||
|
setShowItemModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveItem = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('category_id', itemForm.category_id);
|
||||||
|
formData.append('name', itemForm.name);
|
||||||
|
formData.append('description', itemForm.description);
|
||||||
|
formData.append('price', itemForm.price);
|
||||||
|
formData.append('weight_grams', itemForm.weight_grams);
|
||||||
|
|
||||||
|
if (itemForm.imageFile) {
|
||||||
|
formData.append('image', itemForm.imageFile);
|
||||||
|
} else if (itemForm.image_url) {
|
||||||
|
formData.append('current_image_url', itemForm.image_url);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = editingItem
|
||||||
|
? `/moders/food/items/${editingItem.id}`
|
||||||
|
: '/moders/food/items';
|
||||||
|
const method = editingItem ? 'PUT' : 'POST';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
body: formData,
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
showNotify(editingItem ? 'Блюдо обновлено' : 'Блюдо добавлено', 'success');
|
||||||
|
setShowItemModal(false);
|
||||||
|
fetchMenu();
|
||||||
|
} else {
|
||||||
|
showNotify('Ошибка сохранения', 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showNotify('Ошибка сервера', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteItem = async (id) => {
|
||||||
|
if (!window.confirm('Удалить это блюдо?')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/moders/food/items/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
showNotify('Блюдо удалено', 'success');
|
||||||
|
fetchMenu();
|
||||||
|
} else {
|
||||||
|
showNotify('Ошибка удаления', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showNotify('Ошибка удаления', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// РЕНДЕР
|
||||||
|
// ============================================
|
||||||
|
return (
|
||||||
|
<div style={{ paddingBottom: '40px' }}>
|
||||||
|
{/* Уведомления */}
|
||||||
|
{notify.show && (
|
||||||
|
<div style={{
|
||||||
|
padding: '12px 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Суб-вкладки */}
|
||||||
|
<div style={{ display: 'flex', gap: '10px', marginBottom: '20px', borderBottom: '2px solid #eee', paddingBottom: '10px' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveSubTab('orders')}
|
||||||
|
style={{
|
||||||
|
padding: '8px 20px', border: 'none', borderRadius: '8px', cursor: 'pointer',
|
||||||
|
background: activeSubTab === 'orders' ? '#0056b3' : '#f8f9fa',
|
||||||
|
color: activeSubTab === 'orders' ? 'white' : '#333',
|
||||||
|
fontWeight: activeSubTab === 'orders' ? 'bold' : 'normal'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📦 Заказы
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveSubTab('menu')}
|
||||||
|
style={{
|
||||||
|
padding: '8px 20px', border: 'none', borderRadius: '8px', cursor: 'pointer',
|
||||||
|
background: activeSubTab === 'menu' ? '#0056b3' : '#f8f9fa',
|
||||||
|
color: activeSubTab === 'menu' ? 'white' : '#333',
|
||||||
|
fontWeight: activeSubTab === 'menu' ? 'bold' : 'normal'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🍔 Управление меню
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ================= ЗАКАЗЫ ================= */}
|
||||||
|
{activeSubTab === 'orders' && (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '15px' }}>
|
||||||
|
<div style={{ display: 'flex', gap: '10px' }}>
|
||||||
|
<select
|
||||||
|
value={orderStatus}
|
||||||
|
onChange={(e) => setOrderStatus(e.target.value)}
|
||||||
|
style={{ padding: '8px', borderRadius: '6px', border: '1px solid #ccc' }}
|
||||||
|
>
|
||||||
|
<option value="all">Все статусы</option>
|
||||||
|
<option value="new">Новые</option>
|
||||||
|
<option value="accepted">Принятые</option>
|
||||||
|
<option value="done">Выполненные</option>
|
||||||
|
<option value="cancelled">Отменённые</option>
|
||||||
|
</select>
|
||||||
|
<button onClick={fetchOrders} className="btn-small">Обновить</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={exportToExcel}
|
||||||
|
style={{ padding: '8px 16px', background: '#28a745', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 'bold' }}
|
||||||
|
>
|
||||||
|
📥 Экспорт в Excel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingOrders ? (
|
||||||
|
<p>Загрузка заказов...</p>
|
||||||
|
) : orders.length === 0 ? (
|
||||||
|
<p>Заказов нет.</p>
|
||||||
|
) : (
|
||||||
|
<table className="moders-table" style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>№</th>
|
||||||
|
<th>Дата</th>
|
||||||
|
<th>Сотрудник</th>
|
||||||
|
<th>Табельный</th>
|
||||||
|
<th>Состав</th>
|
||||||
|
<th>Сумма</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{orders.map(o => (
|
||||||
|
<tr key={o.id}>
|
||||||
|
<td>{o.id}</td>
|
||||||
|
<td>{new Date(o.createdAt).toLocaleString('ru-RU')}</td>
|
||||||
|
<td>{o.employee}</td>
|
||||||
|
<td>{o.tnom}</td>
|
||||||
|
<td>
|
||||||
|
<ul style={{ margin: 0, paddingLeft: '15px', fontSize: '12px' }}>
|
||||||
|
{o.items.map((it, i) => (
|
||||||
|
<li key={i}>{it.name} x{it.quantity}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
<td>{o.totalAmount.toFixed(2)}</td>
|
||||||
|
<td>
|
||||||
|
<span style={{
|
||||||
|
padding: '4px 8px', borderRadius: '4px', fontSize: '12px', fontWeight: 'bold',
|
||||||
|
background: o.status === 'new' ? '#ffc107' : o.status === 'done' ? '#28a745' : '#17a2b8',
|
||||||
|
color: 'white'
|
||||||
|
}}>
|
||||||
|
{o.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{o.status === 'cancelled' ? (
|
||||||
|
<span style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
background: '#fdecea',
|
||||||
|
color: '#c0392b',
|
||||||
|
border: '1px solid #f5c6cb'
|
||||||
|
}}>
|
||||||
|
Отменён
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
value={o.status}
|
||||||
|
onChange={(e) => updateOrderStatus(o.id, e.target.value)}
|
||||||
|
style={{ padding: '6px', borderRadius: '4px', border: '1px solid #ccc', fontSize: '13px' }}
|
||||||
|
>
|
||||||
|
<option value="new">Новый</option>
|
||||||
|
<option value="accepted">Принят</option>
|
||||||
|
<option value="done">Выполнен</option>
|
||||||
|
<option value="cancelled">Отменить (вернуть деньги)</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ================= МЕНЮ ================= */}
|
||||||
|
{activeSubTab === 'menu' && (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '15px' }}>
|
||||||
|
<h3 style={{ margin: 0 }}>Блюда в меню</h3>
|
||||||
|
<button
|
||||||
|
onClick={openAddModal}
|
||||||
|
style={{ padding: '8px 16px', background: '#007bff', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 'bold' }}
|
||||||
|
>
|
||||||
|
+ Добавить блюдо
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingMenu ? <p>Загрузка меню...</p> : menuData.categories.map(cat => (
|
||||||
|
<div key={cat.id} style={{ marginBottom: '30px' }}>
|
||||||
|
<h4 style={{ borderBottom: '1px solid #ccc', paddingBottom: '5px' }}>{cat.emoji} {cat.name}</h4>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: '15px' }}>
|
||||||
|
{cat.items.map(item => (
|
||||||
|
<div key={item.id} style={{ background: '#f9f9f9', borderRadius: '8px', padding: '10px', border: '1px solid #ddd', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<div style={{ height: '80px', background: '#eee', borderRadius: '4px', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '32px' }}>
|
||||||
|
{item.imageUrl ? <img src={`http://localhost:3001${item.imageUrl}`} alt={item.name} style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '4px' }} /> : '🍽️'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontWeight: 'bold', marginTop: '8px' }}>{item.name}</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#666', flex: 1 }}>{item.description}</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '10px' }}>
|
||||||
|
<strong style={{ color: '#007bff' }}>{item.price} BYN</strong>
|
||||||
|
<div>
|
||||||
|
<button onClick={() => openEditModal(item, cat.id)} className="btn-small" style={{ marginRight: '5px' }}>✏️</button>
|
||||||
|
<button onClick={() => handleDeleteItem(item.id)} className="btn-small" style={{ background: '#dc3545', color: 'white' }}>🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* МОДАЛКА РЕДАКТИРОВАНИЯ БЛЮДА */}
|
||||||
|
{showItemModal && (
|
||||||
|
<div className="modal" style={{ display: 'block', position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', zIndex: 2000, padding: '20px', overflowY: 'auto' }}>
|
||||||
|
<div className="modal-content" style={{ maxWidth: '500px', margin: '40px auto', background: '#fff', borderRadius: '12px', padding: '20px' }}>
|
||||||
|
<h3>{editingItem ? 'Редактировать блюдо' : 'Добавить блюдо'}</h3>
|
||||||
|
<form onSubmit={handleSaveItem}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Категория:</label>
|
||||||
|
<select
|
||||||
|
value={itemForm.category_id}
|
||||||
|
onChange={(e) => setItemForm({...itemForm, category_id: e.target.value})}
|
||||||
|
required
|
||||||
|
style={{ width: '100%', padding: '8px', borderRadius: '6px', border: '1px solid #ccc' }}
|
||||||
|
>
|
||||||
|
{menuData.categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Название:</label>
|
||||||
|
<input type="text" value={itemForm.name} onChange={(e) => setItemForm({...itemForm, name: e.target.value})} required style={{ width: '100%', padding: '8px' }} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Описание:</label>
|
||||||
|
<input type="text" value={itemForm.description} onChange={(e) => setItemForm({...itemForm, description: e.target.value})} style={{ width: '100%', padding: '8px' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '10px' }}>
|
||||||
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
|
<label>Цена (BYN):</label>
|
||||||
|
<input type="number" step="0.01" value={itemForm.price} onChange={(e) => setItemForm({...itemForm, price: e.target.value})} required style={{ width: '100%', padding: '8px' }} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group" style={{ flex: 1 }}>
|
||||||
|
<label>Вес (гр):</label>
|
||||||
|
<input type="number" value={itemForm.weight_grams} onChange={(e) => setItemForm({...itemForm, weight_grams: e.target.value})} style={{ width: '100%', padding: '8px' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Картинка (оставьте пустым, чтобы не менять):</label>
|
||||||
|
<input type="file" onChange={(e) => setItemForm({...itemForm, imageFile: e.target.files[0]})} accept="image/*" style={{ width: '100%', padding: '8px' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '20px', display: 'flex', gap: '10px' }}>
|
||||||
|
<button type="submit" style={{ padding: '10px 20px', background: '#28a745', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer' }}>Сохранить</button>
|
||||||
|
<button type="button" onClick={() => setShowItemModal(false)} style={{ padding: '10px 20px', background: '#6c757d', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer' }}>Отмена</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FoodManager;
|
||||||
@@ -2,23 +2,20 @@ import React, { useState, useEffect, useCallback } from 'react';
|
|||||||
import '../App.css';
|
import '../App.css';
|
||||||
|
|
||||||
// CSRF token management
|
// CSRF token management
|
||||||
let csrfToken = null;
|
//let csrfToken = null;
|
||||||
async function fetchCsrfToken() {
|
//async function fetchCsrfToken() {
|
||||||
try {
|
// try {
|
||||||
const res = await fetch('/moders/csrf-token', { credentials: 'include' });
|
// const res = await fetch('/moders/csrf-token', { credentials: 'include' });
|
||||||
if (res.ok) {
|
// if (res.ok) {
|
||||||
const data = await res.json();
|
// const data = await res.json();
|
||||||
csrfToken = data.csrfToken;
|
// csrfToken = data.csrfToken;
|
||||||
}
|
//}
|
||||||
} catch (e) { csrfToken = null; }
|
//} catch (e) { csrfToken = null; }
|
||||||
}
|
//}
|
||||||
|
|
||||||
async function apiFetch(url, options = {}) {
|
async function apiFetch(url, options = {}) {
|
||||||
if (!options.headers) options.headers = {};
|
if (!options.headers) options.headers = {};
|
||||||
if (['POST', 'PUT', 'DELETE'].includes((options.method || 'GET').toUpperCase())) {
|
console.log(`📤 [${options.method || 'GET'}] ${url}`, options.body || '');
|
||||||
if (!csrfToken) await fetchCsrfToken();
|
|
||||||
options.headers['X-CSRF-Token'] = csrfToken;
|
|
||||||
}
|
|
||||||
options.credentials = 'include';
|
options.credentials = 'include';
|
||||||
return fetch(url, options);
|
return fetch(url, options);
|
||||||
}
|
}
|
||||||
@@ -439,14 +436,17 @@ function Index() {
|
|||||||
}
|
}
|
||||||
}, [corsOrigins, showToast]);
|
}, [corsOrigins, showToast]);
|
||||||
|
|
||||||
const handleAddModer = useCallback(async (e) => {
|
const handleAddModer = useCallback(async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setAddModerStatus('');
|
console.log('вызыван писюн')
|
||||||
if (!moderUsername || !moderPassword) {
|
setAddModerStatus('');
|
||||||
setAddModerStatus('Заполните все поля');
|
if (!moderUsername || !moderPassword) {
|
||||||
return;
|
setAddModerStatus('Заполните все поля');
|
||||||
}
|
return;
|
||||||
try {
|
}
|
||||||
|
const payload = { username: moderUsername, password: moderPassword };
|
||||||
|
console.log('📤 Отправка модератора:', payload);
|
||||||
|
try {
|
||||||
const res = await apiFetch('/api/moders', {
|
const res = await apiFetch('/api/moders', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -470,7 +470,7 @@ function Index() {
|
|||||||
setAddModerStatus('Ошибка');
|
setAddModerStatus('Ошибка');
|
||||||
showToast('Ошибка', 'error');
|
showToast('Ошибка', 'error');
|
||||||
}
|
}
|
||||||
}, [moderUsername, moderPassword, showToast]);
|
}, [moderUsername, moderPassword, showToast]);
|
||||||
|
|
||||||
const handleDeleteModer = useCallback(async (id) => {
|
const handleDeleteModer = useCallback(async (id) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -12,6 +12,6 @@ LOGS_DIR=../backend/logs
|
|||||||
CARD_LOG_PATH=../backend/logs/cards.log
|
CARD_LOG_PATH=../backend/logs/cards.log
|
||||||
ADMIN_LOG=./logs/admin.log
|
ADMIN_LOG=./logs/admin.log
|
||||||
ADMIN_LOG_DIR=../logs
|
ADMIN_LOG_DIR=../logs
|
||||||
ALLOWED_ORIGINS=http://localhost:3000, http://localhost:3005, http://localhost:3001, http://127.0.0.1:3000
|
ALLOWED_ORIGINS=http://localhost:3000, http://localhost:3005, http://localhost:3001, http://127.0.0.1:3000, http://localhost:3002
|
||||||
BACKEND_APP_LOG=../backend/logs/app.log
|
BACKEND_APP_LOG=../backend/logs/app.log
|
||||||
CARD_LOG=../backend/logs/cards.log
|
CARD_LOG=../backend/logs/cards.log
|
||||||
@@ -10,3 +10,5 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
credentials: true
|
credentials: true
|
||||||
};
|
};
|
||||||
|
console.log('📋 ALLOWED_ORIGINS из .env:', process.env.ALLOWED_ORIGINS);
|
||||||
|
console.log('📋 Массив разрешённых origins:', allowedOrigins);
|
||||||
@@ -1588,3 +1588,936 @@
|
|||||||
[2026-08-09T17:57:08.445Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
[2026-08-09T17:57:08.445Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
[2026-08-09T17:57:08.446Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
[2026-08-09T17:57:08.446Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
[2026-08-09T17:57:08.447Z] [INFO] [LOGIN] Ответ отправлен
|
[2026-08-09T17:57:08.447Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-09T19:05:03.523Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-09T19:05:03.527Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-09T19:05:12.014Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-09T19:05:12.048Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-09T19:05:12.093Z] [ERROR] [DB CHECK] ❌ Ошибка связи с бэкендом: connect ECONNREFUSED 127.0.0.1:3001; connect ECONNREFUSED ::1:3001
|
||||||
|
[2026-08-09T19:10:18.966Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-09T19:10:18.970Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-09T19:11:58.219Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-09T19:11:58.223Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-09T19:12:24.042Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-09T19:12:24.045Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-09T19:13:05.537Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-09T19:13:05.540Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-09T19:14:14.837Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-09T19:14:14.842Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-09T19:19:29.874Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-09T19:19:29.880Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-09T19:20:26.539Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-09T19:20:26.543Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-09T19:21:13.450Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-09T19:21:13.456Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-09T19:24:43.343Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-09T19:24:43.347Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-09T19:24:47.081Z] [ERROR] Error: ENOENT: no such file or directory, stat 'D:\rasch\admin-frontend\build\index.html'
|
||||||
|
[2026-08-09T19:24:47.308Z] [ERROR] Error: ENOENT: no such file or directory, stat 'D:\rasch\admin-frontend\build\index.html'
|
||||||
|
[2026-08-09T19:25:15.162Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-09T19:25:15.167Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-09T19:25:51.739Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-09T19:25:51.743Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-09T19:26:23.916Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-09T19:26:23.932Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-09T19:26:36.976Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-08-09T19:26:36.980Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-09T19:26:36.991Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-09T19:26:36.993Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-09T19:26:37.333Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-09T19:26:37.335Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-08-09T19:26:37.338Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-09T19:29:13.173Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-09T19:29:13.178Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-09T19:30:13.482Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-09T19:30:13.486Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-09T19:32:50.665Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-09T19:32:50.668Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-09T19:34:06.656Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-09T19:34:06.660Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-09T19:34:39.788Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-09T19:34:39.791Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-09T19:36:51.312Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-09T19:36:51.318Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-10T07:16:29.299Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-10T07:16:29.302Z] [INFO] Card log file created at: D:\rasch_git\electron\backend\logs\cards.log
|
||||||
|
[2026-08-10T07:16:29.303Z] [INFO] Card log file created at: D:\rasch_git\electron\backend\logs\cards.log
|
||||||
|
[2026-08-10T07:16:29.303Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-10T07:16:37.113Z] [ERROR] Error: ENOENT: no such file or directory, stat 'D:\rasch_git\electron\admin-frontend\build\index.html'
|
||||||
|
[2026-08-10T07:17:35.578Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-10T07:17:35.581Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-10T07:17:43.099Z] [INFO] Login successful
|
||||||
|
[2026-08-10T07:17:43.218Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-10T07:17:43.250Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-10T07:17:44.412Z] [WARN] Log file not found: D:\rasch_git\electron\backend\logs\app.log
|
||||||
|
[2026-08-10T07:18:02.846Z] [INFO] [LOGIN] Попытка входа: grivova
|
||||||
|
[2026-08-10T07:18:02.846Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-10T07:18:02.852Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-10T07:18:02.853Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-10T07:18:02.943Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-10T07:18:02.944Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":3,"username":"grivova"}
|
||||||
|
[2026-08-10T07:18:02.945Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-10T07:22:25.057Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-10T07:22:25.060Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-10T17:21:24.568Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-10T17:21:24.572Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-10T17:38:05.413Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-10T17:38:05.416Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-10T17:38:24.712Z] [INFO] [LOGIN] Попытка входа: grivova
|
||||||
|
[2026-08-10T17:38:24.713Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-10T17:38:24.749Z] [INFO] [LOGIN] Результат поиска пользователя: не найден
|
||||||
|
[2026-08-10T17:38:24.750Z] [WARN] [LOGIN] Пользователь не найден: grivova
|
||||||
|
[2026-08-10T17:38:29.893Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-08-10T17:38:29.894Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-10T17:38:29.897Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-10T17:38:29.898Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-10T17:38:30.033Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-10T17:38:30.034Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-08-10T17:38:30.038Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-10T17:42:06.235Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-10T17:42:06.238Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-10T17:43:18.054Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-10T17:43:18.068Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-10T17:43:42.744Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-08-10T17:43:42.745Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-10T17:43:42.751Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-10T17:43:42.752Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-10T17:43:42.833Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-10T17:43:42.834Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-08-10T17:43:42.835Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-10T17:44:32.453Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-10T17:44:32.465Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-10T17:44:38.654Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-08-10T17:44:38.655Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-10T17:44:38.657Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-10T17:44:38.658Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-10T17:44:38.794Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-10T17:44:38.795Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-08-10T17:44:38.798Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-10T17:49:04.372Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-10T17:49:04.376Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-10T18:07:19.446Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-10T18:07:19.450Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-10T18:07:21.917Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-10T18:07:21.973Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-10T18:07:22.009Z] [ERROR] [DB CHECK] ❌ Ошибка связи с бэкендом: connect ECONNREFUSED 127.0.0.1:3001; connect ECONNREFUSED ::1:3001
|
||||||
|
[2026-08-10T18:09:04.717Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-10T18:09:04.720Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-10T18:11:04.542Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-10T18:11:04.545Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-10T19:48:50.904Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-10T19:48:50.908Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-10T19:49:05.177Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-10T19:49:05.244Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-10T19:49:05.309Z] [ERROR] [DB CHECK] ❌ Ошибка связи с бэкендом: connect ECONNREFUSED 127.0.0.1:3001; connect ECONNREFUSED ::1:3001
|
||||||
|
[2026-08-10T19:52:24.799Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-10T19:52:24.804Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-10T19:54:26.429Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-08-10T19:54:26.430Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-10T19:54:26.437Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-10T19:54:26.439Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-10T19:54:26.774Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-10T19:54:26.776Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-08-10T19:54:26.780Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-10T19:57:35.827Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-10T19:57:35.848Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-10T20:00:31.524Z] [ERROR] PayloadTooLargeError: request entity too large
|
||||||
|
at readStream (D:\rasch\admin-server1\node_modules\raw-body\index.js:163:17)
|
||||||
|
at getRawBody (D:\rasch\admin-server1\node_modules\raw-body\index.js:116:12)
|
||||||
|
at read (D:\rasch\admin-server1\node_modules\body-parser\lib\read.js:113:3)
|
||||||
|
at jsonParser (D:\rasch\admin-server1\node_modules\body-parser\lib\types\json.js:60:5)
|
||||||
|
at Layer.handleRequest (D:\rasch\admin-server1\node_modules\router\lib\layer.js:152:17)
|
||||||
|
at trimPrefix (D:\rasch\admin-server1\node_modules\router\index.js:342:13)
|
||||||
|
at D:\rasch\admin-server1\node_modules\router\index.js:297:9
|
||||||
|
at processParams (D:\rasch\admin-server1\node_modules\router\index.js:582:12)
|
||||||
|
at next (D:\rasch\admin-server1\node_modules\router\index.js:291:5)
|
||||||
|
at logger (D:\rasch\admin-server1\node_modules\morgan\index.js:170:5)
|
||||||
|
[2026-08-10T20:02:19.620Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-10T20:02:19.623Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-10T20:05:33.895Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-10T20:05:33.899Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-10T20:08:11.744Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-10T20:08:11.746Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-10T20:09:55.295Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-10T20:09:55.298Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-10T20:11:38.925Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-10T20:11:38.928Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-10T20:13:11.668Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-10T20:13:11.671Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-10T20:13:47.030Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-10T20:13:47.033Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-10T20:17:59.208Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-10T20:17:59.211Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-10T20:18:33.110Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-10T20:18:33.113Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-10T20:24:20.748Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-10T20:24:20.752Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-14T13:24:44.818Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-14T13:24:44.821Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-14T13:27:10.675Z] [INFO] [LOGIN] Попытка входа: grivova
|
||||||
|
[2026-08-14T13:27:10.675Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-14T13:27:10.687Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-14T13:27:10.688Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-14T13:27:10.760Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-14T13:27:10.761Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":3,"username":"grivova"}
|
||||||
|
[2026-08-14T13:27:10.763Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-14T13:29:02.161Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-14T13:29:02.164Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-14T13:29:12.245Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-08-14T13:29:12.245Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-14T13:29:12.247Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-14T13:29:12.248Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-14T13:29:12.326Z] [INFO] [LOGIN] Результат сравнения пароля: false
|
||||||
|
[2026-08-14T13:29:12.327Z] [WARN] [LOGIN] Неверный пароль для пользователя: vova
|
||||||
|
[2026-08-14T13:29:17.059Z] [INFO] [LOGIN] Попытка входа: grivova
|
||||||
|
[2026-08-14T13:29:17.060Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-14T13:29:17.062Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-14T13:29:17.063Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-14T13:29:17.132Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-14T13:29:17.133Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":3,"username":"grivova"}
|
||||||
|
[2026-08-14T13:29:17.134Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-14T13:31:55.307Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-14T13:31:55.311Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-14T13:32:01.120Z] [INFO] [LOGIN] Попытка входа: grivova
|
||||||
|
[2026-08-14T13:32:01.121Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-14T13:32:01.130Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-14T13:32:01.131Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-14T13:32:01.211Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-14T13:32:01.212Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":3,"username":"grivova"}
|
||||||
|
[2026-08-14T13:32:01.213Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-14T13:32:24.329Z] [INFO] [LOGIN] Попытка входа: grivova
|
||||||
|
[2026-08-14T13:32:24.329Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-14T13:32:24.332Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-14T13:32:24.332Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-14T13:32:24.403Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-14T13:32:24.403Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":3,"username":"grivova"}
|
||||||
|
[2026-08-14T13:32:24.404Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-14T13:38:25.063Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-14T13:38:25.067Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-16T01:27:22.968Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-16T01:27:22.971Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-16T01:28:31.640Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-16T01:28:31.649Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-16T01:28:40.817Z] [WARN] Неверный пароль
|
||||||
|
[2026-08-16T02:02:39.663Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-16T02:02:39.665Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-17T03:07:53.436Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-17T03:07:53.439Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-17T03:45:34.461Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-17T03:45:34.464Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-17T03:45:42.164Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-08-17T03:45:42.164Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-17T03:45:42.182Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-17T03:45:42.183Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-17T03:45:42.264Z] [INFO] [LOGIN] Результат сравнения пароля: false
|
||||||
|
[2026-08-17T03:45:42.265Z] [WARN] [LOGIN] Неверный пароль для пользователя: vova
|
||||||
|
[2026-08-17T03:45:46.561Z] [INFO] [LOGIN] Попытка входа: grivova
|
||||||
|
[2026-08-17T03:45:46.562Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-17T03:45:46.564Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-17T03:45:46.565Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-17T03:45:46.642Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-17T03:45:46.643Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":3,"username":"grivova"}
|
||||||
|
[2026-08-17T03:45:46.647Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-17T13:53:20.830Z] [INFO] [LOGIN] Попытка входа: grivova
|
||||||
|
[2026-08-17T13:53:20.845Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-17T13:53:20.849Z] [INFO] [LOGIN] Результат поиска пользователя: не найден
|
||||||
|
[2026-08-17T13:53:20.849Z] [WARN] [LOGIN] Пользователь не найден: grivova
|
||||||
|
[2026-08-18T10:10:33.384Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-18T10:10:33.394Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-18T11:31:56.130Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-18T11:31:56.132Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-18T11:32:58.176Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-08-18T11:32:58.177Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-18T11:32:58.179Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-18T11:32:58.180Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-18T11:32:58.253Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-18T11:32:58.253Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-08-18T11:32:58.269Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-18T11:33:58.253Z] [INFO] [LOGIN] Попытка входа: grivova
|
||||||
|
[2026-08-18T11:33:58.253Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-18T11:33:58.254Z] [INFO] [LOGIN] Результат поиска пользователя: не найден
|
||||||
|
[2026-08-18T11:33:58.255Z] [WARN] [LOGIN] Пользователь не найден: grivova
|
||||||
|
[2026-08-18T11:34:03.740Z] [INFO] [LOGIN] Попытка входа: grivova
|
||||||
|
[2026-08-18T11:34:03.741Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-18T11:34:03.742Z] [INFO] [LOGIN] Результат поиска пользователя: не найден
|
||||||
|
[2026-08-18T11:34:03.742Z] [WARN] [LOGIN] Пользователь не найден: grivova
|
||||||
|
[2026-08-18T11:34:14.837Z] [INFO] [LOGIN] Попытка входа: artur
|
||||||
|
[2026-08-18T11:34:14.838Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-18T11:34:14.839Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-18T11:34:14.840Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-18T11:34:14.908Z] [INFO] [LOGIN] Результат сравнения пароля: false
|
||||||
|
[2026-08-18T11:34:14.909Z] [WARN] [LOGIN] Неверный пароль для пользователя: artur
|
||||||
|
[2026-08-18T11:34:39.426Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T11:34:39.439Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-18T11:34:48.373Z] [INFO] Login successful
|
||||||
|
[2026-08-18T11:34:48.471Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T11:34:48.486Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-18T11:36:58.074Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T11:36:58.080Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-18T11:37:15.322Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-18T11:37:15.325Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-18T11:37:21.691Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T11:37:21.711Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-18T11:38:22.270Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T11:38:22.283Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-18T11:41:44.158Z] [INFO] Login successful
|
||||||
|
[2026-08-18T11:41:44.255Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T11:41:44.269Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-18T11:41:45.556Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T11:41:45.559Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-18T11:42:47.990Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-08-18T11:42:47.991Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-18T11:42:47.993Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-18T11:42:47.993Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-18T11:42:48.070Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-18T11:42:48.071Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-08-18T11:42:48.072Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-18T11:43:01.321Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T11:43:01.333Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-18T11:46:34.646Z] [WARN] Неверный пароль
|
||||||
|
[2026-08-18T11:47:19.230Z] [WARN] Неверный пароль
|
||||||
|
[2026-08-18T11:47:38.298Z] [WARN] Неверный пароль
|
||||||
|
[2026-08-18T11:48:06.052Z] [WARN] Неверный пароль
|
||||||
|
[2026-08-18T11:50:50.811Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-08-18T11:50:50.811Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-18T11:50:50.813Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-18T11:50:50.813Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-18T11:50:50.888Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-18T11:50:50.888Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-08-18T11:50:50.889Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-18T11:52:10.516Z] [INFO] Login successful
|
||||||
|
[2026-08-18T11:53:56.044Z] [INFO] [LOGIN] Попытка входа: asutp
|
||||||
|
[2026-08-18T11:53:56.045Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-18T11:53:56.046Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-18T11:53:56.046Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-18T11:53:56.117Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-18T11:53:56.118Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":5,"username":"asutp"}
|
||||||
|
[2026-08-18T11:53:56.118Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-18T11:58:36.536Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-18T11:58:36.539Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-18T11:58:47.285Z] [INFO] [LOGIN] Попытка входа: asutp
|
||||||
|
[2026-08-18T11:58:47.286Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-18T11:58:47.295Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-18T11:58:47.296Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-18T11:58:47.374Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-18T11:58:47.375Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":5,"username":"asutp"}
|
||||||
|
[2026-08-18T11:58:47.378Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-18T11:58:56.612Z] [INFO] Login successful
|
||||||
|
[2026-08-18T11:58:56.738Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T11:58:56.751Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-18T12:06:43.281Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-18T12:06:43.284Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-18T12:06:49.760Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T12:06:49.822Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-18T12:06:58.544Z] [INFO] Login successful
|
||||||
|
[2026-08-18T12:06:58.718Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T12:06:58.737Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-18T12:45:55.568Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-18T12:45:55.613Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-18T12:45:58.509Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T12:45:58.673Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-18T12:46:12.775Z] [INFO] Login successful
|
||||||
|
[2026-08-18T12:46:12.898Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T12:46:12.920Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-18T12:47:08.426Z] [INFO] Login successful
|
||||||
|
[2026-08-18T12:47:08.570Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T12:47:08.603Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-18T12:48:09.716Z] [INFO] Login successful
|
||||||
|
[2026-08-18T12:48:10.047Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T12:48:10.125Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-18T12:52:19.437Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-18T12:52:19.440Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-18T12:53:12.900Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-18T12:53:12.903Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-18T12:53:14.978Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T12:53:15.003Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-18T12:54:17.164Z] [INFO] Login successful
|
||||||
|
[2026-08-18T12:54:17.285Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T12:54:17.296Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-18T12:55:38.139Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-18T12:55:38.142Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-18T12:55:39.689Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T12:55:39.782Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-18T12:55:49.698Z] [INFO] Login successful
|
||||||
|
[2026-08-18T12:55:49.812Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T12:55:49.824Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-18T12:56:55.180Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-18T12:56:55.184Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-18T12:57:06.925Z] [INFO] Login successful
|
||||||
|
[2026-08-18T12:57:07.029Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T12:57:07.058Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-18T12:57:07.086Z] [ERROR] [DB CHECK] ❌ Ошибка связи с бэкендом: connect ECONNREFUSED ::1:3001; connect ECONNREFUSED 127.0.0.1:3001
|
||||||
|
[2026-08-18T12:58:00.062Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T12:58:00.088Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-18T12:58:00.100Z] [ERROR] [DB CHECK] ❌ Ошибка связи с бэкендом: connect ECONNREFUSED ::1:3001; connect ECONNREFUSED 127.0.0.1:3001
|
||||||
|
[2026-08-18T12:58:31.165Z] [INFO] Login successful
|
||||||
|
[2026-08-18T12:58:31.346Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T12:58:31.371Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-18T12:58:31.394Z] [ERROR] [DB CHECK] ❌ Ошибка связи с бэкендом: connect ECONNREFUSED ::1:3001; connect ECONNREFUSED 127.0.0.1:3001
|
||||||
|
[2026-08-18T12:58:53.705Z] [INFO] Login successful
|
||||||
|
[2026-08-18T12:58:53.980Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T12:58:54.005Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-18T12:58:54.019Z] [ERROR] [DB CHECK] ❌ Ошибка связи с бэкендом: connect ECONNREFUSED ::1:3001; connect ECONNREFUSED 127.0.0.1:3001
|
||||||
|
[2026-08-18T12:59:13.343Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T12:59:13.366Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-18T12:59:13.382Z] [ERROR] [DB CHECK] ❌ Ошибка связи с бэкендом: connect ECONNREFUSED ::1:3001; connect ECONNREFUSED 127.0.0.1:3001
|
||||||
|
[2026-08-18T12:59:28.878Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T12:59:28.895Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-18T12:59:28.909Z] [ERROR] [DB CHECK] ❌ Ошибка связи с бэкендом: connect ECONNREFUSED ::1:3001; connect ECONNREFUSED 127.0.0.1:3001
|
||||||
|
[2026-08-18T12:59:35.992Z] [WARN] Неверный пароль
|
||||||
|
[2026-08-18T12:59:39.665Z] [INFO] Login successful
|
||||||
|
[2026-08-18T12:59:39.976Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T12:59:40.018Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-18T12:59:40.112Z] [ERROR] [DB CHECK] ❌ Ошибка связи с бэкендом: connect ECONNREFUSED ::1:3001; connect ECONNREFUSED 127.0.0.1:3001
|
||||||
|
[2026-08-18T12:59:42.804Z] [INFO] New WebSocket connection
|
||||||
|
[2026-08-18T13:05:28.473Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-18T13:05:28.477Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-18T13:05:37.314Z] [INFO] Login successful
|
||||||
|
[2026-08-18T13:05:37.565Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T13:05:37.669Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-18T13:05:37.695Z] [ERROR] [DB CHECK] ❌ Ошибка связи с бэкендом: connect ECONNREFUSED ::1:3001; connect ECONNREFUSED 127.0.0.1:3001
|
||||||
|
[2026-08-18T13:06:03.526Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-18T13:06:03.529Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-18T13:06:05.949Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T13:06:05.996Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-18T13:06:06.021Z] [ERROR] [DB CHECK] ❌ Ошибка связи с бэкендом: connect ECONNREFUSED ::1:3001; connect ECONNREFUSED 127.0.0.1:3001
|
||||||
|
[2026-08-18T13:06:14.658Z] [INFO] Login successful
|
||||||
|
[2026-08-18T13:06:14.878Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T13:06:14.888Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-18T13:06:14.901Z] [ERROR] [DB CHECK] ❌ Ошибка связи с бэкендом: connect ECONNREFUSED ::1:3001; connect ECONNREFUSED 127.0.0.1:3001
|
||||||
|
[2026-08-18T13:06:37.489Z] [INFO] Login successful
|
||||||
|
[2026-08-18T13:06:37.586Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T13:06:37.608Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-18T13:15:28.381Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-18T13:15:28.385Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-18T13:15:38.123Z] [INFO] Login successful
|
||||||
|
[2026-08-18T13:15:38.295Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T13:15:38.323Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-18T13:16:43.343Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-18T13:16:43.347Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-18T13:16:52.786Z] [INFO] Login successful
|
||||||
|
[2026-08-18T13:16:53.080Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T13:16:53.108Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-18T13:18:14.958Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-18T13:18:14.962Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-18T13:18:25.271Z] [WARN] Неверный пароль
|
||||||
|
[2026-08-18T13:18:27.320Z] [INFO] Login successful
|
||||||
|
[2026-08-18T13:18:27.462Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T13:18:27.568Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-18T13:18:51.138Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T13:18:51.153Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-18T13:20:53.836Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-18T13:20:53.840Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-18T13:21:02.327Z] [INFO] Login successful
|
||||||
|
[2026-08-18T13:21:02.408Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T13:21:02.425Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-18T13:21:25.430Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-18T13:21:25.432Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-18T13:21:43.745Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T13:21:43.774Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-18T13:21:48.177Z] [INFO] Login successful
|
||||||
|
[2026-08-18T13:21:48.283Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-18T13:21:48.296Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-19T06:49:14.829Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-19T06:49:14.837Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-19T08:16:23.702Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-19T08:16:23.706Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-19T09:32:26.775Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-19T09:32:26.778Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-19T09:54:13.538Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-19T09:54:13.541Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-19T10:03:57.687Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-19T10:03:57.691Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-19T13:16:59.418Z] [INFO] Login successful
|
||||||
|
[2026-08-19T13:16:59.575Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-19T13:16:59.591Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-19T13:17:34.211Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-08-19T13:17:34.212Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-19T13:17:34.217Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-19T13:17:34.217Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-19T13:17:34.291Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-19T13:17:34.292Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-08-19T13:17:34.293Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-22T10:02:00.127Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-22T10:02:00.130Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-28T13:16:54.329Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-28T13:16:54.345Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-28T13:17:44.305Z] [WARN] Неверный пароль
|
||||||
|
[2026-08-28T13:17:47.842Z] [WARN] Неверный пароль
|
||||||
|
[2026-08-28T13:17:49.122Z] [WARN] Неверный пароль
|
||||||
|
[2026-08-28T13:18:24.580Z] [INFO] Login successful
|
||||||
|
[2026-08-28T13:18:24.735Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-08-28T13:18:24.756Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-08-28T13:18:42.365Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-08-28T13:18:42.366Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-28T13:18:42.380Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-28T13:18:42.381Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-28T13:18:42.462Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-28T13:18:42.463Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-08-28T13:18:42.464Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-28T13:21:07.055Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-28T13:21:07.058Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-28T13:21:24.349Z] [ERROR] MulterError: Unexpected field
|
||||||
|
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||||
|
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||||
|
at Multipart.emit (node:events:509:28)
|
||||||
|
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||||
|
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||||
|
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||||
|
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||||
|
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||||
|
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||||
|
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||||
|
[2026-08-28T13:21:28.269Z] [ERROR] MulterError: Unexpected field
|
||||||
|
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||||
|
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||||
|
at Multipart.emit (node:events:509:28)
|
||||||
|
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||||
|
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||||
|
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||||
|
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||||
|
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||||
|
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||||
|
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||||
|
[2026-08-28T13:22:13.764Z] [ERROR] MulterError: Unexpected field
|
||||||
|
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||||
|
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||||
|
at Multipart.emit (node:events:509:28)
|
||||||
|
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||||
|
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||||
|
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||||
|
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||||
|
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||||
|
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||||
|
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||||
|
[2026-08-28T13:25:47.640Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-28T13:25:47.643Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-28T13:25:57.085Z] [ERROR] MulterError: Unexpected field
|
||||||
|
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||||
|
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||||
|
at Multipart.emit (node:events:509:28)
|
||||||
|
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||||
|
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||||
|
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||||
|
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||||
|
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||||
|
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||||
|
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||||
|
[2026-08-28T13:28:40.679Z] [ERROR] MulterError: Unexpected field
|
||||||
|
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||||
|
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||||
|
at Multipart.emit (node:events:509:28)
|
||||||
|
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||||
|
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||||
|
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||||
|
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||||
|
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||||
|
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||||
|
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||||
|
[2026-08-28T13:35:09.914Z] [ERROR] MulterError: Unexpected field
|
||||||
|
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||||
|
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||||
|
at Multipart.emit (node:events:509:28)
|
||||||
|
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||||
|
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||||
|
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||||
|
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||||
|
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||||
|
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||||
|
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||||
|
[2026-08-28T13:36:00.169Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-28T13:36:00.172Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-28T13:37:58.077Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-28T13:37:58.080Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-28T13:38:24.214Z] [ERROR] MulterError: Unexpected field
|
||||||
|
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||||
|
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||||
|
at Multipart.emit (node:events:509:28)
|
||||||
|
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||||
|
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||||
|
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||||
|
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||||
|
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||||
|
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||||
|
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||||
|
[2026-08-28T13:38:54.054Z] [ERROR] MulterError: Unexpected field
|
||||||
|
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||||
|
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||||
|
at Multipart.emit (node:events:509:28)
|
||||||
|
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||||
|
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||||
|
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||||
|
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||||
|
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||||
|
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||||
|
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||||
|
[2026-08-28T13:38:57.735Z] [ERROR] MulterError: Unexpected field
|
||||||
|
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||||
|
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||||
|
at Multipart.emit (node:events:509:28)
|
||||||
|
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||||
|
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||||
|
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||||
|
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||||
|
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||||
|
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||||
|
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||||
|
[2026-08-28T13:40:58.165Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-28T13:40:58.168Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-28T13:41:09.217Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-08-28T13:41:09.218Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-28T13:41:09.220Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-28T13:41:09.220Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-28T13:41:09.298Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-28T13:41:09.299Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-08-28T13:41:09.301Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-28T13:41:18.242Z] [ERROR] MulterError: Unexpected field
|
||||||
|
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||||
|
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||||
|
at Multipart.emit (node:events:509:28)
|
||||||
|
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||||
|
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||||
|
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||||
|
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||||
|
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||||
|
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||||
|
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||||
|
[2026-08-28T13:44:33.109Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-28T13:44:33.112Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-28T13:44:45.327Z] [ERROR] MulterError: Unexpected field
|
||||||
|
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||||
|
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||||
|
at Multipart.emit (node:events:509:28)
|
||||||
|
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||||
|
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||||
|
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||||
|
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||||
|
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||||
|
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||||
|
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||||
|
[2026-08-28T13:47:57.993Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-28T13:47:57.997Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-28T13:52:17.446Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-28T13:52:17.449Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-28T13:53:18.856Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-28T13:53:18.859Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-28T13:53:30.533Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-08-28T13:53:30.534Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-28T13:53:30.546Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-28T13:53:30.547Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-28T13:53:30.646Z] [INFO] [LOGIN] Результат сравнения пароля: false
|
||||||
|
[2026-08-28T13:53:30.646Z] [WARN] [LOGIN] Неверный пароль для пользователя: vova
|
||||||
|
[2026-08-28T13:53:36.962Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-08-28T13:53:36.963Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-28T13:53:36.965Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-28T13:53:36.965Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-28T13:53:37.043Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-28T13:53:37.043Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-08-28T13:53:37.045Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-28T13:54:12.715Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-08-28T13:54:12.715Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-28T13:54:12.717Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-28T13:54:12.718Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-28T13:54:12.803Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-28T13:54:12.804Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-08-28T13:54:12.805Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-28T13:54:29.593Z] [ERROR] MulterError: Unexpected field
|
||||||
|
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||||
|
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||||
|
at Multipart.emit (node:events:509:28)
|
||||||
|
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||||
|
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||||
|
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||||
|
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||||
|
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||||
|
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||||
|
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||||
|
[2026-08-28T13:55:32.725Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-28T13:55:32.728Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-28T13:55:44.526Z] [ERROR] MulterError: Unexpected field
|
||||||
|
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||||
|
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||||
|
at Multipart.emit (node:events:509:28)
|
||||||
|
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||||
|
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||||
|
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||||
|
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||||
|
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||||
|
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||||
|
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||||
|
[2026-08-28T13:57:30.453Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-28T13:57:30.456Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-28T13:57:37.890Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-08-28T13:57:37.891Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-28T13:57:37.893Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-28T13:57:37.893Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-28T13:57:37.973Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-28T13:57:37.973Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-08-28T13:57:37.975Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-28T13:59:04.163Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-28T13:59:04.166Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-28T13:59:18.113Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-08-28T13:59:18.113Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-28T13:59:18.128Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-28T13:59:18.129Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-28T13:59:18.259Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-28T13:59:18.260Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-08-28T13:59:18.262Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-28T14:02:13.224Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-28T14:02:13.226Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-28T14:02:25.923Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-08-28T14:02:25.924Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-28T14:02:25.940Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-28T14:02:25.941Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-28T14:02:26.078Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-28T14:02:26.087Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-08-28T14:02:26.089Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-28T14:03:37.800Z] [ERROR] MulterError: Unexpected field
|
||||||
|
at wrappedFileFilter (D:\rasch_git\electron\admin-server1\node_modules\multer\index.js:41:19)
|
||||||
|
at Multipart.<anonymous> (D:\rasch_git\electron\admin-server1\node_modules\multer\lib\make-middleware.js:193:7)
|
||||||
|
at Multipart.emit (node:events:509:28)
|
||||||
|
at HeaderParser.cb (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:358:14)
|
||||||
|
at HeaderParser.push (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:162:20)
|
||||||
|
at SBMH.ssCb [as _cb] (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:394:37)
|
||||||
|
at feed (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:219:14)
|
||||||
|
at SBMH.push (D:\rasch_git\electron\admin-server1\node_modules\streamsearch\lib\sbmh.js:104:16)
|
||||||
|
at Multipart._write (D:\rasch_git\electron\admin-server1\node_modules\busboy\lib\types\multipart.js:567:19)
|
||||||
|
at writeOrBuffer (node:internal/streams/writable:570:12)
|
||||||
|
[2026-08-28T14:05:00.439Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-28T14:05:00.442Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-28T14:05:47.706Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-08-28T14:05:47.707Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-28T14:05:47.709Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-28T14:05:47.710Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-28T14:05:47.796Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-28T14:05:47.797Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-08-28T14:05:47.798Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-28T14:06:31.202Z] [INFO] [LOGIN] Попытка входа: grivova
|
||||||
|
[2026-08-28T14:06:31.202Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-28T14:06:31.204Z] [INFO] [LOGIN] Результат поиска пользователя: не найден
|
||||||
|
[2026-08-28T14:06:31.204Z] [WARN] [LOGIN] Пользователь не найден: grivova
|
||||||
|
[2026-08-28T14:06:43.404Z] [INFO] [LOGIN] Попытка входа: asutp
|
||||||
|
[2026-08-28T14:06:43.406Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-28T14:06:43.407Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-28T14:06:43.408Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-28T14:06:43.489Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-28T14:06:43.490Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":5,"username":"asutp"}
|
||||||
|
[2026-08-28T14:06:43.491Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-28T14:07:57.016Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-28T14:07:57.018Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-28T14:08:15.541Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-08-28T14:08:15.543Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-28T14:08:15.546Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-28T14:08:15.547Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-28T14:08:15.629Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-28T14:08:15.630Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-08-28T14:08:15.631Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-28T14:09:51.626Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-28T14:09:51.629Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-31T05:58:23.110Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-31T05:58:23.117Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-31T11:41:26.146Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-31T11:41:26.163Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-31T11:41:58.530Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-08-31T11:41:58.531Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-08-31T11:41:58.554Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-08-31T11:41:58.562Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-08-31T11:41:58.657Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-08-31T11:41:58.661Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-08-31T11:41:58.668Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-08-31T12:06:00.544Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-31T12:06:00.547Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-31T12:18:40.420Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-31T12:18:40.423Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-31T12:21:29.695Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-31T12:21:29.697Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-31T12:23:29.133Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-31T12:23:29.137Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-31T12:27:21.641Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-31T12:27:21.644Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-08-31T12:30:33.180Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-08-31T12:30:33.183Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-09-21T06:46:42.261Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-09-21T06:46:42.445Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-09-24T08:35:23.724Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-09-24T08:35:23.739Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-10-18T09:42:48.767Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-10-18T09:42:48.774Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-10-18T17:07:04.649Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-10-18T17:07:04.663Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-10-18T17:07:45.889Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-10-18T17:07:45.916Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-10-18T17:08:29.318Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-10-18T17:08:29.320Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-10-18T17:08:29.350Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-10-18T17:08:29.352Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-10-18T17:08:29.458Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-10-18T17:08:29.461Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-10-18T17:08:29.464Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-10-18T17:10:17.359Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-10-18T17:10:17.409Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-10-18T17:10:32.276Z] [WARN] Неверный пароль
|
||||||
|
[2026-10-18T17:10:37.859Z] [WARN] Неверный пароль
|
||||||
|
[2026-10-18T17:10:39.203Z] [WARN] Неверный пароль
|
||||||
|
[2026-10-18T17:15:23.261Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-10-18T17:15:23.275Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-10-21T03:42:25.554Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-10-21T03:42:25.562Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-10-21T03:45:23.041Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-10-21T03:45:23.041Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-10-21T03:45:23.052Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-10-21T03:45:23.053Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-10-21T03:45:23.128Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-10-21T03:45:23.129Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-10-21T03:45:23.141Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-10-21T04:41:10.860Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-10-21T04:41:10.885Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-10-21T18:14:23.958Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-10-21T18:14:23.964Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-10-26T10:30:06.261Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-10-26T10:30:06.272Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-10-28T18:25:51.489Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-10-28T18:25:51.499Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-10-28T19:29:04.657Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-10-28T19:29:04.725Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-10-28T20:19:46.381Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-10-28T20:19:46.394Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-10-28T20:32:55.955Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-10-28T20:32:55.960Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-10-28T20:46:01.429Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-10-28T20:46:01.433Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-10-28T20:59:03.413Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-10-28T20:59:03.494Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-10-28T21:00:18.133Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-10-28T21:00:18.134Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-10-28T21:00:18.154Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-10-28T21:00:18.155Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-10-28T21:00:18.264Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-10-28T21:00:18.265Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-10-28T21:00:18.266Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-10-28T21:13:11.494Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-10-28T21:13:11.515Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-10-28T21:14:42.274Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-10-28T21:14:42.274Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-10-28T21:14:42.276Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-10-28T21:14:42.277Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-10-28T21:14:42.390Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-10-28T21:14:42.391Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-10-28T21:14:42.392Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-10-28T21:20:21.465Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-10-28T21:20:21.469Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-10-28T21:27:08.156Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-10-28T21:27:08.158Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-10-28T21:27:21.606Z] [ERROR] [admin/food/orders GET] Request failed with status code 500
|
||||||
|
[2026-10-28T21:27:24.092Z] [ERROR] [admin/food/orders GET] Request failed with status code 500
|
||||||
|
[2026-10-28T21:27:25.235Z] [ERROR] [admin/food/orders GET] Request failed with status code 500
|
||||||
|
[2026-10-28T21:27:26.257Z] [ERROR] [admin/food/orders GET] Request failed with status code 500
|
||||||
|
[2026-10-28T21:27:27.402Z] [ERROR] [admin/food/orders GET] Request failed with status code 500
|
||||||
|
[2026-10-28T21:29:56.599Z] [ERROR] [admin/food/orders GET] Request failed with status code 500
|
||||||
|
[2026-10-28T21:29:58.442Z] [ERROR] [admin/food/orders GET] Request failed with status code 500
|
||||||
|
[2026-10-28T21:29:59.302Z] [ERROR] [admin/food/orders GET] Request failed with status code 500
|
||||||
|
[2026-10-28T21:30:01.382Z] [ERROR] [admin/food/orders GET] Request failed with status code 500
|
||||||
|
[2026-10-28T21:30:02.524Z] [ERROR] [admin/food/orders GET] Request failed with status code 500
|
||||||
|
[2026-10-28T21:30:03.627Z] [ERROR] [admin/food/orders GET] Request failed with status code 500
|
||||||
|
[2026-10-28T21:30:16.016Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-10-28T21:30:16.016Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-10-28T21:30:16.018Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-10-28T21:30:16.018Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-10-28T21:30:16.130Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-10-28T21:30:16.131Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-10-28T21:30:16.132Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-10-28T21:30:19.009Z] [ERROR] [admin/food/orders GET] Request failed with status code 500
|
||||||
|
[2026-10-28T21:30:20.720Z] [ERROR] [admin/food/orders GET] Request failed with status code 500
|
||||||
|
[2026-10-28T21:30:21.657Z] [ERROR] [admin/food/orders GET] Request failed with status code 500
|
||||||
|
[2026-10-28T21:30:22.658Z] [ERROR] [admin/food/orders GET] Request failed with status code 500
|
||||||
|
[2026-10-28T21:30:23.631Z] [ERROR] [admin/food/orders GET] Request failed with status code 500
|
||||||
|
[2026-10-28T21:30:24.526Z] [ERROR] [admin/food/orders GET] Request failed with status code 500
|
||||||
|
[2026-10-28T21:30:24.948Z] [ERROR] [admin/food/orders GET] Request failed with status code 500
|
||||||
|
[2026-10-28T21:35:22.344Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-10-28T21:35:22.348Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-10-28T21:39:47.954Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-10-28T21:39:47.959Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-10-28T21:40:26.037Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-10-28T21:40:26.039Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-10-28T21:40:26.049Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-10-28T21:40:26.050Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-10-28T21:40:26.155Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-10-28T21:40:26.155Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-10-28T21:40:26.157Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-10-28T21:44:16.803Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-10-28T21:44:16.808Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-10-28T21:44:59.309Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-10-28T21:44:59.310Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-10-28T21:44:59.312Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-10-28T21:44:59.313Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-10-28T21:44:59.426Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-10-28T21:44:59.427Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-10-28T21:44:59.428Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-10-28T21:48:59.108Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-10-28T21:48:59.112Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-10-28T21:49:23.402Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-10-28T21:49:23.403Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-10-28T21:49:23.406Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-10-28T21:49:23.407Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-10-28T21:49:23.520Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-10-28T21:49:23.522Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-10-28T21:49:23.523Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-10-28T21:59:33.857Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-10-28T21:59:33.860Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-10-28T22:04:29.620Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-10-28T22:04:29.626Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-10-28T22:12:28.385Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-10-28T22:12:28.411Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-10-28T22:30:18.979Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-10-28T22:30:18.982Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-10-28T22:39:36.568Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-10-28T22:39:36.572Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-10-28T22:43:12.105Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-10-28T22:43:12.109Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-10-28T22:50:49.508Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-10-28T22:50:49.511Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-10-28T23:02:07.770Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-10-28T23:02:07.773Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-10-28T23:02:26.714Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-10-28T23:02:26.715Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-10-28T23:02:26.728Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-10-28T23:02:26.729Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-10-28T23:02:26.853Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-10-28T23:02:26.854Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-10-28T23:02:26.856Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-10-28T23:03:20.549Z] [ERROR] [admin/food/orders PATCH] Request failed with status code 400
|
||||||
|
[2026-10-28T23:03:24.249Z] [ERROR] [admin/food/orders PATCH] Request failed with status code 400
|
||||||
|
[2026-10-28T23:03:25.985Z] [ERROR] [admin/food/orders PATCH] Request failed with status code 400
|
||||||
|
[2026-10-28T23:03:28.558Z] [ERROR] [admin/food/orders PATCH] Request failed with status code 400
|
||||||
|
[2026-10-28T23:03:31.683Z] [ERROR] [admin/food/orders PATCH] Request failed with status code 400
|
||||||
|
[2026-10-28T23:03:34.003Z] [ERROR] [admin/food/orders PATCH] Request failed with status code 400
|
||||||
|
[2026-10-28T23:03:35.191Z] [ERROR] [admin/food/orders PATCH] Request failed with status code 400
|
||||||
|
[2026-10-28T23:03:36.865Z] [ERROR] [admin/food/orders PATCH] Request failed with status code 400
|
||||||
|
[2026-10-28T23:04:09.307Z] [ERROR] [admin/food/orders PATCH] Request failed with status code 400
|
||||||
|
[2026-10-28T23:04:12.689Z] [ERROR] [admin/food/orders PATCH] Request failed with status code 400
|
||||||
|
[2026-10-28T23:04:14.448Z] [ERROR] [admin/food/orders PATCH] Request failed with status code 400
|
||||||
|
[2026-10-28T23:05:12.507Z] [ERROR] [admin/food/orders PATCH] Request failed with status code 400
|
||||||
|
[2026-10-28T23:07:37.357Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-10-28T23:07:37.367Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-10-29T01:23:30.890Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-10-29T01:23:30.921Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-10-29T03:14:32.608Z] [INFO] Server running on http://localhost:3005
|
||||||
|
[2026-10-29T03:14:32.634Z] [INFO] Card logs initialized successfully
|
||||||
|
[2026-10-29T03:15:11.513Z] [INFO] [LOGIN] Попытка входа: vova
|
||||||
|
[2026-10-29T03:15:11.514Z] [INFO] [LOGIN] Проверка подключения к базе...
|
||||||
|
[2026-10-29T03:15:11.537Z] [INFO] [LOGIN] Результат поиска пользователя: найден
|
||||||
|
[2026-10-29T03:15:11.539Z] [INFO] [LOGIN] Сравнение пароля через bcrypt...
|
||||||
|
[2026-10-29T03:15:11.627Z] [INFO] [LOGIN] Результат сравнения пароля: true
|
||||||
|
[2026-10-29T03:15:11.628Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":4,"username":"vova"}
|
||||||
|
[2026-10-29T03:15:11.630Z] [INFO] [LOGIN] Ответ отправлен
|
||||||
|
[2026-10-29T03:15:48.284Z] [WARN] Неверный пароль
|
||||||
|
[2026-10-29T03:15:52.963Z] [WARN] Неверный пароль
|
||||||
|
[2026-10-29T03:15:54.203Z] [WARN] Неверный пароль
|
||||||
|
[2026-10-29T03:16:03.099Z] [WARN] Неверный пароль
|
||||||
|
[2026-10-29T03:16:04.050Z] [WARN] Неверный пароль
|
||||||
|
[2026-10-29T03:16:05.890Z] [WARN] Неверный пароль
|
||||||
|
[2026-10-29T04:24:56.000Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-10-29T04:24:56.088Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
[2026-10-29T04:25:34.381Z] [WARN] Неверный пароль
|
||||||
|
[2026-10-29T04:25:38.869Z] [INFO] Login successful
|
||||||
|
[2026-10-29T04:25:39.079Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content
|
||||||
|
[2026-10-29T04:25:39.118Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL
|
||||||
|
|||||||
@@ -9,92 +9,254 @@ 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'].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'].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'].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'].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) => {
|
||||||
|
const { section } = req.params;
|
||||||
|
if (!['events', 'help', 'housing'].includes(section)) {
|
||||||
|
return res.status(400).json({ message: 'Некорректный раздел' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const [rows] = await db.query(
|
||||||
|
'SELECT * FROM content_blocks WHERE tab = ? ORDER BY created_at ASC',
|
||||||
|
[section]
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ message: 'Ошибка сервера' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Сохранить контент раздела
|
||||||
|
router.post('/section/:section', requireModerator, async (req, res) => {
|
||||||
|
const { section } = req.params;
|
||||||
|
const { content } = req.body;
|
||||||
|
|
||||||
|
if (!['events', 'help', 'housing'].includes(section)) {
|
||||||
|
return res.status(400).json({ message: 'Некорректный раздел' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.query('DELETE FROM content_blocks WHERE tab = ?', [section]);
|
||||||
|
|
||||||
|
const [result] = await db.query(
|
||||||
|
'INSERT INTO content_blocks (tab, type, content, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW())',
|
||||||
|
[section, 'text', content, req.session.userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
id: result.insertId,
|
||||||
|
message: 'Раздел сохранён'
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ message: 'Ошибка сервера' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const axios = require('axios');
|
||||||
|
const logger = require('../../logger');
|
||||||
|
const multer = require('multer');
|
||||||
|
const FormData = require('form-data');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001';
|
||||||
|
const upload = multer({ dest: 'uploads/tmp/' });
|
||||||
|
|
||||||
|
// ================= ЗАКАЗЫ =================
|
||||||
|
router.get('/orders', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { status, date } = req.query;
|
||||||
|
const { data } = await axios.get(`${BACKEND_URL}/api/food/orders`, {
|
||||||
|
params: { status, date }, timeout: 5000,
|
||||||
|
});
|
||||||
|
res.json(data);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('[admin/food/orders GET]', e.message);
|
||||||
|
res.status(500).json({ error: 'Не удалось получить заказы' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch('/orders/:id/status', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.patch(`${BACKEND_URL}/api/food/orders/${req.params.id}/status`, req.body, { timeout: 5000 });
|
||||||
|
res.json(data);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('[admin/food/orders PATCH]', e.message);
|
||||||
|
res.status(500).json({ error: 'Ошибка обновления статуса' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ================= МЕНЮ =================
|
||||||
|
router.get('/items', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get(`${BACKEND_URL}/api/food/menu`);
|
||||||
|
res.json(data);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: 'Не удалось получить меню' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/items', upload.single('image'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
// Передаем текстовые поля
|
||||||
|
Object.keys(req.body).forEach(key => {
|
||||||
|
formData.append(key, req.body[key]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Передаем файл, если он есть
|
||||||
|
if (req.file) {
|
||||||
|
formData.append('image', fs.createReadStream(req.file.path));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await axios.post(`${BACKEND_URL}/api/food/items`, formData, {
|
||||||
|
headers: formData.getHeaders(),
|
||||||
|
maxContentLength: Infinity,
|
||||||
|
maxBodyLength: Infinity
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.file) fs.unlinkSync(req.file.path);
|
||||||
|
res.json(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Ошибка проксирования POST:', e.response?.data || e.message);
|
||||||
|
res.status(500).json({ error: e.response?.data?.error || 'Ошибка добавления блюда' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/items/:id', upload.single('image'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
// Передаем текстовые поля
|
||||||
|
Object.keys(req.body).forEach(key => {
|
||||||
|
formData.append(key, req.body[key]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Передаем файл, если он есть
|
||||||
|
if (req.file) {
|
||||||
|
formData.append('image', fs.createReadStream(req.file.path));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await axios.put(`${BACKEND_URL}/api/food/items/${req.params.id}`, formData, {
|
||||||
|
headers: formData.getHeaders(),
|
||||||
|
maxContentLength: Infinity,
|
||||||
|
maxBodyLength: Infinity
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.file) fs.unlinkSync(req.file.path);
|
||||||
|
res.json(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Ошибка проксирования PUT:', e.response?.data || e.message);
|
||||||
|
res.status(500).json({ error: e.response?.data?.error || 'Ошибка обновления блюда' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/items/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.delete(`${BACKEND_URL}/api/food/items/${req.params.id}`);
|
||||||
|
res.json(data);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: 'Ошибка удаления блюда' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -7,7 +7,6 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "admin-server",
|
"name": "admin-server",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.10.0",
|
"axios": "^1.10.0",
|
||||||
@@ -19,6 +18,7 @@
|
|||||||
"express-mysql-session": "^3.0.3",
|
"express-mysql-session": "^3.0.3",
|
||||||
"express-rate-limit": "^8.0.1",
|
"express-rate-limit": "^8.0.1",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
|
"form-data": "^4.0.6",
|
||||||
"helmet": "^8.3.0",
|
"helmet": "^8.3.0",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"multer": "^2.0.1",
|
"multer": "^2.0.1",
|
||||||
@@ -57,6 +57,18 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/agent-base": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/append-field": {
|
"node_modules/append-field": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||||
@@ -142,9 +154,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/body-parser/node_modules/content-type": {
|
"node_modules/body-parser/node_modules/content-type": {
|
||||||
"version": "2.0.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
|
||||||
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -535,15 +547,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/express-mysql-session/node_modules/lru-cache": {
|
|
||||||
"version": "8.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-8.0.5.tgz",
|
|
||||||
"integrity": "sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16.14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/express-mysql-session/node_modules/ms": {
|
"node_modules/express-mysql-session/node_modules/ms": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||||
@@ -885,18 +888,6 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/https-proxy-agent/node_modules/agent-base": {
|
|
||||||
"version": "6.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
|
||||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"debug": "4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.7.3",
|
"version": "0.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||||
@@ -920,9 +911,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/ip-address": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.4.0",
|
"version": "10.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz",
|
||||||
"integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
|
"integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12"
|
"node": ">= 12"
|
||||||
@@ -974,6 +965,15 @@
|
|||||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/lru-cache": {
|
||||||
|
"version": "8.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-8.0.5.tgz",
|
||||||
|
"integrity": "sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lru.min": {
|
"node_modules/lru.min": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
|
||||||
@@ -1152,15 +1152,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mysql2": {
|
"node_modules/mysql2": {
|
||||||
"version": "3.23.2",
|
"version": "3.23.3",
|
||||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.23.3.tgz",
|
||||||
"integrity": "sha512-fxh3HpQ8vJtu/Mmnd4Xsur19jGjHGzRLMxptiDtOkbX7EVBgnafGSGDx1WGGVmJLClVh2LeeBMMo24IFv8wCyQ==",
|
"integrity": "sha512-ehp9HEKr4wVJaBOUVxNFa+CNrsCCCZ6363/jbGhb7WpEmSRNIXjHBjFs5K2s2cXn7j/RhDieejsQJ6nfUwD6vQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"aws-ssl-profiles": "^1.1.2",
|
"aws-ssl-profiles": "^1.1.2",
|
||||||
"denque": "^2.1.0",
|
|
||||||
"generate-function": "^2.3.1",
|
"generate-function": "^2.3.1",
|
||||||
"iconv-lite": "^0.7.2",
|
"iconv-lite": "^0.7.3",
|
||||||
"long": "^5.3.2",
|
"long": "^5.3.2",
|
||||||
"lru.min": "^1.1.4",
|
"lru.min": "^1.1.4",
|
||||||
"named-placeholders": "^1.1.6",
|
"named-placeholders": "^1.1.6",
|
||||||
@@ -1636,9 +1635,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/type-is/node_modules/content-type": {
|
"node_modules/type-is/node_modules/content-type": {
|
||||||
"version": "2.0.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
|
||||||
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"electron": "electron .",
|
"electron": "electron .",
|
||||||
"electron-dev": "set NODE_ENV=development&& electron .",
|
"electron-dev": "set NODE_ENV=development&& electron .",
|
||||||
"postinstall": "electron-builder install-app-deps",
|
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
@@ -23,6 +22,7 @@
|
|||||||
"express-mysql-session": "^3.0.3",
|
"express-mysql-session": "^3.0.3",
|
||||||
"express-rate-limit": "^8.0.1",
|
"express-rate-limit": "^8.0.1",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
|
"form-data": "^4.0.6",
|
||||||
"helmet": "^8.3.0",
|
"helmet": "^8.3.0",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"multer": "^2.0.1",
|
"multer": "^2.0.1",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const healthRouter = require('./routes/health');
|
|||||||
const monitoringRouter = require('./routes/monitoring');
|
const monitoringRouter = require('./routes/monitoring');
|
||||||
const setupWebSocket = require('./services/websocket');
|
const setupWebSocket = require('./services/websocket');
|
||||||
const cardController = require('./controllers/cardController');
|
const cardController = require('./controllers/cardController');
|
||||||
|
const foodOrdersRouter = require('./moders/routes/foodOrders');
|
||||||
const app = express();
|
const app = express();
|
||||||
const server = require('http').createServer(app);
|
const server = require('http').createServer(app);
|
||||||
|
|
||||||
@@ -27,17 +28,29 @@ const corsConfig = require('./config/cors');
|
|||||||
const sessionConfig = require('./config/session');
|
const sessionConfig = require('./config/session');
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
app.use(helmet()); // Добавление helmet для защиты
|
app.use(helmet({
|
||||||
|
contentSecurityPolicy: false, // Разрешаем скрипты на странице питания
|
||||||
|
}));
|
||||||
app.use(cors(corsConfig));
|
app.use(cors(corsConfig));
|
||||||
app.use(morgan('dev'));
|
app.use(morgan('dev'));
|
||||||
app.use(express.json());
|
app.use(express.json({ limit: '100mb' }));
|
||||||
app.use(session(sessionConfig(session)));
|
app.use(session(sessionConfig(session)));
|
||||||
|
|
||||||
// Роуты
|
// Роуты
|
||||||
app.use('/api/auth', authRouter);
|
app.use('/api/auth', authRouter);
|
||||||
// Статические файлы
|
// Статические файлы
|
||||||
app.use(express.static(path.join(__dirname, '..','admin-frontend', 'build')));
|
app.use(express.static(path.join(__dirname, '..','admin-frontend', 'build')));
|
||||||
app.use('/uploads', express.static(__dirname + '/uploads'));
|
app.use('/uploads', express.static(__dirname + '/uploads'));
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ПИТАНИЕ (API + HTML страница)
|
||||||
|
// ============================================
|
||||||
|
app.use('/moders/food', foodOrdersRouter); // 👈 ВОТ ЭТА СТРОКА ИСПРАВЛЕНА
|
||||||
|
app.get('/moders/food', (req, res) => {
|
||||||
|
// Отдаем HTML файл
|
||||||
|
res.sendFile(path.join(__dirname, 'views', 'food-orders.html'));
|
||||||
|
});
|
||||||
|
|
||||||
// Роуты
|
// Роуты
|
||||||
app.use('/api/card', cardRouter);
|
app.use('/api/card', cardRouter);
|
||||||
app.use('/api/logs', logsRouter);
|
app.use('/api/logs', logsRouter);
|
||||||
@@ -47,6 +60,12 @@ app.use('/api/health', healthRouter);
|
|||||||
app.use('/api/monitoring', monitoringRouter);
|
app.use('/api/monitoring', monitoringRouter);
|
||||||
app.use('/moders', moderAuthRouter);
|
app.use('/moders', moderAuthRouter);
|
||||||
app.use('/moders/content', moderContentRouter);
|
app.use('/moders/content', moderContentRouter);
|
||||||
|
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
// Initialize card logs
|
// Initialize card logs
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -70,9 +89,10 @@ app.get(/.*/, (req, res, next) => {
|
|||||||
|
|
||||||
// Обработчик ошибок (должен быть последним!)
|
// Обработчик ошибок (должен быть последним!)
|
||||||
app.use(require('./middleware/errorHandler'));
|
app.use(require('./middleware/errorHandler'));
|
||||||
|
|
||||||
app.get('/api/status', async (req, res) => {
|
app.get('/api/status', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
res.json({ status: 'online' }); // или другая логика проверки
|
res.json({ status: 'online' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ status: 'error', message: err.message });
|
res.status(500).json({ status: 'error', message: err.message });
|
||||||
}
|
}
|
||||||
@@ -92,19 +112,17 @@ app.get('/api/config', async (req, res) => {
|
|||||||
res.status(500).json({ error: 'Ошибка загрузки конфигурации' });
|
res.status(500).json({ error: 'Ошибка загрузки конфигурации' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Добавьте рядом с существующим GET обработчиком
|
|
||||||
app.post('/api/config', async (req, res) => {
|
app.post('/api/config', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const backendConfigPath = path.join(__dirname, '../backend/config.json');
|
const backendConfigPath = path.join(__dirname, '../backend/config.json');
|
||||||
const content = await fs.readFile(backendConfigPath, 'utf8');
|
const content = await fs.readFile(backendConfigPath, 'utf8');
|
||||||
const currentConfig = JSON.parse(content);
|
const currentConfig = JSON.parse(content);
|
||||||
|
|
||||||
// Обновляем только разрешенные поля
|
|
||||||
if (req.body.payslipsPath) {
|
if (req.body.payslipsPath) {
|
||||||
currentConfig.payslipsPath = req.body.payslipsPath;
|
currentConfig.payslipsPath = req.body.payslipsPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Сохраняем обновленный конфиг
|
|
||||||
await fs.writeFile(backendConfigPath, JSON.stringify(currentConfig, null, 4));
|
await fs.writeFile(backendConfigPath, JSON.stringify(currentConfig, null, 4));
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -119,6 +137,7 @@ app.post('/api/config', async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
server.listen(process.env.PORT || 3005, () => {
|
server.listen(process.env.PORT || 3005, () => {
|
||||||
logger.info(`Server running on ${process.env.ADMIN_URL}`);
|
logger.info(`Server running on ${process.env.ADMIN_URL}`);
|
||||||
});
|
});
|
||||||
|
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 |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 113 KiB |
@@ -0,0 +1,460 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Заказы питания — панель модератора</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: #f4f7fb;
|
||||||
|
color: #2d3436;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #2b6cb0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.nav-link {
|
||||||
|
text-decoration: none;
|
||||||
|
color: #2b6cb0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-right: 15px;
|
||||||
|
}
|
||||||
|
.nav-link:hover { text-decoration: underline; }
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.filters button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #eef2f7;
|
||||||
|
color: #4a5568;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.filters button:hover { background: #dde5ef; }
|
||||||
|
.filters button.active {
|
||||||
|
background: #2b6cb0;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.filters button .badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 6px;
|
||||||
|
background: rgba(255,255,255,0.3);
|
||||||
|
padding: 0 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 24px auto;
|
||||||
|
padding: 0 16px 60px;
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: #4a5568;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.toolbar .dot {
|
||||||
|
width: 10px; height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #2ecc71;
|
||||||
|
animation: pulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
.orders {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.order {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.06);
|
||||||
|
border-left: 5px solid #bdc3c7;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.order:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.order.status-new { border-left-color: #e74c3c; }
|
||||||
|
.order.status-accepted { border-left-color: #f39c12; }
|
||||||
|
.order.status-done { border-left-color: #2ecc71; }
|
||||||
|
.order.status-cancelled { border-left-color: #95a5a6; opacity: 0.7; }
|
||||||
|
|
||||||
|
.order-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.order-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.order-id {
|
||||||
|
background: #eef2f7;
|
||||||
|
color: #2b6cb0;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.order-time {
|
||||||
|
color: #95a5a6;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.employee {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #4a5568;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.employee b { color: #2d3436; }
|
||||||
|
.items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-top: 1px dashed #eef2f7;
|
||||||
|
border-bottom: 1px dashed #eef2f7;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.item-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #2d3436;
|
||||||
|
}
|
||||||
|
.item-row .qty { color: #95a5a6; margin-left: 6px; font-weight: 500; }
|
||||||
|
.order-foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.total {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1abc9c;
|
||||||
|
}
|
||||||
|
.total small { font-size: 13px; color: #95a5a6; font-weight: 500; }
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.actions button {
|
||||||
|
padding: 8px 18px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #fff;
|
||||||
|
transition: transform 0.1s, opacity 0.2s;
|
||||||
|
}
|
||||||
|
.actions button:active { transform: scale(0.96); }
|
||||||
|
.actions button:hover { opacity: 0.85; }
|
||||||
|
.btn-accept { background: #f39c12; }
|
||||||
|
.btn-done { background: #2ecc71; }
|
||||||
|
.btn-cancel { background: #e74c3c; }
|
||||||
|
.actions button:disabled {
|
||||||
|
background: #bdc3c7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.empty, .loading {
|
||||||
|
text-align: center;
|
||||||
|
color: #95a5a6;
|
||||||
|
padding: 60px 20px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 16px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.04);
|
||||||
|
}
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 30px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: #2d3436;
|
||||||
|
color: #fff;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.25);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.toast.show { opacity: 1; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div style="display: flex; align-items: center;">
|
||||||
|
<a href="/moders" class="nav-link">← В панель модератора</a>
|
||||||
|
<h1>🍔 Заказы питания</h1>
|
||||||
|
</div>
|
||||||
|
<div class="filters" id="filters">
|
||||||
|
<button data-status="new" class="active">Новые <span class="badge" id="b-new">0</span></button>
|
||||||
|
<button data-status="accepted">Принятые <span class="badge" id="b-accepted">0</span></button>
|
||||||
|
<button data-status="done">Выполненные <span class="badge" id="b-done">0</span></button>
|
||||||
|
<button data-status="cancelled">Отменённые <span class="badge" id="b-cancelled">0</span></button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="dot"></div>
|
||||||
|
<span id="auto-status">Автообновление каждые 10 сек…</span>
|
||||||
|
</div>
|
||||||
|
<div id="container" class="orders">
|
||||||
|
<div class="loading">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ============================================================
|
||||||
|
// Конфиг
|
||||||
|
// ============================================================
|
||||||
|
const API = '/moders/food/orders';
|
||||||
|
const API_STATUS = '/moders/food/orders/';
|
||||||
|
const REFRESH_MS = 10000;
|
||||||
|
|
||||||
|
let currentStatus = 'new';
|
||||||
|
let allOrders = [];
|
||||||
|
|
||||||
|
const container = document.getElementById('container');
|
||||||
|
const toastEl = document.getElementById('toast');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Загрузка данных
|
||||||
|
// ============================================================
|
||||||
|
async function loadOrders() {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API}?status=${currentStatus}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
|
const data = await r.json();
|
||||||
|
allOrders = data;
|
||||||
|
render(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Ошибка загрузки:', e);
|
||||||
|
if (container.innerHTML.includes('loading')) {
|
||||||
|
container.innerHTML = `<div class="empty">Ошибка загрузки: ${e.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Бейджи (кол-во по каждому статусу)
|
||||||
|
// ============================================================
|
||||||
|
async function updateBadges() {
|
||||||
|
try {
|
||||||
|
const statuses = ['new', 'accepted', 'done', 'cancelled'];
|
||||||
|
const results = await Promise.all(
|
||||||
|
statuses.map((s) =>
|
||||||
|
fetch(`${API}?status=${s}`, { credentials: 'include' })
|
||||||
|
.then((r) => r.ok ? r.json() : [])
|
||||||
|
.then((arr) => arr.length)
|
||||||
|
.catch(() => 0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
statuses.forEach((s, i) => {
|
||||||
|
const el = document.getElementById('b-' + s);
|
||||||
|
if (el) el.textContent = results[i];
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Игнорируем ошибки бейджей
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Рендер карточек
|
||||||
|
// ============================================================
|
||||||
|
function render(orders) {
|
||||||
|
if (!orders || orders.length === 0) {
|
||||||
|
container.innerHTML = `<div class="empty">Заказов нет</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = orders.map(renderOrder).join('');
|
||||||
|
|
||||||
|
// Вешаем обработчики на кнопки
|
||||||
|
container.querySelectorAll('[data-action]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const id = btn.dataset.id;
|
||||||
|
const status = btn.dataset.action;
|
||||||
|
changeStatus(id, status, btn);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOrder(o) {
|
||||||
|
const itemsHtml = (o.items || [])
|
||||||
|
.map(
|
||||||
|
(it) => `
|
||||||
|
<div class="item-row">
|
||||||
|
<span>${escapeHtml(it.name)}<span class="qty">× ${it.quantity}</span></span>
|
||||||
|
<span>${Number(it.subtotal).toFixed(2)} BYN</span>
|
||||||
|
</div>`
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const actions = [];
|
||||||
|
|
||||||
|
if (o.status === 'new') {
|
||||||
|
actions.push(`<button class="btn-accept" data-action="accepted" data-id="${o.id}">Принять</button>`);
|
||||||
|
actions.push(`<button class="btn-cancel" data-action="cancelled" data-id="${o.id}">Отменить</button>`);
|
||||||
|
} else if (o.status === 'accepted') {
|
||||||
|
actions.push(`<button class="btn-done" data-action="done" data-id="${o.id}">Выполнен</button>`);
|
||||||
|
actions.push(`<button class="btn-cancel" data-action="cancelled" data-id="${o.id}">Отменить</button>`);
|
||||||
|
} else if (o.status === 'cancelled') {
|
||||||
|
actions.push(`<button class="btn-accept" data-action="accepted" data-id="${o.id}">Восстановить</button>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="order status-${o.status}">
|
||||||
|
<div class="order-head">
|
||||||
|
<h2><span class="order-id">Заказ #${o.id}</span></h2>
|
||||||
|
<span class="order-time">${formatDate(o.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="employee">
|
||||||
|
${escapeHtml(o.employee || 'Сотрудник')} · таб. № <b>${escapeHtml(o.tnom)}</b>
|
||||||
|
</div>
|
||||||
|
<div class="items">${itemsHtml}</div>
|
||||||
|
<div class="order-foot">
|
||||||
|
<div class="total">${Number(o.totalAmount).toFixed(2)} <small>BYN</small></div>
|
||||||
|
<div class="actions">${actions.join('')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Смена статуса
|
||||||
|
// ============================================================
|
||||||
|
async function changeStatus(id, status, btn) {
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '...';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API_STATUS}${id}/status`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
|
|
||||||
|
showToast(`Заказ #${id} → ${statusLabel(status)}`);
|
||||||
|
await loadOrders();
|
||||||
|
await updateBadges();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(`Ошибка: ${e.message}`);
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = false;
|
||||||
|
// Кнопка вернет свой текст при перерисовке
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(s) {
|
||||||
|
return { new: 'Новый', accepted: 'Принят', done: 'Выполнен', cancelled: 'Отменён' }[s] || s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Утилиты
|
||||||
|
// ============================================================
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return String(str || '').replace(/[&<>"']/g, (c) => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||||
|
}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso) {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleString('ru-RU', {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let toastTimer = null;
|
||||||
|
function showToast(text) {
|
||||||
|
toastEl.textContent = text;
|
||||||
|
toastEl.classList.add('show');
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastTimer = setTimeout(() => toastEl.classList.remove('show'), 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Фильтры
|
||||||
|
// ============================================================
|
||||||
|
document.getElementById('filters').addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('button[data-status]');
|
||||||
|
if (!btn) return;
|
||||||
|
document.querySelectorAll('#filters button').forEach((b) => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
currentStatus = btn.dataset.status;
|
||||||
|
container.innerHTML = `<div class="loading">Загрузка...</div>`;
|
||||||
|
loadOrders();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Старт и Автообновление
|
||||||
|
// ============================================================
|
||||||
|
loadOrders();
|
||||||
|
updateBadges();
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
loadOrders();
|
||||||
|
updateBadges();
|
||||||
|
}, REFRESH_MS);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -10,3 +10,10 @@ LOG_FILE_CARDS=cards.log
|
|||||||
LOG_DIR=../../logs/
|
LOG_DIR=../../logs/
|
||||||
LOG_FILE=app.log
|
LOG_FILE=app.log
|
||||||
DOCX_PATH=../../../frontend/public/handbook.docx
|
DOCX_PATH=../../../frontend/public/handbook.docx
|
||||||
|
|
||||||
|
# Питание (rasch_content — локальная БД с users/content/food)
|
||||||
|
FOOD_DB_HOST=localhost
|
||||||
|
FOOD_DB_PORT=3306
|
||||||
|
FOOD_DB_USER=root
|
||||||
|
FOOD_DB_PASSWORD=Vova-2003.
|
||||||
|
FOOD_DB_NAME=rasch_content
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
{
|
{
|
||||||
"payslipsPath": "\\\\PC-20010\\payslips",
|
"payslipsPath": "./payslips",
|
||||||
|
"networkCredentials": {
|
||||||
|
"username": "",
|
||||||
|
"password": "",
|
||||||
|
"domain": "WORKGROUP"
|
||||||
|
},
|
||||||
"server": {
|
"server": {
|
||||||
"host": "localhost",
|
"host": "localhost",
|
||||||
"port": "3001"
|
"port": "3001"
|
||||||
@@ -7,6 +12,34 @@
|
|||||||
"testMode": true,
|
"testMode": true,
|
||||||
"mockData": {
|
"mockData": {
|
||||||
"employees": [
|
"employees": [
|
||||||
|
{
|
||||||
|
"id": "1635578914",
|
||||||
|
"cardNumber": "1635578914",
|
||||||
|
"tnom": "22906",
|
||||||
|
"famaly": "Пецевич",
|
||||||
|
"ima": "Анатолий",
|
||||||
|
"otch": "Анатольевич",
|
||||||
|
"profession": "Инженер-электроник",
|
||||||
|
"category": "Высшая",
|
||||||
|
"departmentName": "УАМИТ",
|
||||||
|
"departmentCode": "550",
|
||||||
|
"organization": "Филиал «Завод Химволокно» ОАО «Гродно Азот»",
|
||||||
|
"identNumber": "123456789"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1649045490",
|
||||||
|
"cardNumber": "1649045490",
|
||||||
|
"tnom": "23959",
|
||||||
|
"famaly": "Григорян",
|
||||||
|
"ima": "Владимир",
|
||||||
|
"otch": "Гагикович",
|
||||||
|
"profession": "Инженер-программист сектора АСУТП",
|
||||||
|
"category": "Высшая",
|
||||||
|
"departmentName": "УАМИТ",
|
||||||
|
"departmentCode": "550",
|
||||||
|
"organization": "Филиал «Завод Химволокно» ОАО «Гродно Азот»",
|
||||||
|
"identNumber": "123456789"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"cardNumber": "1001",
|
"cardNumber": "1001",
|
||||||
@@ -14,11 +47,11 @@
|
|||||||
"famaly": "Петров",
|
"famaly": "Петров",
|
||||||
"ima": "Петр",
|
"ima": "Петр",
|
||||||
"otch": "Петрович",
|
"otch": "Петрович",
|
||||||
"profession": "Главный инженер",
|
"profession": "электромонтер",
|
||||||
"category": "Высшая",
|
"category": "Высшая",
|
||||||
"departmentName": "Технический отдел",
|
"departmentName": "Технический отдел",
|
||||||
"departmentCode": "101",
|
"departmentCode": "101",
|
||||||
"organization": "ООО ТехноЦентр",
|
"organization": "Филиал «Завод Химволокно» ОАО «Гродно Азот»",
|
||||||
"identNumber": "123456789"
|
"identNumber": "123456789"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -32,7 +65,7 @@
|
|||||||
"category": "1",
|
"category": "1",
|
||||||
"departmentName": "Бухгалтерия",
|
"departmentName": "Бухгалтерия",
|
||||||
"departmentCode": "202",
|
"departmentCode": "202",
|
||||||
"organization": "ООО ТехноЦентр",
|
"organization": "Филиал «Завод Химволокно» ОАО «Гродно Азот»",
|
||||||
"identNumber": "987654321"
|
"identNumber": "987654321"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -42,18 +75,20 @@
|
|||||||
"famaly": "Иванов",
|
"famaly": "Иванов",
|
||||||
"ima": "Иван",
|
"ima": "Иван",
|
||||||
"otch": "Иванович",
|
"otch": "Иванович",
|
||||||
"profession": "Разработчик",
|
"profession": "Инженер-программист",
|
||||||
"category": "2",
|
"category": "2",
|
||||||
"departmentName": "IT-отдел",
|
"departmentName": "IT-отдел",
|
||||||
"departmentCode": "303",
|
"departmentCode": "303",
|
||||||
"organization": "ООО ТехноЦентр",
|
"organization": "Филиал «Завод Химволокно» ОАО «Гродно Азот»",
|
||||||
"identNumber": "555555555"
|
"identNumber": "555555555"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"payslips": [
|
"payslips": [
|
||||||
"Петров_ПП_2026-08.pdf",
|
"001.pdf",
|
||||||
"Сидорова_АС_2026-08.pdf",
|
"001.pdf",
|
||||||
"Иванов_ИИ_2026-08.pdf"
|
"001.pdf",
|
||||||
|
"002.pdf",
|
||||||
|
"003.pdf"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- Модуль питания: категории, блюда, счета, заказы
|
||||||
|
-- ============================================================
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
-- ---------- Компания-оператор питания ----------
|
||||||
|
CREATE TABLE IF NOT EXISTS food_companies (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ---------- Категории (Горячее, Напитки, Закуски, Суп...) ----------
|
||||||
|
CREATE TABLE IF NOT EXISTS food_categories (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
emoji VARCHAR(16) DEFAULT NULL,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ---------- Блюда ----------
|
||||||
|
CREATE TABLE IF NOT EXISTS food_items (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
category_id INT NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description VARCHAR(500) DEFAULT NULL,
|
||||||
|
price DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||||
|
image_url VARCHAR(500) DEFAULT NULL,
|
||||||
|
weight_grams INT DEFAULT NULL,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
KEY idx_food_items_cat (category_id),
|
||||||
|
CONSTRAINT fk_item_cat FOREIGN KEY (category_id)
|
||||||
|
REFERENCES food_categories(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ---------- Счета питания сотрудников (по табельному) ----------
|
||||||
|
CREATE TABLE IF NOT EXISTS food_accounts (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
tnom VARCHAR(20) NOT NULL UNIQUE,
|
||||||
|
balance DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ---------- Заказы ----------
|
||||||
|
CREATE TABLE IF NOT EXISTS food_orders (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
tnom VARCHAR(20) NOT NULL,
|
||||||
|
total_amount DECIMAL(10,2) NOT NULL,
|
||||||
|
status ENUM('new','accepted','done','cancelled')
|
||||||
|
NOT NULL DEFAULT 'new',
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_food_orders_status (status),
|
||||||
|
KEY idx_food_orders_tnom (tnom),
|
||||||
|
KEY idx_food_orders_date (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ---------- Позиции заказа ----------
|
||||||
|
CREATE TABLE IF NOT EXISTS food_order_items (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
order_id INT NOT NULL,
|
||||||
|
item_id INT NOT NULL,
|
||||||
|
item_name VARCHAR(255) NOT NULL, -- фиксируем имя на момент заказа
|
||||||
|
unit_price DECIMAL(10,2) NOT NULL, -- и цену
|
||||||
|
quantity INT NOT NULL,
|
||||||
|
subtotal DECIMAL(10,2) NOT NULL,
|
||||||
|
KEY idx_order_items_order (order_id),
|
||||||
|
KEY idx_order_items_item (item_id),
|
||||||
|
CONSTRAINT fk_oi_order FOREIGN KEY (order_id)
|
||||||
|
REFERENCES food_orders(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO food_companies (id, name) VALUES (1, 'АзотСервис');
|
||||||
|
|
||||||
|
INSERT IGNORE INTO food_categories (id, name, emoji, sort_order) VALUES
|
||||||
|
(1, 'Горячее', '🍲', 10),
|
||||||
|
(2, 'Супы', '🥣', 20),
|
||||||
|
(3, 'Закуски', '🥪', 30),
|
||||||
|
(4, 'Напитки', '🥤', 40),
|
||||||
|
(5, 'Десерты', '🍰', 50);
|
||||||
|
|
||||||
|
INSERT IGNORE INTO food_items (id, category_id, name, description, price, image_url, weight_grams, sort_order) VALUES
|
||||||
|
(1, 1, 'Котлета', 'Куриная котлета с маслом', 6.50, '/uploads/food/kiev.jpg', 250, 10),
|
||||||
|
(2, 1, 'Плов с говядиной', 'Рассольный плов', 7.20, '/uploads/food/plov.jpg', 300, 20),
|
||||||
|
(3, 2, 'Борщ', 'С говядиной и сметаной', 4.50, '/uploads/food/borsch.jpg', 350, 10),
|
||||||
|
(4, 2, 'Куриный суп', 'С домашней лапшой', 3.80, '/uploads/food/soup.jpg', 350, 20),
|
||||||
|
(5, 3, 'Бутерброд с сыром', 'Тост, сыр, ветчина', 2.90, '/uploads/food/sandwich.jpg',150, 10),
|
||||||
|
(6, 4, 'Чай чёрный', 'С сахаром', 1.20, '/uploads/food/tea.jpg', 250, 10),
|
||||||
|
(7, 4, 'Кофе американо', 'Эспрессо с водой', 2.00, '/uploads/food/coffee.jpg', 200, 20),
|
||||||
|
(8, 4, 'Сок апельсиновый', 'Свежевыжатый', 2.50, '/uploads/food/juice.jpg', 250, 30),
|
||||||
|
(9, 5, 'Пирожок с вишней', 'Выпечка', 1.80, '/uploads/food/pie.jpg', 100, 10);
|
||||||
|
|
||||||
|
-- Тестовые счета (можно удалить на проде)
|
||||||
|
INSERT IGNORE INTO food_accounts (tnom, balance) VALUES
|
||||||
|
('001', 50.00),
|
||||||
|
('002', 32.50),
|
||||||
|
('003', 100.00);
|
||||||
@@ -12,13 +12,18 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.2.1",
|
"dotenv": "^17.2.1",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"express-rate-limit": "^8.6.2",
|
||||||
|
"helmet": "^8.3.0",
|
||||||
"mammoth": "^1.9.1",
|
"mammoth": "^1.9.1",
|
||||||
|
"multer": "^2.5.0",
|
||||||
"mysql2": "^3.14.2",
|
"mysql2": "^3.14.2",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"winston": "^3.17.0"
|
"odata-client": "^0.8.0",
|
||||||
|
"winston": "^3.17.0",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^2.0.22"
|
"nodemon": "^3.1.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@colors/colors": {
|
"node_modules/@colors/colors": {
|
||||||
@@ -41,6 +46,18 @@
|
|||||||
"kuler": "^2.0.0"
|
"kuler": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@sindresorhus/is": {
|
||||||
|
"version": "5.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-5.6.0.tgz",
|
||||||
|
"integrity": "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sindresorhus/is?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@so-ric/colorspace": {
|
"node_modules/@so-ric/colorspace": {
|
||||||
"version": "1.1.6",
|
"version": "1.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz",
|
||||||
@@ -51,6 +68,24 @@
|
|||||||
"text-hex": "1.0.x"
|
"text-hex": "1.0.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@szmarczak/http-timer": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"defer-to-connect": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/http-cache-semantics": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "26.2.0",
|
"version": "26.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
|
||||||
@@ -89,6 +124,21 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/addressparser": {
|
||||||
|
"version": "0.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/addressparser/-/addressparser-0.3.2.tgz",
|
||||||
|
"integrity": "sha512-fDlslCJpojuY1cnb7tY7COAriA7cdSzDiWyrWNdFn7Cjd+jrEgZavqkOgD/wg+eH765YPnQjqlS88OL/Q0Qtkg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/adler-32": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/anymatch": {
|
"node_modules/anymatch": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||||
@@ -103,6 +153,12 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/append-field": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/argparse": {
|
"node_modules/argparse": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||||
@@ -128,11 +184,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/base64-js": {
|
"node_modules/base64-js": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
@@ -211,14 +270,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.18",
|
"version": "5.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0",
|
"balanced-match": "^4.0.2"
|
||||||
"concat-map": "0.0.1"
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/braces": {
|
"node_modules/braces": {
|
||||||
@@ -234,6 +295,17 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/busboy": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||||
|
"dependencies": {
|
||||||
|
"streamsearch": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.16.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bytes": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -243,6 +315,33 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cacheable-lookup": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cacheable-request": {
|
||||||
|
"version": "10.2.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-10.2.14.tgz",
|
||||||
|
"integrity": "sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/http-cache-semantics": "^4.0.2",
|
||||||
|
"get-stream": "^6.0.1",
|
||||||
|
"http-cache-semantics": "^4.1.1",
|
||||||
|
"keyv": "^4.5.3",
|
||||||
|
"mimic-response": "^4.0.0",
|
||||||
|
"normalize-url": "^8.0.0",
|
||||||
|
"responselike": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/call-bind-apply-helpers": {
|
"node_modules/call-bind-apply-helpers": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
@@ -272,6 +371,19 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cfb": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"crc-32": "~1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||||
@@ -297,6 +409,15 @@
|
|||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/codepage": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color": {
|
"node_modules/color": {
|
||||||
"version": "5.0.3",
|
"version": "5.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz",
|
||||||
@@ -343,13 +464,6 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/concat-map": {
|
|
||||||
"version": "0.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
|
||||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/content-disposition": {
|
"node_modules/content-disposition": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
||||||
@@ -413,6 +527,18 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/crc-32": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"crc32": "bin/crc32.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -430,6 +556,42 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decompress-response": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mimic-response": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/decompress-response/node_modules/mimic-response": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/defer-to-connect": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/denque": {
|
"node_modules/denque": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||||
@@ -510,6 +672,27 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/encoding": {
|
||||||
|
"version": "0.1.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
||||||
|
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"iconv-lite": "^0.6.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/encoding/node_modules/iconv-lite": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/es-define-property": {
|
"node_modules/es-define-property": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
@@ -598,6 +781,25 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/express-rate-limit": {
|
||||||
|
"version": "8.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz",
|
||||||
|
"integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.4.3",
|
||||||
|
"ip-address": "^10.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/express-rate-limit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"express": ">= 4.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fecha": {
|
"node_modules/fecha": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz",
|
||||||
@@ -644,6 +846,15 @@
|
|||||||
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==",
|
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/form-data-encoder": {
|
||||||
|
"version": "2.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz",
|
||||||
|
"integrity": "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/forwarded": {
|
"node_modules/forwarded": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
@@ -653,6 +864,15 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/frac": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fresh": {
|
"node_modules/fresh": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||||
@@ -732,6 +952,18 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-stream": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/glob-parent": {
|
"node_modules/glob-parent": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||||
@@ -757,6 +989,31 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/got": {
|
||||||
|
"version": "12.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/got/-/got-12.6.1.tgz",
|
||||||
|
"integrity": "sha512-mThBblvlAF1d4O5oqyvN+ZxLAYwIJK7bpMxgYqPD9okW0C3qm5FFn7k811QrcuEBwaogR3ngOFoCfs6mRv7teQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@sindresorhus/is": "^5.2.0",
|
||||||
|
"@szmarczak/http-timer": "^5.0.1",
|
||||||
|
"cacheable-lookup": "^7.0.0",
|
||||||
|
"cacheable-request": "^10.2.8",
|
||||||
|
"decompress-response": "^6.0.0",
|
||||||
|
"form-data-encoder": "^2.1.2",
|
||||||
|
"get-stream": "^6.0.1",
|
||||||
|
"http2-wrapper": "^2.1.10",
|
||||||
|
"lowercase-keys": "^3.0.0",
|
||||||
|
"p-cancelable": "^3.0.0",
|
||||||
|
"responselike": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sindresorhus/got?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/has-flag": {
|
"node_modules/has-flag": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
|
||||||
@@ -791,6 +1048,24 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/helmet": {
|
||||||
|
"version": "8.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
|
||||||
|
"integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/EvanHahn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/http-cache-semantics": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/http-errors": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
@@ -811,6 +1086,19 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/http2-wrapper": {
|
||||||
|
"version": "2.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz",
|
||||||
|
"integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"quick-lru": "^5.1.1",
|
||||||
|
"resolve-alpn": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.7.3",
|
"version": "0.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||||
@@ -846,6 +1134,15 @@
|
|||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ip-address": {
|
||||||
|
"version": "10.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz",
|
||||||
|
"integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -931,6 +1228,12 @@
|
|||||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/json-buffer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/jszip": {
|
"node_modules/jszip": {
|
||||||
"version": "3.10.1",
|
"version": "3.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||||
@@ -943,6 +1246,15 @@
|
|||||||
"setimmediate": "^1.0.5"
|
"setimmediate": "^1.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/keyv": {
|
||||||
|
"version": "4.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
|
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"json-buffer": "3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/kuler": {
|
"node_modules/kuler": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz",
|
||||||
@@ -958,6 +1270,12 @@
|
|||||||
"immediate": "~3.0.5"
|
"immediate": "~3.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash": {
|
||||||
|
"version": "4.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||||
|
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/logform": {
|
"node_modules/logform": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz",
|
||||||
@@ -992,6 +1310,18 @@
|
|||||||
"underscore": "^1.13.1"
|
"underscore": "^1.13.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lowercase-keys": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lru.min": {
|
"node_modules/lru.min": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
|
||||||
@@ -1090,17 +1420,42 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/mimelib": {
|
||||||
"version": "3.1.5",
|
"version": "0.2.19",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/mimelib/-/mimelib-0.2.19.tgz",
|
||||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
"integrity": "sha512-f0FEP0iMNT2u4YbAIBK8KZHaOvWGAdrVq+OyLUpIjpCfjO+nVCTtNM9wb+O8tJPm12w6kb4I6okERU94QdA43A==",
|
||||||
"dev": true,
|
"deprecated": "This project is unmaintained",
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^1.1.7"
|
"addressparser": "~0.3.2",
|
||||||
|
"encoding": "~0.1.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mimic-response": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minimatch": {
|
||||||
|
"version": "10.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||||
|
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^5.0.8"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "*"
|
"node": "18 || 20 || >=22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
@@ -1109,6 +1464,67 @@
|
|||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/multer": {
|
||||||
|
"version": "2.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/multer/-/multer-2.5.0.tgz",
|
||||||
|
"integrity": "sha512-BsqOzYGVq49JcW2IzoSAxeKYRevb+dGGPPWleOR8Lrlk4igwxu1/9pwSXlByhCb7MVZDrNJFC0IBP5/O5nLYRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"append-field": "^1.0.0",
|
||||||
|
"busboy": "^1.6.0",
|
||||||
|
"type-is": "^1.6.18"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.16.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/multer/node_modules/media-typer": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/multer/node_modules/mime-db": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/multer/node_modules/mime-types": {
|
||||||
|
"version": "2.1.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "1.52.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/multer/node_modules/type-is": {
|
||||||
|
"version": "1.6.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||||
|
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"media-typer": "0.3.0",
|
||||||
|
"mime-types": "~2.1.24"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mysql2": {
|
"node_modules/mysql2": {
|
||||||
"version": "3.23.2",
|
"version": "3.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.23.2.tgz",
|
||||||
@@ -1162,19 +1578,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nodemon": {
|
"node_modules/nodemon": {
|
||||||
"version": "2.0.22",
|
"version": "3.1.14",
|
||||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.22.tgz",
|
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
|
||||||
"integrity": "sha512-B8YqaKMmyuCO7BowF1Z1/mkPqLk6cs/l63Ojtd6otKjMx47Dq1utxfRxcavH1I7VSaL8n5BUaoutadnsX3AAVQ==",
|
"integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": "^3.5.2",
|
"chokidar": "^3.5.2",
|
||||||
"debug": "^3.2.7",
|
"debug": "^4",
|
||||||
"ignore-by-default": "^1.0.1",
|
"ignore-by-default": "^1.0.1",
|
||||||
"minimatch": "^3.1.2",
|
"minimatch": "^10.2.1",
|
||||||
"pstree.remy": "^1.1.8",
|
"pstree.remy": "^1.1.8",
|
||||||
"semver": "^5.7.1",
|
"semver": "^7.5.3",
|
||||||
"simple-update-notifier": "^1.0.7",
|
"simple-update-notifier": "^2.0.0",
|
||||||
"supports-color": "^5.5.0",
|
"supports-color": "^5.5.0",
|
||||||
"touch": "^3.1.0",
|
"touch": "^3.1.0",
|
||||||
"undefsafe": "^2.0.5"
|
"undefsafe": "^2.0.5"
|
||||||
@@ -1183,23 +1599,13 @@
|
|||||||
"nodemon": "bin/nodemon.js"
|
"nodemon": "bin/nodemon.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.10.0"
|
"node": ">=10"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/nodemon"
|
"url": "https://opencollective.com/nodemon"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nodemon/node_modules/debug": {
|
|
||||||
"version": "3.2.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
|
|
||||||
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ms": "^2.1.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/normalize-path": {
|
"node_modules/normalize-path": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||||
@@ -1210,6 +1616,18 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/normalize-url": {
|
||||||
|
"version": "8.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.1.tgz",
|
||||||
|
"integrity": "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -1231,6 +1649,24 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/odata-client": {
|
||||||
|
"version": "0.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/odata-client/-/odata-client-0.8.0.tgz",
|
||||||
|
"integrity": "sha512-H0JxBtwvmMcBWHq3rIQNAxusYDN79ajkeon8OoinVpaq0whQwddgpzy1gDohxVDtpqPZcmp3gVaB+n+zRH64jQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"bluebird": "^3.4.1",
|
||||||
|
"got": "^12.5.3",
|
||||||
|
"lodash": "^4.17.14",
|
||||||
|
"mimelib": "^0.2.19",
|
||||||
|
"uuid": "^8.3.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"request": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/on-finished": {
|
"node_modules/on-finished": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
@@ -1267,6 +1703,15 @@
|
|||||||
"integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==",
|
"integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==",
|
||||||
"license": "BSD-2-Clause"
|
"license": "BSD-2-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/p-cancelable": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pako": {
|
"node_modules/pako": {
|
||||||
"version": "1.0.11",
|
"version": "1.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
@@ -1356,6 +1801,18 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/quick-lru": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/range-parser": {
|
"node_modules/range-parser": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
|
||||||
@@ -1412,6 +1869,27 @@
|
|||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/resolve-alpn": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/responselike": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/responselike/-/responselike-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lowercase-keys": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/router": {
|
"node_modules/router": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||||
@@ -1450,13 +1928,16 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "5.7.2",
|
"version": "7.8.5",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver"
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/send": {
|
"node_modules/send": {
|
||||||
@@ -1589,26 +2070,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/simple-update-notifier": {
|
"node_modules/simple-update-notifier": {
|
||||||
"version": "1.1.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
|
||||||
"integrity": "sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==",
|
"integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"semver": "~7.0.0"
|
"semver": "^7.5.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.10.0"
|
"node": ">=10"
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/simple-update-notifier/node_modules/semver": {
|
|
||||||
"version": "7.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz",
|
|
||||||
"integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"bin": {
|
|
||||||
"semver": "bin/semver.js"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sprintf-js": {
|
"node_modules/sprintf-js": {
|
||||||
@@ -1632,6 +2103,18 @@
|
|||||||
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
|
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ssf": {
|
||||||
|
"version": "0.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||||
|
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"frac": "~1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stack-trace": {
|
"node_modules/stack-trace": {
|
||||||
"version": "0.0.10",
|
"version": "0.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
|
||||||
@@ -1650,6 +2133,14 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/streamsearch": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string_decoder": {
|
"node_modules/string_decoder": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
@@ -1785,6 +2276,16 @@
|
|||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/uuid": {
|
||||||
|
"version": "8.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||||
|
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||||
|
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vary": {
|
"node_modules/vary": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
@@ -1858,12 +2359,51 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wmf": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/word": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/wrappy": {
|
"node_modules/wrappy": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/xlsx": {
|
||||||
|
"version": "0.18.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||||
|
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"cfb": "~1.2.1",
|
||||||
|
"codepage": "~1.15.0",
|
||||||
|
"crc-32": "~1.2.1",
|
||||||
|
"ssf": "~0.11.2",
|
||||||
|
"wmf": "~1.0.1",
|
||||||
|
"word": "~0.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"xlsx": "bin/xlsx.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xmlbuilder": {
|
"node_modules/xmlbuilder": {
|
||||||
"version": "10.1.1",
|
"version": "10.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
|
||||||
|
|||||||
@@ -15,12 +15,17 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.2.1",
|
"dotenv": "^17.2.1",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"express-rate-limit": "^8.6.2",
|
||||||
|
"helmet": "^8.3.0",
|
||||||
"mammoth": "^1.9.1",
|
"mammoth": "^1.9.1",
|
||||||
|
"multer": "^2.5.0",
|
||||||
"mysql2": "^3.14.2",
|
"mysql2": "^3.14.2",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"winston": "^3.17.0"
|
"odata-client": "^0.8.0",
|
||||||
|
"winston": "^3.17.0",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^2.0.22"
|
"nodemon": "^3.1.14"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// ============================================
|
||||||
|
// 🍔 Отдельное подключение к БД питания
|
||||||
|
// (rasch_content — там таблицы food_*)
|
||||||
|
// ============================================
|
||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
const path = require('path');
|
||||||
|
const { logger } = require('./logger');
|
||||||
|
|
||||||
|
require('dotenv').config({ path: path.join(__dirname, '../../config.env') });
|
||||||
|
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
host: process.env.FOOD_DB_HOST || 'localhost',
|
||||||
|
port: Number(process.env.FOOD_DB_PORT) || 3306,
|
||||||
|
user: process.env.FOOD_DB_USER || 'root',
|
||||||
|
password: process.env.FOOD_DB_PASSWORD || '',
|
||||||
|
database: process.env.FOOD_DB_NAME || 'rasch_content',
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 5,
|
||||||
|
queueLimit: 0,
|
||||||
|
charset: 'utf8mb4',
|
||||||
|
});
|
||||||
|
|
||||||
|
async function query(sql, params = []) {
|
||||||
|
const [rows] = await pool.execute(sql, params);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryOne(sql, params = []) {
|
||||||
|
const rows = await query(sql, params);
|
||||||
|
return rows.length > 0 ? rows[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[db_food] pool → ${process.env.FOOD_DB_HOST || 'localhost'}:${process.env.FOOD_DB_PORT || 3306}/${process.env.FOOD_DB_NAME || 'rasch_content'}`
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = { pool, query, queryOne };
|
||||||
@@ -21,7 +21,7 @@ async function readJson(filePath) {
|
|||||||
/**
|
/**
|
||||||
* Получение mock-данных сотрудника из config.json
|
* Получение mock-данных сотрудника из config.json
|
||||||
*/
|
*/
|
||||||
async function getMockEmployee(searchParam, type = 'card') {
|
async function getMockEmployee(searchParam) {
|
||||||
try {
|
try {
|
||||||
const configPath = path.join(__dirname, '../../config.json');
|
const configPath = path.join(__dirname, '../../config.json');
|
||||||
const config = await readJson(configPath);
|
const config = await readJson(configPath);
|
||||||
@@ -31,14 +31,20 @@ async function getMockEmployee(searchParam, type = 'card') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mockEmployees = config.mockData.employees || [];
|
const mockEmployees = config.mockData.employees || [];
|
||||||
|
const paramStr = String(searchParam);
|
||||||
|
|
||||||
if (type === 'card') {
|
// Сначала пробуем найти по cardNumber (как строка, без приведения)
|
||||||
return mockEmployees.find(e => String(e.cardNumber) === String(searchParam)) || null;
|
let employee = mockEmployees.find(e => String(e.cardNumber) === paramStr);
|
||||||
} else if (type === 'id') {
|
|
||||||
return mockEmployees.find(e => e.id === parseInt(searchParam)) || null;
|
// Если не нашли — пробуем по id (как число)
|
||||||
|
if (!employee) {
|
||||||
|
const paramNum = parseInt(paramStr);
|
||||||
|
if (!isNaN(paramNum)) {
|
||||||
|
employee = mockEmployees.find(e => e.id === paramNum);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return employee || null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('[MOCK ERROR]', { error: err.message });
|
logger.error('[MOCK ERROR]', { error: err.message });
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -0,0 +1,502 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { query, queryOne } = require('../config/db_food');
|
||||||
|
const { logger } = require('../config/logger');
|
||||||
|
const multer = require('multer');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 🖼️ НАСТРОЙКА ЗАГРУЗКИ КАРТИНОК (MULTER)
|
||||||
|
// ============================================
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => {
|
||||||
|
const dir = path.join(__dirname, '../../uploads/food');
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
|
cb(null, dir);
|
||||||
|
},
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
cb(null, Date.now() + path.extname(file.originalname));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const upload = multer({ storage });
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 📋 ПОЛУЧЕНИЕ МЕНЮ (ДЛЯ КИОСКА И АДМИНКИ)
|
||||||
|
// ============================================
|
||||||
|
router.get('/menu', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const categories = await query(
|
||||||
|
`SELECT id, name, emoji, sort_order
|
||||||
|
FROM food_categories
|
||||||
|
WHERE is_active = 1
|
||||||
|
ORDER BY sort_order, name`
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = await query(
|
||||||
|
`SELECT id, category_id, name, description, price, image_url, weight_grams
|
||||||
|
FROM food_items
|
||||||
|
WHERE is_active = 1
|
||||||
|
ORDER BY category_id, sort_order, name`
|
||||||
|
);
|
||||||
|
|
||||||
|
const company = await queryOne(
|
||||||
|
`SELECT name FROM food_companies WHERE is_active = 1 LIMIT 1`
|
||||||
|
);
|
||||||
|
|
||||||
|
const byCat = {};
|
||||||
|
items.forEach((it) => {
|
||||||
|
(byCat[it.category_id] ||= []).push({
|
||||||
|
id: it.id,
|
||||||
|
name: it.name,
|
||||||
|
description: it.description,
|
||||||
|
price: Number(it.price),
|
||||||
|
imageUrl: it.image_url,
|
||||||
|
weightGrams: it.weight_grams,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
company: company?.name || 'АзотСервис',
|
||||||
|
categories: categories.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
emoji: c.emoji,
|
||||||
|
items: byCat[c.id] || [],
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('[food/menu]', { error: err.message });
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 💰 БАЛАНС ПОЛЬЗОВАТЕЛЯ
|
||||||
|
// ============================================
|
||||||
|
router.get('/balance/:tnom', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { tnom } = req.params;
|
||||||
|
const row = await queryOne(
|
||||||
|
`SELECT balance FROM food_accounts WHERE tnom = ?`,
|
||||||
|
[tnom]
|
||||||
|
);
|
||||||
|
res.json({ tnom, balance: row ? Number(row.balance) : 0 });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('[food/balance]', { error: err.message });
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 🛒 СОЗДАНИЕ ЗАКАЗА
|
||||||
|
// ============================================
|
||||||
|
router.post('/orders', async (req, res) => {
|
||||||
|
const { tnom, items } = req.body || {};
|
||||||
|
|
||||||
|
if (!tnom || !Array.isArray(items) || items.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'tnom и items обязательны' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = [...new Set(items.map((i) => Number(i.itemId)))];
|
||||||
|
if (ids.length === 0 || ids.some((n) => !Number.isFinite(n))) {
|
||||||
|
return res.status(400).json({ error: 'Некорректные itemId' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const placeholders = ids.map(() => '?').join(',');
|
||||||
|
const rows = await query(
|
||||||
|
`SELECT id, name, price FROM food_items
|
||||||
|
WHERE id IN (${placeholders}) AND is_active = 1`,
|
||||||
|
ids
|
||||||
|
);
|
||||||
|
|
||||||
|
const byId = Object.fromEntries(rows.map((r) => [r.id, r]));
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
const orderItems = [];
|
||||||
|
for (const it of items) {
|
||||||
|
const dbItem = byId[Number(it.itemId)];
|
||||||
|
const qty = Number(it.quantity);
|
||||||
|
if (!dbItem || !Number.isInteger(qty) || qty <= 0) continue;
|
||||||
|
|
||||||
|
const unitPrice = Number(dbItem.price);
|
||||||
|
const subtotal = +(unitPrice * qty).toFixed(2);
|
||||||
|
total += subtotal;
|
||||||
|
|
||||||
|
orderItems.push({
|
||||||
|
itemId: dbItem.id,
|
||||||
|
itemName: dbItem.name,
|
||||||
|
unitPrice,
|
||||||
|
quantity: qty,
|
||||||
|
subtotal,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orderItems.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Пустой заказ' });
|
||||||
|
}
|
||||||
|
|
||||||
|
total = +total.toFixed(2);
|
||||||
|
|
||||||
|
const acc = await queryOne(
|
||||||
|
`SELECT balance FROM food_accounts WHERE tnom = ?`,
|
||||||
|
[tnom]
|
||||||
|
);
|
||||||
|
const balance = acc ? Number(acc.balance) : 0;
|
||||||
|
if (acc && balance < total) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: 'Недостаточно средств на счёте питания',
|
||||||
|
balance,
|
||||||
|
total,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderId = await (async () => {
|
||||||
|
const { pool } = require('../config/db_food');
|
||||||
|
const conn = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction();
|
||||||
|
|
||||||
|
const [orderResult] = await conn.execute(
|
||||||
|
`INSERT INTO food_orders (tnom, total_amount) VALUES (?, ?)`,
|
||||||
|
[tnom, total]
|
||||||
|
);
|
||||||
|
const newId = orderResult.insertId;
|
||||||
|
|
||||||
|
for (const oi of orderItems) {
|
||||||
|
await conn.execute(
|
||||||
|
`INSERT INTO food_order_items
|
||||||
|
(order_id, item_id, item_name, unit_price, quantity, subtotal)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
[newId, oi.itemId, oi.itemName, oi.unitPrice, oi.quantity, oi.subtotal]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (acc) {
|
||||||
|
await conn.execute(
|
||||||
|
`UPDATE food_accounts SET balance = balance - ? WHERE tnom = ?`,
|
||||||
|
[total, tnom]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await conn.commit();
|
||||||
|
return newId;
|
||||||
|
} catch (e) {
|
||||||
|
await conn.rollback();
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
logger.info(`[food/orders] Создан заказ #${orderId} для ${tnom}, сумма ${total}`);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
orderId,
|
||||||
|
total,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('[food/orders]', { error: err.message });
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 📦 ПОЛУЧЕНИЕ СПИСКА ЗАКАЗОВ (ДЛЯ МОДЕРАТОРА)
|
||||||
|
// ============================================
|
||||||
|
router.get('/orders', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { status, tnom, date } = req.query;
|
||||||
|
const where = [];
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (status && status !== 'all') { where.push('o.status = ?'); params.push(status); }
|
||||||
|
if (tnom) { where.push('o.tnom = ?'); params.push(tnom); }
|
||||||
|
if (date) { where.push('DATE(o.created_at) = ?'); params.push(date); }
|
||||||
|
|
||||||
|
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
const orders = await query(
|
||||||
|
`SELECT o.id, o.tnom, o.total_amount, o.status, o.created_at
|
||||||
|
FROM food_orders o
|
||||||
|
${whereSql}
|
||||||
|
ORDER BY o.created_at DESC
|
||||||
|
LIMIT 200`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
if (orders.length > 0) {
|
||||||
|
const ids = orders.map((o) => o.id);
|
||||||
|
const ph = ids.map(() => '?').join(',');
|
||||||
|
const items = await query(
|
||||||
|
`SELECT order_id, item_id, item_name, unit_price, quantity, subtotal
|
||||||
|
FROM food_order_items
|
||||||
|
WHERE order_id IN (${ph})`,
|
||||||
|
ids
|
||||||
|
);
|
||||||
|
const grouped = {};
|
||||||
|
items.forEach((it) => (grouped[it.order_id] ||= []).push(it));
|
||||||
|
|
||||||
|
return res.json(
|
||||||
|
orders.map((o) => ({
|
||||||
|
id: o.id,
|
||||||
|
tnom: o.tnom,
|
||||||
|
employee: 'Сотрудник (таб. ' + o.tnom + ')',
|
||||||
|
totalAmount: Number(o.total_amount),
|
||||||
|
status: o.status,
|
||||||
|
createdAt: o.created_at,
|
||||||
|
items: (grouped[o.id] || []).map((it) => ({
|
||||||
|
itemId: it.item_id,
|
||||||
|
name: it.item_name,
|
||||||
|
unitPrice: Number(it.unit_price),
|
||||||
|
quantity: it.quantity,
|
||||||
|
subtotal: Number(it.subtotal),
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json([]);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('[food/orders GET]', { error: err.message });
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 🔄 СМЕНА СТАТУСА ЗАКАЗА (С ВОЗВРАТОМ СРЕДСТВ ПРИ ОТМЕНЕ)
|
||||||
|
// ============================================
|
||||||
|
router.patch('/orders/:id/status', async (req, res) => {
|
||||||
|
const { status } = req.body || {};
|
||||||
|
const allowed = ['new', 'accepted', 'done', 'cancelled'];
|
||||||
|
|
||||||
|
if (!allowed.includes(status)) {
|
||||||
|
return res.status(400).json({ error: 'Некорректный статус' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Получаем текущий заказ
|
||||||
|
const order = await queryOne(
|
||||||
|
`SELECT id, tnom, total_amount, status FROM food_orders WHERE id = ?`,
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return res.status(404).json({ error: 'Заказ не найден' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Если заказ уже отменён — блокируем изменения
|
||||||
|
if (order.status === 'cancelled') {
|
||||||
|
return res.status(400).json({ error: 'Заказ уже отменён. Изменение статуса недоступно.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Если новый статус — cancelled, возвращаем деньги и меняем статус
|
||||||
|
if (status === 'cancelled') {
|
||||||
|
const { pool } = require('../config/db_food');
|
||||||
|
const conn = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction();
|
||||||
|
|
||||||
|
// Меняем статус
|
||||||
|
await conn.execute(
|
||||||
|
`UPDATE food_orders SET status = 'cancelled' WHERE id = ?`,
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Возвращаем деньги на баланс
|
||||||
|
await conn.execute(
|
||||||
|
`UPDATE food_accounts SET balance = balance + ? WHERE tnom = ?`,
|
||||||
|
[order.total_amount, order.tnom]
|
||||||
|
);
|
||||||
|
|
||||||
|
await conn.commit();
|
||||||
|
logger.info(`[food/orders] Заказ #${req.params.id} отменён, возвращено ${order.total_amount} BYN`);
|
||||||
|
return res.json({ success: true, refunded: true, refund: Number(order.total_amount) });
|
||||||
|
} catch (e) {
|
||||||
|
await conn.rollback();
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Любой другой статус (new/accepted/done) — просто меняем
|
||||||
|
await query(`UPDATE food_orders SET status = ? WHERE id = ?`, [status, req.params.id]);
|
||||||
|
res.json({ success: true, refunded: false });
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('[food/orders PATCH]', { error: err.message });
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 🛠️ CRUD ДЛЯ МОДЕРАТОРА (Управление меню)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// Добавить блюдо
|
||||||
|
router.post('/items', upload.single('image'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { category_id, name, description, price, weight_grams } = req.body;
|
||||||
|
const image_url = req.file ? `/uploads/food/${req.file.filename}` : null;
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO food_items (category_id, name, description, price, image_url, weight_grams)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
[category_id, name, description, price, image_url, weight_grams || null]
|
||||||
|
);
|
||||||
|
res.json({ success: true, id: result.insertId });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('[food/items POST]', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Редактировать блюдо
|
||||||
|
router.put('/items/:id', upload.single('image'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { category_id, name, description, price, weight_grams, current_image_url } = req.body;
|
||||||
|
|
||||||
|
let image_url = current_image_url;
|
||||||
|
if (req.file) {
|
||||||
|
image_url = `/uploads/food/${req.file.filename}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (image_url === 'null' || image_url === 'undefined') {
|
||||||
|
image_url = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE food_items SET category_id=?, name=?, description=?, price=?, image_url=?, weight_grams=?
|
||||||
|
WHERE id=?`,
|
||||||
|
[category_id, name, description, price, image_url, weight_grams || null, id]
|
||||||
|
);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('[food/items PUT]', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Удалить блюдо
|
||||||
|
router.delete('/items/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
await query(`DELETE FROM food_items WHERE id = ?`, [req.params.id]);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('[food/items DELETE]', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 📦 МОИ ЗАКАЗЫ (ДЛЯ ПОЛЬЗОВАТЕЛЯ)
|
||||||
|
// ============================================
|
||||||
|
router.get('/my-orders/:tnom', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { tnom } = req.params;
|
||||||
|
|
||||||
|
const orders = await query(
|
||||||
|
`SELECT id, total_amount, status, created_at
|
||||||
|
FROM food_orders
|
||||||
|
WHERE tnom = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 50`,
|
||||||
|
[tnom]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (orders.length === 0) return res.json([]);
|
||||||
|
|
||||||
|
// Получаем позиции для этих заказов
|
||||||
|
const ids = orders.map((o) => o.id);
|
||||||
|
const ph = ids.map(() => '?').join(',');
|
||||||
|
const items = await query(
|
||||||
|
`SELECT order_id, item_name, unit_price, quantity, subtotal
|
||||||
|
FROM food_order_items
|
||||||
|
WHERE order_id IN (${ph})`,
|
||||||
|
ids
|
||||||
|
);
|
||||||
|
|
||||||
|
const grouped = {};
|
||||||
|
items.forEach((it) => (grouped[it.order_id] ||= []).push(it));
|
||||||
|
|
||||||
|
const result = orders.map((o) => ({
|
||||||
|
id: o.id,
|
||||||
|
totalAmount: Number(o.total_amount),
|
||||||
|
status: o.status,
|
||||||
|
createdAt: o.created_at,
|
||||||
|
items: (grouped[o.id] || []).map((it) => ({
|
||||||
|
name: it.item_name,
|
||||||
|
unitPrice: Number(it.unit_price),
|
||||||
|
quantity: it.quantity,
|
||||||
|
subtotal: Number(it.subtotal),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('[food/my-orders]', { error: err.message });
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ❌ ОТМЕНА ЗАКАЗА ПОЛЬЗОВАТЕЛЕМ (ТОЛЬКО СТАТУС 'new')
|
||||||
|
// ============================================
|
||||||
|
router.patch('/my-orders/:id/cancel', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { tnom } = req.body; // Проверяем, что отменяет владелец
|
||||||
|
|
||||||
|
if (!tnom) return res.status(400).json({ error: 'Не указан табельный номер' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Получаем информацию о заказе
|
||||||
|
const order = await queryOne(
|
||||||
|
`SELECT id, status, total_amount FROM food_orders WHERE id = ? AND tnom = ?`,
|
||||||
|
[id, tnom]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return res.status(404).json({ error: 'Заказ не найден' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.status !== 'new') {
|
||||||
|
return res.status(400).json({ error: 'Отменить можно только новые заказы' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Транзакция: смена статуса + возврат денег
|
||||||
|
const { pool } = require('../config/db_food');
|
||||||
|
const conn = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction();
|
||||||
|
|
||||||
|
// 1. Меняем статус
|
||||||
|
await conn.execute(
|
||||||
|
`UPDATE food_orders SET status = 'cancelled' WHERE id = ?`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Возвращаем деньги
|
||||||
|
await conn.execute(
|
||||||
|
`UPDATE food_accounts SET balance = balance + ? WHERE tnom = ?`,
|
||||||
|
[order.total_amount, tnom]
|
||||||
|
);
|
||||||
|
|
||||||
|
await conn.commit();
|
||||||
|
res.json({ success: true, refund: Number(order.total_amount) });
|
||||||
|
} catch (e) {
|
||||||
|
await conn.rollback();
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('[food/my-orders cancel]', { error: err.message });
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
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 || 'не указан'}`);
|
||||||
|
|
||||||
|
const configPath = path.join(__dirname, '../../config.json');
|
||||||
|
const config = JSON.parse(fs.readFileSync(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}`);
|
||||||
|
|
||||||
|
if (!fs.existsSync(excelPath)) {
|
||||||
|
logger.warn(`[PayslipForm] Excel не найден: ${excelPath}`);
|
||||||
|
return res.status(404).json({
|
||||||
|
message: 'Excel-файл расчетного листка не найден',
|
||||||
|
path: excelPath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parsePayslipExcel(excelPath);
|
||||||
|
logger.info(`[PayslipForm] Данные извлечены:`, {
|
||||||
|
fullName: data.fullName,
|
||||||
|
tabNumber: data.tabNumber,
|
||||||
|
totalAccrued: data.totalAccrued,
|
||||||
|
totalPaid: data.totalPaid
|
||||||
|
});
|
||||||
|
|
||||||
|
const html = generatePayslipForm(data);
|
||||||
|
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/data/:tnom
|
||||||
|
* Получить структурированные данные из Excel (для предпросмотра и печати)
|
||||||
|
*/
|
||||||
|
router.get('/data/:tnom', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { tnom } = req.params;
|
||||||
|
const { period } = req.query;
|
||||||
|
|
||||||
|
if (!tnom || !period) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Не указан табельный номер или период'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`[PayslipData] Запрос данных для ${tnom}, период: ${period}`);
|
||||||
|
|
||||||
|
const configPath = path.join(__dirname, '../../config.json');
|
||||||
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||||
|
|
||||||
|
let payslipBasePath = config.payslipsPath || './payslips';
|
||||||
|
if (!path.isAbsolute(payslipBasePath)) {
|
||||||
|
payslipBasePath = path.join(__dirname, '../..', payslipBasePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const excelPath = path.join(payslipBasePath, period, `${tnom}.xlsx`);
|
||||||
|
|
||||||
|
logger.info(`[PayslipData] Путь к Excel: ${excelPath}`);
|
||||||
|
|
||||||
|
if (!fs.existsSync(excelPath)) {
|
||||||
|
logger.warn(`[PayslipData] Файл не найден: ${excelPath}`);
|
||||||
|
return res.status(404).json({
|
||||||
|
error: `Файл не найден: ${tnom}.xlsx в папке ${period}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parsePayslipExcel(excelPath);
|
||||||
|
data.period = period;
|
||||||
|
|
||||||
|
logger.info(`[PayslipData] Данные извлечены:`, {
|
||||||
|
fullName: data.fullName,
|
||||||
|
tabNumber: data.tabNumber,
|
||||||
|
totalAccrued: data.totalAccrued,
|
||||||
|
totalPaid: data.totalPaid
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(data);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[PayslipData] Ошибка:', error.message);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/payslip/url/:tnom
|
||||||
|
* Проверка наличия PDF файла
|
||||||
|
*/
|
||||||
|
router.get('/url/:tnom', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { tnom } = req.params;
|
||||||
|
const { period } = req.query;
|
||||||
|
|
||||||
|
if (!tnom || !period) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Не указан табельный номер или период'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const configPath = path.join(__dirname, '../../config.json');
|
||||||
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||||
|
|
||||||
|
let payslipBasePath = config.payslipsPath || './payslips';
|
||||||
|
if (!path.isAbsolute(payslipBasePath)) {
|
||||||
|
payslipBasePath = path.join(__dirname, '../..', payslipBasePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdfPath = path.join(payslipBasePath, period, `${tnom}.pdf`);
|
||||||
|
const exists = fs.existsSync(pdfPath);
|
||||||
|
|
||||||
|
logger.info(`[PayslipURL] Проверка PDF для ${tnom}, период: ${period}, существует: ${exists}`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
exists: exists,
|
||||||
|
url: exists ? `/payslips/${period}/${tnom}.pdf` : null
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[PayslipURL] Ошибка:', error.message);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/payslip/file/:fileName
|
||||||
|
* Отдача PDF файла для предпросмотра (НОВЫЙ ЭНДПОИНТ)
|
||||||
|
*/
|
||||||
|
router.get('/file/:fileName', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { fileName } = req.params;
|
||||||
|
|
||||||
|
const configPath = path.join(__dirname, '../../config.json');
|
||||||
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||||
|
|
||||||
|
let payslipBasePath = config.payslipsPath || './payslips';
|
||||||
|
if (!path.isAbsolute(payslipBasePath)) {
|
||||||
|
payslipBasePath = path.join(__dirname, '../..', payslipBasePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ищем файл в папке payslips и подпапках
|
||||||
|
let filePath = null;
|
||||||
|
|
||||||
|
// Сначала ищем в корне
|
||||||
|
const rootPath = path.join(payslipBasePath, fileName);
|
||||||
|
if (fs.existsSync(rootPath)) {
|
||||||
|
filePath = rootPath;
|
||||||
|
} else {
|
||||||
|
// Ищем в подпапках (периодах)
|
||||||
|
try {
|
||||||
|
const subDirs = fs.readdirSync(payslipBasePath);
|
||||||
|
for (const subDir of subDirs) {
|
||||||
|
const subPath = path.join(payslipBasePath, subDir, fileName);
|
||||||
|
if (fs.existsSync(subPath)) {
|
||||||
|
filePath = subPath;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('[PayslipFile] Не удалось прочитать папку:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
logger.warn(`[PayslipFile] Файл не найден: ${fileName}`);
|
||||||
|
return res.status(404).json({ error: 'Файл не найден' });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`[PayslipFile] Отдача файла: ${filePath}`);
|
||||||
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
|
res.setHeader('Content-Disposition', 'inline; filename="' + fileName + '"');
|
||||||
|
res.sendFile(filePath);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[PayslipFile] Ошибка:', error.message);
|
||||||
|
res.status(500).json({ 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;
|
||||||
|
|
||||||
|
const configPath = path.join(__dirname, '../../config.json');
|
||||||
|
const config = JSON.parse(fs.readFileSync(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`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(excelPath)) {
|
||||||
|
return res.status(404).json({ message: 'Excel не найден' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parsePayslipExcel(excelPath);
|
||||||
|
res.json(data);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[PayslipForm] Ошибка получения данных:', error.message);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -1,23 +1,38 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs');
|
||||||
|
const fsPromises = require('fs').promises;
|
||||||
const { connectDB } = require('./config/db');
|
const { connectDB } = require('./config/db');
|
||||||
const { logger } = require('./config/logger');
|
const { logger } = require('./config/logger');
|
||||||
const handbookRoutes = require('./routes/handbook');
|
const handbookRoutes = require('./routes/handbook');
|
||||||
const employeeRoutes = require('./routes/employee');
|
const employeeRoutes = require('./routes/employee');
|
||||||
|
const foodRoutes = require('./routes/food'); // ← НОВАЯ
|
||||||
|
const helmet = require('helmet');
|
||||||
|
const rateLimit = require('express-rate-limit');
|
||||||
|
const XLSX = require('xlsx');
|
||||||
require('dotenv').config({ path: path.join(__dirname, '../config.env') });
|
require('dotenv').config({ path: path.join(__dirname, '../config.env') });
|
||||||
|
|
||||||
|
const limiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 100
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 📚 ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ
|
||||||
|
// ============================================
|
||||||
|
|
||||||
async function readJson(filePath) {
|
async function readJson(filePath) {
|
||||||
const data = await fs.readFile(filePath, 'utf8');
|
const data = await fsPromises.readFile(filePath, 'utf8');
|
||||||
return JSON.parse(data);
|
return JSON.parse(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pathExists(filePath) {
|
async function pathExists(filePath) {
|
||||||
|
console.log('🔍 Проверка пути:', filePath);
|
||||||
try {
|
try {
|
||||||
await fs.access(filePath);
|
await fsPromises.access(filePath);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch (err) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -32,16 +47,16 @@ async function connectToNetworkShare(config) {
|
|||||||
const username = config.networkCredentials.username;
|
const username = config.networkCredentials.username;
|
||||||
const password = config.networkCredentials.password;
|
const password = config.networkCredentials.password;
|
||||||
const domain = config.networkCredentials.domain;
|
const domain = config.networkCredentials.domain;
|
||||||
const netUseCommand = `net use \\${payslipsPath} ${password} /user:${domain ? domain + '\\' : ''}${username}`;
|
const netUseCommand = 'net use \\\\' + payslipsPath + ' ' + password + ' /user:' + (domain ? domain + '\\' : '') + username;
|
||||||
|
|
||||||
logger.info(`Attempting to connect to network share with credentials`);
|
logger.info('Attempting to connect to network share with credentials');
|
||||||
const { stdout, stderr } = await execAsync(netUseCommand);
|
const result = await execAsync(netUseCommand);
|
||||||
|
|
||||||
if (stderr) {
|
if (result.stderr) {
|
||||||
logger.warn(`Network share connection warning: ${stderr}`);
|
logger.warn('Network share connection warning: ' + result.stderr);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Network share connection result: ${stdout}`);
|
logger.info('Network share connection result: ' + result.stdout);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to connect to network share', { error: error.message });
|
logger.error('Failed to connect to network share', { error: error.message });
|
||||||
@@ -52,53 +67,606 @@ async function connectToNetworkShare(config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 🚀 Инициализация Express
|
// 🚀 ИНИЦИАЛИЗАЦИЯ EXPRESS
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
|
// ✅ Раздаем статические файлы (картинки питания, слайды и т.д.)
|
||||||
|
app.use('/uploads', express.static(path.join(__dirname, '../uploads')));
|
||||||
|
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
|
app.use(function(req, res, next) {
|
||||||
|
if (req.method === 'POST' || req.method === 'PUT') {
|
||||||
|
if (req.path.startsWith('/api/food')) {
|
||||||
|
return next(); // Пропускаем FormData для питания
|
||||||
|
}
|
||||||
|
if (!req.is('application/json')) {
|
||||||
|
return res.status(415).json({ message: 'Only JSON allowed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ ИСПРАВЛЕННАЯ НАСТРОЙКА 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);
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 📍 Маршруты API
|
// 📍 МАРШРУТЫ API
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
app.use('/api/handbook', handbookRoutes);
|
app.use('/api/handbook', handbookRoutes);
|
||||||
app.use('/api/employee', employeeRoutes);
|
app.use('/api/employee', employeeRoutes);
|
||||||
|
app.use('/api/food', foodRoutes); // ← НОВАЯ
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 🧪 Тестовые эндпоинты
|
// 🧪 ТЕСТОВЫЙ ЭНДПОИНТ
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
app.get('/api/test', (req, res) => {
|
app.get('/api/test', function(req, res) {
|
||||||
res.json({ message: 'API работает' });
|
res.json({ message: 'API работает' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 📊 Эндпоинт для получения статуса системы
|
// 📊 КУРСЫ ВАЛЮТ ИЗ EXCEL
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
app.get('/api/system-status', async (req, res) => {
|
var EXCEL_PATH = path.join(__dirname, '../rates.xlsx');
|
||||||
try {
|
|
||||||
const configPath = path.join(__dirname, '../config.json');
|
|
||||||
const config = await readJson(configPath);
|
|
||||||
const { getDBStatus } = require('./config/db');
|
|
||||||
const dbStatus = await getDBStatus();
|
|
||||||
|
|
||||||
let networkAccess = false;
|
async function createExcelTemplate() {
|
||||||
let networkError = null;
|
var wb = XLSX.utils.book_new();
|
||||||
let networkContents = [];
|
|
||||||
|
var currencyData = [
|
||||||
|
['currency', 'rate', 'change'],
|
||||||
|
['USD', 3.22, 0.5],
|
||||||
|
['EUR', 3.45, -0.2],
|
||||||
|
['CNY', 0.44, 0.1],
|
||||||
|
['RUB', 0.035, -0.3]
|
||||||
|
];
|
||||||
|
var currencySheet = XLSX.utils.aoa_to_sheet(currencyData);
|
||||||
|
XLSX.utils.book_append_sheet(wb, currencySheet, 'Currency');
|
||||||
|
|
||||||
|
var metalsData = [
|
||||||
|
['metal', 'price', 'change'],
|
||||||
|
['Золото', 1950, 0.8],
|
||||||
|
['Серебро', 23.5, -0.5],
|
||||||
|
['Платина', 920, 0.3],
|
||||||
|
['Палладий', 1450, -0.7]
|
||||||
|
];
|
||||||
|
var metalsSheet = XLSX.utils.aoa_to_sheet(metalsData);
|
||||||
|
XLSX.utils.book_append_sheet(wb, metalsSheet, 'Metals');
|
||||||
|
|
||||||
|
XLSX.writeFile(wb, EXCEL_PATH);
|
||||||
|
logger.info('Excel-шаблон создан');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRatesFromExcel() {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(EXCEL_PATH)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var workbook = XLSX.readFile(EXCEL_PATH);
|
||||||
|
|
||||||
|
var currencySheet = workbook.Sheets['Currency'];
|
||||||
|
var currency = [];
|
||||||
|
if (currencySheet) {
|
||||||
|
var data = XLSX.utils.sheet_to_json(currencySheet);
|
||||||
|
for (var i = 0; i < data.length; i++) {
|
||||||
|
var row = data[i];
|
||||||
|
currency.push({
|
||||||
|
currency: String(row.currency || row.Currency || '').toUpperCase(),
|
||||||
|
rate: parseFloat(row.rate || row.Rate || 0),
|
||||||
|
change: parseFloat(row.change || row.Change || 0)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var metalsSheet = workbook.Sheets['Metals'];
|
||||||
|
var metals = [];
|
||||||
|
if (metalsSheet) {
|
||||||
|
var data = XLSX.utils.sheet_to_json(metalsSheet);
|
||||||
|
for (var i = 0; i < data.length; i++) {
|
||||||
|
var row = data[i];
|
||||||
|
metals.push({
|
||||||
|
metal: String(row.metal || row.Metal || row.name || row.Name || ''),
|
||||||
|
price: parseFloat(row.price || row.Price || 0),
|
||||||
|
change: parseFloat(row.change || row.Change || 0)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { currency: currency, metals: metals };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Ошибка чтения Excel:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 📊 GET /api/rates
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
app.get('/api/rates', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var fileExists = await pathExists(EXCEL_PATH);
|
||||||
|
if (!fileExists) {
|
||||||
|
await createExcelTemplate();
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = getRatesFromExcel();
|
||||||
|
|
||||||
|
if (data && data.currency.length > 0 && data.metals.length > 0) {
|
||||||
|
res.json({
|
||||||
|
currency: data.currency,
|
||||||
|
metals: data.metals,
|
||||||
|
source: 'excel',
|
||||||
|
updated: new Date().toISOString()
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.json({
|
||||||
|
currency: [
|
||||||
|
{ currency: 'USD', rate: 3.22, change: 0.5 },
|
||||||
|
{ currency: 'EUR', rate: 3.45, change: -0.2 },
|
||||||
|
{ currency: 'CNY', rate: 0.44, change: 0.1 },
|
||||||
|
{ currency: 'RUB', rate: 0.035, change: -0.3 }
|
||||||
|
],
|
||||||
|
metals: [
|
||||||
|
{ metal: 'Золото', price: 1950, change: 0.8 },
|
||||||
|
{ metal: 'Серебро', price: 23.5, change: -0.5 },
|
||||||
|
{ metal: 'Платина', price: 920, change: 0.3 },
|
||||||
|
{ metal: 'Палладий', price: 1450, change: -0.7 }
|
||||||
|
],
|
||||||
|
source: 'default'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Ошибка получения курсов:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 🔄 POST /api/rates
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
app.post('/api/rates', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var currency = req.body.currency;
|
||||||
|
var metals = req.body.metals;
|
||||||
|
|
||||||
|
var wb = XLSX.utils.book_new();
|
||||||
|
|
||||||
|
if (currency && currency.length > 0) {
|
||||||
|
var currencyData = [
|
||||||
|
['currency', 'rate', 'change']
|
||||||
|
];
|
||||||
|
for (var i = 0; i < currency.length; i++) {
|
||||||
|
currencyData.push([currency[i].currency, currency[i].rate, currency[i].change]);
|
||||||
|
}
|
||||||
|
var currencySheet = XLSX.utils.aoa_to_sheet(currencyData);
|
||||||
|
XLSX.utils.book_append_sheet(wb, currencySheet, 'Currency');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metals && metals.length > 0) {
|
||||||
|
var metalsData = [
|
||||||
|
['metal', 'price', 'change']
|
||||||
|
];
|
||||||
|
for (var i = 0; i < metals.length; i++) {
|
||||||
|
metalsData.push([metals[i].metal, metals[i].price, metals[i].change]);
|
||||||
|
}
|
||||||
|
var metalsSheet = XLSX.utils.aoa_to_sheet(metalsData);
|
||||||
|
XLSX.utils.book_append_sheet(wb, metalsSheet, 'Metals');
|
||||||
|
}
|
||||||
|
|
||||||
|
XLSX.writeFile(wb, EXCEL_PATH);
|
||||||
|
logger.info('Excel-файл обновлён');
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Курсы обновлены в Excel',
|
||||||
|
path: EXCEL_PATH
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Ошибка при обновлении Excel:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 🔄 POST /api/rates/update-from-nbrb
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
app.post('/api/rates/update-from-nbrb', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var currencyResponse = await fetch('https://api.nbrb.by/exrates/rates?periodicity=0');
|
||||||
|
var currencyData = await currencyResponse.json();
|
||||||
|
|
||||||
|
var metalResponse = await fetch('https://api.nbrb.by/ingots/prices');
|
||||||
|
var metalData = await metalResponse.json();
|
||||||
|
|
||||||
|
var currency = [
|
||||||
|
{ currency: 'USD', rate: 0, change: 0 },
|
||||||
|
{ currency: 'EUR', rate: 0, change: 0 },
|
||||||
|
{ currency: 'CNY', rate: 0, change: 0 },
|
||||||
|
{ currency: 'RUB', rate: 0, change: 0 }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (var i = 0; i < currencyData.length; i++) {
|
||||||
|
var c = currencyData[i];
|
||||||
|
if (c.Cur_Abbreviation === 'USD') currency[0].rate = c.Cur_OfficialRate || 0;
|
||||||
|
if (c.Cur_Abbreviation === 'EUR') currency[1].rate = c.Cur_OfficialRate || 0;
|
||||||
|
if (c.Cur_Abbreviation === 'CNY') currency[2].rate = c.Cur_OfficialRate || 0;
|
||||||
|
if (c.Cur_Abbreviation === 'RUB') currency[3].rate = c.Cur_OfficialRate || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var metals = [
|
||||||
|
{ metal: 'Золото', price: 0, change: 0 },
|
||||||
|
{ metal: 'Серебро', price: 0, change: 0 },
|
||||||
|
{ metal: 'Платина', price: 0, change: 0 },
|
||||||
|
{ metal: 'Палладий', price: 0, change: 0 }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (var i = 0; i < metalData.length; i++) {
|
||||||
|
var m = metalData[i];
|
||||||
|
if (m.MetalId === 0) metals[0].price = m.EntitiesDollars || 0;
|
||||||
|
if (m.MetalId === 1) metals[1].price = m.EntitiesDollars || 0;
|
||||||
|
if (m.MetalId === 2) metals[2].price = m.EntitiesDollars || 0;
|
||||||
|
if (m.MetalId === 3) metals[3].price = m.EntitiesDollars || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var wb = XLSX.utils.book_new();
|
||||||
|
|
||||||
|
var currencySheetData = [
|
||||||
|
['currency', 'rate', 'change']
|
||||||
|
];
|
||||||
|
for (var i = 0; i < currency.length; i++) {
|
||||||
|
currencySheetData.push([currency[i].currency, currency[i].rate, currency[i].change]);
|
||||||
|
}
|
||||||
|
var currencySheet = XLSX.utils.aoa_to_sheet(currencySheetData);
|
||||||
|
XLSX.utils.book_append_sheet(wb, currencySheet, 'Currency');
|
||||||
|
|
||||||
|
var metalsSheetData = [
|
||||||
|
['metal', 'price', 'change']
|
||||||
|
];
|
||||||
|
for (var i = 0; i < metals.length; i++) {
|
||||||
|
metalsSheetData.push([metals[i].metal, metals[i].price, metals[i].change]);
|
||||||
|
}
|
||||||
|
var metalsSheet = XLSX.utils.aoa_to_sheet(metalsSheetData);
|
||||||
|
XLSX.utils.book_append_sheet(wb, metalsSheet, 'Metals');
|
||||||
|
|
||||||
|
XLSX.writeFile(wb, EXCEL_PATH);
|
||||||
|
logger.info('Курсы обновлены из НБРБ');
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Курсы обновлены из НБРБ',
|
||||||
|
data: { currency: currency, metals: metals }
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Ошибка при обновлении курсов из НБРБ:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 📄 РАСЧЕТНЫЕ ЛИСТКИ (ОБНОВЛЕННЫЕ ЭНДПОИНТЫ)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// ✅ НОВАЯ ФУНКЦИЯ: получает путь к файлу с учетом периода (месяца)
|
||||||
|
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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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) {
|
||||||
|
let fileUrl = `/api/payslip/tnom/${tnom}`;
|
||||||
|
if (period) {
|
||||||
|
fileUrl += `?period=${period}`;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
|
||||||
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 📄 РАСЧЕТНЫЙ ЛИСТОК (ФОРМА ДЛЯ ПЕЧАТИ) - НОВЫЙ МАРШРУТ
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const payslipFormRoutes = require('./routes/payslipForm');
|
||||||
|
app.use('/api/payslip', payslipFormRoutes);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 🗄️ СТАРЫЕ ЭНДПОИНТЫ РАСЧЕТНЫХ ЛИСТКОВ (для обратной совместимости)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
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/system-status', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var configPath = path.join(__dirname, '../config.json');
|
||||||
|
var config = await readJson(configPath);
|
||||||
|
var db = require('./config/db');
|
||||||
|
var dbStatus = await db.getDBStatus();
|
||||||
|
|
||||||
|
var networkAccess = false;
|
||||||
|
var networkError = null;
|
||||||
|
var networkContents = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
networkAccess = await pathExists(config.payslipsPath);
|
networkAccess = await pathExists(config.payslipsPath);
|
||||||
if (networkAccess) {
|
if (networkAccess) {
|
||||||
const stats = await fs.stat(config.payslipsPath);
|
var stats = await fsPromises.stat(config.payslipsPath);
|
||||||
networkAccess = stats.isDirectory();
|
networkAccess = stats.isDirectory();
|
||||||
if (networkAccess) {
|
if (networkAccess) {
|
||||||
networkContents = await fs.readdir(config.payslipsPath);
|
networkContents = await fsPromises.readdir(config.payslipsPath);
|
||||||
if (networkContents.length > 10) {
|
if (networkContents.length > 10) {
|
||||||
networkContents = networkContents.slice(0, 10);
|
networkContents = networkContents.slice(0, 10);
|
||||||
networkContents.push(`... и еще ${networkContents.length - 10} элементов`);
|
networkContents.push('... и еще ' + (networkContents.length - 10) + ' элементов');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,7 +675,7 @@ app.get('/api/system-status', async (req, res) => {
|
|||||||
networkError = err.message;
|
networkError = err.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverInfo = {
|
var serverInfo = {
|
||||||
uptime: process.uptime(),
|
uptime: process.uptime(),
|
||||||
memory: process.memoryUsage(),
|
memory: process.memoryUsage(),
|
||||||
nodeVersion: process.version,
|
nodeVersion: process.version,
|
||||||
@@ -130,8 +698,8 @@ app.get('/api/system-status', async (req, res) => {
|
|||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
testMode: config.testMode || false,
|
testMode: config.testMode || false,
|
||||||
serverHost: config.server?.host || 'localhost',
|
serverHost: config.server ? config.server.host : 'localhost',
|
||||||
serverPort: config.server?.port || '3001'
|
serverPort: config.server ? config.server.port : '3001'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -145,22 +713,18 @@ app.get('/api/system-status', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================
|
app.get('/api/test-network', async function(req, res) {
|
||||||
// 🌐 Тест сетевой папки
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
app.get('/api/test-network', async (req, res) => {
|
|
||||||
try {
|
try {
|
||||||
const configPath = path.join(__dirname, '../config.json');
|
var configPath = path.join(__dirname, '../config.json');
|
||||||
const config = await readJson(configPath);
|
var config = await readJson(configPath);
|
||||||
const payslipBasePath = config.payslipsPath;
|
var payslipBasePath = config.payslipsPath;
|
||||||
|
|
||||||
logger.info(`Testing network connection to: ${payslipBasePath}`);
|
logger.info('Testing network connection to: ' + payslipBasePath);
|
||||||
|
|
||||||
const networkConnected = await connectToNetworkShare(config);
|
var networkConnected = await connectToNetworkShare(config);
|
||||||
logger.info(`Network share connection successful: ${networkConnected}`);
|
logger.info('Network share connection successful: ' + networkConnected);
|
||||||
|
|
||||||
const testResults = {
|
var testResults = {
|
||||||
basePath: payslipBasePath,
|
basePath: payslipBasePath,
|
||||||
basePathExists: false,
|
basePathExists: false,
|
||||||
basePathAccessible: false,
|
basePathAccessible: false,
|
||||||
@@ -171,16 +735,16 @@ app.get('/api/test-network', async (req, res) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
testResults.basePathExists = await pathExists(payslipBasePath);
|
testResults.basePathExists = await pathExists(payslipBasePath);
|
||||||
logger.info(`Base path exists: ${testResults.basePathExists}`);
|
logger.info('Base path exists: ' + testResults.basePathExists);
|
||||||
|
|
||||||
if (testResults.basePathExists) {
|
if (testResults.basePathExists) {
|
||||||
const stats = await fs.stat(payslipBasePath);
|
var stats = await fsPromises.stat(payslipBasePath);
|
||||||
testResults.basePathAccessible = stats.isDirectory();
|
testResults.basePathAccessible = stats.isDirectory();
|
||||||
logger.info(`Base path is directory: ${testResults.basePathAccessible}`);
|
logger.info('Base path is directory: ' + testResults.basePathAccessible);
|
||||||
|
|
||||||
if (testResults.basePathAccessible) {
|
if (testResults.basePathAccessible) {
|
||||||
testResults.basePathContents = await fs.readdir(payslipBasePath);
|
testResults.basePathContents = await fsPromises.readdir(payslipBasePath);
|
||||||
logger.info(`Base path contents: ${JSON.stringify(testResults.basePathContents)}`);
|
logger.info('Base path contents: ' + JSON.stringify(testResults.basePathContents));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -195,125 +759,10 @@ app.get('/api/test-network', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================
|
app.get('/api/db-status', async function(req, res) {
|
||||||
// 📄 Получение расчетного листка (с периодом)
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
app.get('/api/payslip/:period/:fileName', async (req, res) => {
|
|
||||||
try {
|
try {
|
||||||
const { period, fileName } = req.params;
|
var db = require('./config/db');
|
||||||
const configPath = path.join(__dirname, '../config.json');
|
await db.query('SELECT 1');
|
||||||
const config = await readJson(configPath);
|
|
||||||
const payslipBasePath = config.payslipsPath;
|
|
||||||
|
|
||||||
logger.info(`Payslip request - Period: ${period}, File: ${fileName}`);
|
|
||||||
logger.info(`Base path from config: ${payslipBasePath}`);
|
|
||||||
|
|
||||||
const networkConnected = await connectToNetworkShare(config);
|
|
||||||
logger.info(`Network share connection for payslip: ${networkConnected}`);
|
|
||||||
|
|
||||||
let finalPayslipPath;
|
|
||||||
if (path.isAbsolute(payslipBasePath)) {
|
|
||||||
finalPayslipPath = path.join(payslipBasePath, period, fileName);
|
|
||||||
} else {
|
|
||||||
const projectRoot = path.resolve(__dirname, '../../');
|
|
||||||
finalPayslipPath = path.join(projectRoot, payslipBasePath, period, fileName);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Final payslip path: ${finalPayslipPath}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const basePathExists = await pathExists(payslipBasePath);
|
|
||||||
logger.info(`Base path exists: ${basePathExists}`);
|
|
||||||
|
|
||||||
if (basePathExists) {
|
|
||||||
const basePathStats = await fs.stat(payslipBasePath);
|
|
||||||
logger.info(`Base path stats: ${JSON.stringify({
|
|
||||||
isDirectory: basePathStats.isDirectory(),
|
|
||||||
permissions: basePathStats.mode,
|
|
||||||
size: basePathStats.size
|
|
||||||
})}`);
|
|
||||||
|
|
||||||
const basePathContents = await fs.readdir(payslipBasePath);
|
|
||||||
logger.info(`Base path contents: ${JSON.stringify(basePathContents)}`);
|
|
||||||
|
|
||||||
const periodPath = path.join(payslipBasePath, period);
|
|
||||||
const periodPathExists = await pathExists(periodPath);
|
|
||||||
logger.info(`Period path exists: ${periodPathExists} (${periodPath})`);
|
|
||||||
|
|
||||||
if (periodPathExists) {
|
|
||||||
const periodPathContents = await fs.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 (req, res) => {
|
|
||||||
try {
|
|
||||||
const { fileName } = req.params;
|
|
||||||
const configPath = path.join(__dirname, '../config.json');
|
|
||||||
const config = await readJson(configPath);
|
|
||||||
const payslipBasePath = config.payslipsPath;
|
|
||||||
|
|
||||||
let finalPayslipPath;
|
|
||||||
if (path.isAbsolute(payslipBasePath)) {
|
|
||||||
finalPayslipPath = path.join(payslipBasePath, fileName);
|
|
||||||
} else {
|
|
||||||
const 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 (req, res) => {
|
|
||||||
try {
|
|
||||||
const { query } = require('./config/db');
|
|
||||||
await query('SELECT 1');
|
|
||||||
res.status(200).json({ status: 'ok', message: 'DB connection successful' });
|
res.status(200).json({ status: 'ok', message: 'DB connection successful' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Database connection check failed', { error: err.message, stack: err.stack });
|
logger.error('Database connection check failed', { error: err.message, stack: err.stack });
|
||||||
@@ -321,15 +770,36 @@ app.get('/api/db-status', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/time', function(req, res) {
|
||||||
|
var now = new Date();
|
||||||
|
logger.info('[TIME] Запрос времени от ' + (req.ip || req.connection.remoteAddress));
|
||||||
|
res.json({
|
||||||
|
time: now.toISOString(),
|
||||||
|
timestamp: now.getTime()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 🚀 ЗАПУСК СЕРВЕРА
|
||||||
|
// ============================================
|
||||||
|
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
try {
|
try {
|
||||||
const configPath = path.join(__dirname, '../config.json');
|
var configPath = path.join(__dirname, '../config.json');
|
||||||
const config = await readJson(configPath);
|
var config = await readJson(configPath);
|
||||||
const { host, port } = config.server;
|
var host = config.server.host;
|
||||||
|
var port = config.server.port;
|
||||||
|
|
||||||
|
var excelExists = await pathExists(EXCEL_PATH);
|
||||||
|
if (!excelExists) {
|
||||||
|
await createExcelTemplate();
|
||||||
|
logger.info('Создан Excel-шаблон');
|
||||||
|
}
|
||||||
|
|
||||||
await connectDB();
|
await connectDB();
|
||||||
app.listen(port, host, () => {
|
app.listen(port, host, function() {
|
||||||
logger.info(`Server is running on http://${host}:${port}`);
|
logger.info('Server is running on http://' + host + ':' + port);
|
||||||
|
logger.info('Excel file: ' + EXCEL_PATH);
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (logger) {
|
if (logger) {
|
||||||
@@ -340,4 +810,13 @@ async function startServer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ❌ ОБРАБОТКА ОШИБОК
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
app.use(function(err, req, res, next) {
|
||||||
|
logger.error('Unhandled error', { error: err.message, stack: err.stack });
|
||||||
|
res.status(500).json({ message: 'Внутренняя ошибка сервера' });
|
||||||
|
});
|
||||||
|
|
||||||
startServer();
|
startServer();
|
||||||
@@ -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,338 @@
|
|||||||
|
const { logger } = require('../config/logger');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Генерирует HTML-форму расчетного листка для термопринтера
|
||||||
|
* @param {Object} data - данные из Excel
|
||||||
|
* @returns {string} HTML-код формы
|
||||||
|
*/
|
||||||
|
// ============================================
|
||||||
|
// 📄 ГЕНЕРАЦИЯ 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 с улучшенной печатью
|
||||||
|
let html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Расчетный листок</title>
|
||||||
|
<style>
|
||||||
|
/* ✅ ГЛОБАЛЬНЫЕ ПРАВИЛА ДЛЯ ПЕЧАТИ */
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
width: 80mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2mm 2mm;
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #000;
|
||||||
|
background: #fff;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ✅ ПРАВИЛА ДЛЯ ПЕЧАТИ - ПРИНУДИТЕЛЬНОЕ ОТОБРАЖЕНИЕ */
|
||||||
|
@media print {
|
||||||
|
body {
|
||||||
|
width: 80mm;
|
||||||
|
margin: 0;
|
||||||
|
padding: 1mm 1.5mm;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ✅ ЗАПРЕЩАЕМ РАЗРЫВЫ ВНУТРИ БЛОКОВ */
|
||||||
|
.header, .sub-header, .section-title, .total-row, .footer {
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ✅ ПРИНУДИТЕЛЬНО РАЗМЕРЫ */
|
||||||
|
.item-row {
|
||||||
|
font-size: 7.5pt !important;
|
||||||
|
padding: 0.5px 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-row {
|
||||||
|
font-size: 9.5pt !important;
|
||||||
|
padding: 2px 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
font-size: 11pt !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 9pt !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
font-size: 7.5pt !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
font-size: 6.5pt !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ✅ СТИЛИ ДЛЯ ЭКРАНА (ПРЕДПРОСМОТР) */
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11pt;
|
||||||
|
font-weight: bold;
|
||||||
|
padding-bottom: 3px;
|
||||||
|
border-bottom: 1px dashed #000;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-header {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 9pt;
|
||||||
|
padding-bottom: 3px;
|
||||||
|
border-bottom: 1px dashed #000;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1px 0;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-label {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-value {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
border-top: 1px dashed #000;
|
||||||
|
margin: 3px 0;
|
||||||
|
padding-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 2px 0;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 9.5pt;
|
||||||
|
border-top: 1px solid #000;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 9pt;
|
||||||
|
padding: 3px 0 2px 0;
|
||||||
|
border-bottom: 1px dotted #000;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5px 0;
|
||||||
|
font-size: 7.5pt;
|
||||||
|
padding-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-desc {
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-amount {
|
||||||
|
text-align: right;
|
||||||
|
min-width: 40px;
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 6.5pt;
|
||||||
|
color: #666;
|
||||||
|
border-top: 1px dashed #000;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bank-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5px 0;
|
||||||
|
font-size: 7.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5px 0;
|
||||||
|
font-size: 7.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ✅ ДЛЯ ПРЕДПРОСМОТРА В ПРИЛОЖЕНИИ */
|
||||||
|
@media screen {
|
||||||
|
body {
|
||||||
|
max-width: 80mm;
|
||||||
|
padding: 3mm;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
margin: 10px auto;
|
||||||
|
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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:7pt;max-width:100px;overflow:hidden;">${data.department || ''}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span>Должность:</span>
|
||||||
|
<span style="font-size:7pt;max-width:100px;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:12pt;padding:3px 0;">
|
||||||
|
<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:7pt;color:#666;">
|
||||||
|
<span>Лицевой счет:</span>
|
||||||
|
<span style="font-size:6pt;">${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 };
|
||||||
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1 @@
|
|||||||
|
VITE_NTP_SERVER=http://192.168.139.249:3001/api/time
|
||||||
@@ -9,15 +9,26 @@
|
|||||||
"@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.20.0",
|
||||||
|
"date-fns": "^2.30.0",
|
||||||
|
"dompurify": "^3.4.13",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"electron-is-dev": "^2.0.0",
|
"electron-is-dev": "^2.0.0",
|
||||||
"pdfjs-dist": "^4.10.38",
|
"framer-motion": "^13.1.1",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
|
"pdf-to-printer": "^5.8.1",
|
||||||
|
"pdfjs-dist": "^3.11.174",
|
||||||
|
"pdfkit": "^0.20.2",
|
||||||
|
"pdfmake": "^0.1.72",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-pdf": "^10.0.0",
|
"react-icons": "^4.12.0",
|
||||||
"serialport": "^13.0.0",
|
"react-pdf": "^8.0.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",
|
||||||
@@ -40,7 +51,7 @@
|
|||||||
},
|
},
|
||||||
"extraResources": [
|
"extraResources": [
|
||||||
{
|
{
|
||||||
"from": "node_modules/pdfjs-dist/build/pdf.worker.min.mjs",
|
"from": "node_modules/pdfjs-dist/build/pdf.worker.min.js",
|
||||||
"to": "pdf.worker.mjs",
|
"to": "pdf.worker.mjs",
|
||||||
"filter": [
|
"filter": [
|
||||||
"**/*"
|
"**/*"
|
||||||
@@ -69,12 +80,12 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/node": "^26.2.0",
|
"@types/node": "^26.2.0",
|
||||||
"@vitejs/plugin-react": "^4.7.0",
|
"@vitejs/plugin-react": "^6.0.5",
|
||||||
"concurrently": "^7.5.0",
|
"concurrently": "^7.5.0",
|
||||||
"electron": "^37.0.0",
|
"electron": "^37.0.0",
|
||||||
"electron-builder": "^26.15.3",
|
"electron-builder": "^26.15.3",
|
||||||
"electron-rebuild": "^3.2.9",
|
"electron-rebuild": "^3.2.9",
|
||||||
"vite": "^6.0.0",
|
"vite": "^8.2.1",
|
||||||
"vitest": "^2.1.8",
|
"vitest": "^2.1.8",
|
||||||
"wait-on": "^6.0.1"
|
"wait-on": "^6.0.1"
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 3.3 MiB |
@@ -3,12 +3,12 @@ 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 { SerialPort } = require('serialport');
|
|
||||||
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');
|
||||||
|
const { generatePayslipPDF } = require('./pdfGenerator');
|
||||||
|
|
||||||
protocol.registerSchemesAsPrivileged([
|
protocol.registerSchemesAsPrivileged([
|
||||||
{
|
{
|
||||||
scheme: 'app',
|
scheme: 'app',
|
||||||
@@ -52,6 +52,532 @@ try {
|
|||||||
|
|
||||||
global.allowClose = false;
|
global.allowClose = false;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 📄 IPC ОБРАБОТЧИКИ
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// ✅ 1. Печать PDF (принимает URL или base64) - СКРЫТАЯ ПЕЧАТЬ
|
||||||
|
ipcMain.handle('print-pdf', async (event, data = {}) => {
|
||||||
|
let tempFile = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!data.pdfUrl && !data.pdfBase64) {
|
||||||
|
throw new Error('URL или base64 PDF не передан');
|
||||||
|
}
|
||||||
|
|
||||||
|
let pdfBuffer;
|
||||||
|
|
||||||
|
// ✅ Если передан base64 — используем его
|
||||||
|
if (data.pdfBase64) {
|
||||||
|
logger.info('[PRINT] Печать PDF из base64');
|
||||||
|
pdfBuffer = Buffer.from(data.pdfBase64, 'base64');
|
||||||
|
logger.info('[PRINT] Размер PDF из base64:', pdfBuffer.length, 'байт');
|
||||||
|
}
|
||||||
|
// ✅ Если передан URL — скачиваем
|
||||||
|
else if (data.pdfUrl) {
|
||||||
|
const cleanUrl = data.pdfUrl.split('#')[0];
|
||||||
|
logger.info('[PRINT] Загрузка PDF из URL:', cleanUrl);
|
||||||
|
|
||||||
|
// Если это blob URL — получаем через fetch
|
||||||
|
if (cleanUrl.startsWith('blob:')) {
|
||||||
|
const response = await fetch(cleanUrl);
|
||||||
|
const blob = await response.blob();
|
||||||
|
pdfBuffer = Buffer.from(await blob.arrayBuffer());
|
||||||
|
} else {
|
||||||
|
const response = await axios.get(cleanUrl, {
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
timeout: 30000
|
||||||
|
});
|
||||||
|
pdfBuffer = Buffer.from(response.data);
|
||||||
|
}
|
||||||
|
logger.info('[PRINT] Размер PDF из URL:', pdfBuffer.length, 'байт');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pdfBuffer || pdfBuffer.length < 100) {
|
||||||
|
throw new Error('PDF слишком маленький или пустой');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ ВРЕМЕННО СОХРАНЯЕМ ДЛЯ ПЕЧАТИ (pdf-to-printer требует файл)
|
||||||
|
const tempDir = app.getPath('temp');
|
||||||
|
tempFile = path.join(tempDir, `print_${Date.now()}.pdf`);
|
||||||
|
fs.writeFileSync(tempFile, pdfBuffer);
|
||||||
|
logger.info('[PRINT] Временный файл создан:', tempFile);
|
||||||
|
|
||||||
|
const printerName = data.deviceName || 'GP-80250 Series';
|
||||||
|
logger.info('[PRINT] СКРЫТАЯ печать через pdf-to-printer:', printerName);
|
||||||
|
|
||||||
|
// ✅ КЛЮЧЕВОЕ: silent: true — печать без диалога
|
||||||
|
await print(tempFile, {
|
||||||
|
printer: printerName,
|
||||||
|
silent: true,
|
||||||
|
printDialog: false
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('[PRINT] ✅ Печать успешна');
|
||||||
|
|
||||||
|
// ✅ УДАЛЯЕМ ВРЕМЕННЫЙ ФАЙЛ
|
||||||
|
// try {
|
||||||
|
// if (tempFile && fs.existsSync(tempFile)) {
|
||||||
|
// fs.unlinkSync(tempFile);
|
||||||
|
// logger.info('[PRINT] Временный файл удален');
|
||||||
|
// }
|
||||||
|
// } catch (e) {
|
||||||
|
// logger.warn('[PRINT] Не удалось удалить временный файл:', e.message);
|
||||||
|
// }
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[PRINT] ❌ Ошибка:', error.message);
|
||||||
|
try {
|
||||||
|
if (tempFile && fs.existsSync(tempFile)) {
|
||||||
|
fs.unlinkSync(tempFile);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ 2. ОСНОВНАЯ ЛОГИКА ПЕЧАТИ HTML (оставлена для совместимости)
|
||||||
|
async function printHtmlDirectLogic(data) {
|
||||||
|
let tempHtml = null;
|
||||||
|
let tempPdf = null;
|
||||||
|
let printWindow = null;
|
||||||
|
let savedPdfPath = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!data.html) {
|
||||||
|
throw new Error('HTML-код не передан');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('[PRINT-HTML] ========== СОХРАНЕНИЕ PDF ==========');
|
||||||
|
logger.info('[PRINT-HTML] Длина HTML:', data.html.length);
|
||||||
|
|
||||||
|
const desktopPath = app.getPath('desktop');
|
||||||
|
const documentsPath = app.getPath('documents');
|
||||||
|
|
||||||
|
let saveDir = documentsPath;
|
||||||
|
const possiblePaths = [
|
||||||
|
path.join(documentsPath, 'payslips'),
|
||||||
|
path.join(documentsPath, 'Расчетные листки'),
|
||||||
|
path.join(desktopPath, 'payslips'),
|
||||||
|
path.join(desktopPath, 'Расчетные листки'),
|
||||||
|
documentsPath
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const dir of possiblePaths) {
|
||||||
|
if (fs.existsSync(dir)) {
|
||||||
|
saveDir = dir;
|
||||||
|
logger.info('[PRINT-HTML] Найдена папка для сохранения:', saveDir);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const dateStr = now.getFullYear() +
|
||||||
|
String(now.getMonth() + 1).padStart(2, '0') +
|
||||||
|
String(now.getDate()).padStart(2, '0') + '_' +
|
||||||
|
String(now.getHours()).padStart(2, '0') +
|
||||||
|
String(now.getMinutes()).padStart(2, '0') +
|
||||||
|
String(now.getSeconds()).padStart(2, '0');
|
||||||
|
|
||||||
|
const fileName = `test_${dateStr}.pdf`;
|
||||||
|
savedPdfPath = path.join(saveDir, fileName);
|
||||||
|
|
||||||
|
logger.info('[PRINT-HTML] PDF будет сохранен в:', savedPdfPath);
|
||||||
|
|
||||||
|
const tempDir = app.getPath('temp');
|
||||||
|
tempHtml = path.join(tempDir, `print_${Date.now()}.html`);
|
||||||
|
tempPdf = path.join(tempDir, `print_${Date.now()}.pdf`);
|
||||||
|
|
||||||
|
let htmlContent = data.html;
|
||||||
|
if (!htmlContent || htmlContent.trim().length < 10) {
|
||||||
|
logger.warn('[PRINT-HTML] HTML пустой, используем тестовый');
|
||||||
|
htmlContent = `
|
||||||
|
<div style="text-align:center;padding:20px;font-size:16pt;border:2px solid #000;">
|
||||||
|
<b>ТЕСТ ПЕЧАТИ</b>
|
||||||
|
<hr>
|
||||||
|
<div>Дата: ${new Date().toLocaleString()}</div>
|
||||||
|
<div>Табельный номер: TEST</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullHtml = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Расчетный листок</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
padding: 20px;
|
||||||
|
background: white;
|
||||||
|
color: black;
|
||||||
|
font-size: 12pt;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 16pt;
|
||||||
|
border-bottom: 2px solid #000;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
.total {
|
||||||
|
font-weight: bold;
|
||||||
|
border-top: 2px solid #000;
|
||||||
|
padding-top: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 14pt;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
font-weight: bold;
|
||||||
|
border-bottom: 1px dotted #000;
|
||||||
|
margin: 10px 0;
|
||||||
|
padding: 5px 0;
|
||||||
|
font-size: 14pt;
|
||||||
|
}
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 3px 0 3px 15px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 10pt;
|
||||||
|
color: #666;
|
||||||
|
border-top: 1px dashed #000;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
.debug-info {
|
||||||
|
color: #999;
|
||||||
|
font-size: 8pt;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 5px;
|
||||||
|
border: 1px dashed #ccc;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${htmlContent}
|
||||||
|
<div class="debug-info">
|
||||||
|
Сгенерировано: ${new Date().toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
fs.writeFileSync(tempHtml, fullHtml, 'utf8');
|
||||||
|
logger.info('[PRINT-HTML] HTML файл создан:', tempHtml);
|
||||||
|
logger.info('[PRINT-HTML] Размер HTML:', fs.statSync(tempHtml).size, 'байт');
|
||||||
|
|
||||||
|
printWindow = new BrowserWindow({
|
||||||
|
width: 600,
|
||||||
|
height: 800,
|
||||||
|
show: true,
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
webSecurity: false,
|
||||||
|
allowRunningInsecureContent: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
printWindow.webContents.openDevTools();
|
||||||
|
|
||||||
|
logger.info('[PRINT-HTML] Загрузка HTML в окно...');
|
||||||
|
await printWindow.loadFile(tempHtml);
|
||||||
|
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
printWindow.webContents.once('did-finish-load', () => {
|
||||||
|
logger.info('[PRINT-HTML] Событие did-finish-load');
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
logger.info('[PRINT-HTML] Таймаут загрузки');
|
||||||
|
resolve();
|
||||||
|
}, 10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentCheck = await printWindow.webContents.executeJavaScript(`
|
||||||
|
(function() {
|
||||||
|
const body = document.body;
|
||||||
|
if (!body) return { hasBody: false, contentLength: 0 };
|
||||||
|
const text = body.textContent || '';
|
||||||
|
const html = body.innerHTML || '';
|
||||||
|
return {
|
||||||
|
hasBody: true,
|
||||||
|
contentLength: text.length,
|
||||||
|
htmlLength: html.length,
|
||||||
|
preview: text.substring(0, 200)
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
`);
|
||||||
|
|
||||||
|
logger.info('[PRINT-HTML] Проверка контента:', contentCheck);
|
||||||
|
|
||||||
|
if (contentCheck.contentLength < 10) {
|
||||||
|
logger.warn('[PRINT-HTML] ⚠️ В окне нет контента!');
|
||||||
|
await printWindow.webContents.executeJavaScript(`
|
||||||
|
if (!document.body || document.body.textContent.length < 10) {
|
||||||
|
document.body.innerHTML = '<div style="padding:20px;font-size:20pt;color:red;">ОШИБКА: Контент не загрузился!</div>';
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await printWindow.webContents.executeJavaScript(`
|
||||||
|
document.body.style.display = 'block';
|
||||||
|
document.body.style.visibility = 'visible';
|
||||||
|
document.body.style.opacity = '1';
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
document.querySelectorAll('*').forEach(el => {
|
||||||
|
el.style.display = 'block';
|
||||||
|
el.style.visibility = 'visible';
|
||||||
|
el.style.opacity = '1';
|
||||||
|
});
|
||||||
|
console.log('Контент в окне:', document.body.textContent.length, 'символов');
|
||||||
|
console.log('HTML:', document.body.innerHTML.substring(0, 500));
|
||||||
|
`);
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
|
||||||
|
const finalCheck = await printWindow.webContents.executeJavaScript(`
|
||||||
|
document.body ? document.body.textContent.length : 0
|
||||||
|
`);
|
||||||
|
logger.info('[PRINT-HTML] Финальная проверка контента:', finalCheck, 'символов');
|
||||||
|
|
||||||
|
logger.info('[PRINT-HTML] Конвертация в PDF...');
|
||||||
|
const pdfData = await printWindow.webContents.printToPDF({
|
||||||
|
pageSize: {
|
||||||
|
width: 226800,
|
||||||
|
height: 500000
|
||||||
|
},
|
||||||
|
margins: {
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0
|
||||||
|
},
|
||||||
|
printBackground: true,
|
||||||
|
landscape: false
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.writeFileSync(tempPdf, pdfData);
|
||||||
|
logger.info('[PRINT-HTML] PDF создан, размер:', pdfData.length, 'байт');
|
||||||
|
|
||||||
|
const pdfBuffer = fs.readFileSync(tempPdf);
|
||||||
|
const isPdf = pdfBuffer.slice(0, 4).toString() === '%PDF';
|
||||||
|
logger.info('[PRINT-HTML] Это PDF?', isPdf);
|
||||||
|
|
||||||
|
if (!isPdf) {
|
||||||
|
logger.error('[PRINT-HTML] ⚠️ Файл НЕ является PDF!');
|
||||||
|
throw new Error('Созданный файл не является PDF');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (printWindow) {
|
||||||
|
printWindow.close();
|
||||||
|
printWindow = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pdfData.length < 500) {
|
||||||
|
logger.warn('[PRINT-HTML] ⚠️ PDF слишком маленький:', pdfData.length, 'байт');
|
||||||
|
logger.info('[PRINT-HTML] Пробуем еще раз с большим ожиданием...');
|
||||||
|
|
||||||
|
const retryWindow = new BrowserWindow({
|
||||||
|
width: 600,
|
||||||
|
height: 800,
|
||||||
|
show: false,
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
webSecurity: false,
|
||||||
|
allowRunningInsecureContent: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await retryWindow.loadFile(tempHtml);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||||
|
|
||||||
|
const retryPdf = await retryWindow.webContents.printToPDF({
|
||||||
|
pageSize: { width: 226800, height: 500000 },
|
||||||
|
margins: { top: 0, bottom: 0, left: 0, right: 0 },
|
||||||
|
printBackground: true,
|
||||||
|
landscape: false
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.writeFileSync(tempPdf, retryPdf);
|
||||||
|
logger.info('[PRINT-HTML] Повторный PDF создан, размер:', retryPdf.length, 'байт');
|
||||||
|
retryWindow.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.copyFileSync(tempPdf, savedPdfPath);
|
||||||
|
logger.info('[PRINT-HTML] ✅ PDF сохранен в:', savedPdfPath);
|
||||||
|
logger.info('[PRINT-HTML] Размер сохраненного PDF:', fs.statSync(savedPdfPath).size, 'байт');
|
||||||
|
|
||||||
|
const printerName = data.printerName || 'GP-80250 Series';
|
||||||
|
logger.info('[PRINT-HTML] Отправка PDF на печать, принтер:', printerName);
|
||||||
|
|
||||||
|
let printResult = null;
|
||||||
|
let printSuccess = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info('[PRINT-HTML] Попытка печати...');
|
||||||
|
printResult = await print(tempPdf, {
|
||||||
|
printer: printerName,
|
||||||
|
scale: 'fit',
|
||||||
|
paperSize: 'A4'
|
||||||
|
});
|
||||||
|
printSuccess = true;
|
||||||
|
logger.info('[PRINT-HTML] Печать успешна:', printResult);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('[PRINT-HTML] Печать не удалась:', err.message);
|
||||||
|
try {
|
||||||
|
printResult = await print(tempPdf);
|
||||||
|
printSuccess = true;
|
||||||
|
logger.info('[PRINT-HTML] Печать успешна (без параметров)');
|
||||||
|
} catch (err2) {
|
||||||
|
logger.error('[PRINT-HTML] Все попытки печати не удались:', err2.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (tempHtml && fs.existsSync(tempHtml)) fs.unlinkSync(tempHtml);
|
||||||
|
if (tempPdf && fs.existsSync(tempPdf)) fs.unlinkSync(tempPdf);
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
logger.info('[PRINT-HTML] ========== ЗАВЕРШЕНО ==========');
|
||||||
|
logger.info('[PRINT-HTML] ✅ PDF сохранен в:', savedPdfPath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
pdfPath: savedPdfPath,
|
||||||
|
printSuccess: printSuccess,
|
||||||
|
message: `PDF сохранен в: ${savedPdfPath}`
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[PRINT-HTML] ❌ Ошибка:', error.message);
|
||||||
|
logger.error('[PRINT-HTML] Стек:', error.stack);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (printWindow) printWindow.close();
|
||||||
|
if (tempHtml && fs.existsSync(tempHtml)) fs.unlinkSync(tempHtml);
|
||||||
|
if (tempPdf && fs.existsSync(tempPdf)) fs.unlinkSync(tempPdf);
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 3. Обработчик print-html-direct (для совместимости)
|
||||||
|
ipcMain.handle('print-html-direct', async (event, data) => {
|
||||||
|
return await printHtmlDirectLogic(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ 4. ОСНОВНОЙ ОБРАБОТЧИК - ГЕНЕРАЦИЯ PDF В ПАМЯТИ (БЕЗ СОХРАНЕНИЯ НА ДИСК)
|
||||||
|
ipcMain.handle('print-payslip-form', async (event, data = {}) => {
|
||||||
|
try {
|
||||||
|
logger.info('[PRINT-PAYSLIP] ========== ГЕНЕРАЦИЯ PDF В ПАМЯТИ ==========');
|
||||||
|
logger.info('[PRINT-PAYSLIP] tnom:', data.tnom);
|
||||||
|
logger.info('[PRINT-PAYSLIP] period:', data.period);
|
||||||
|
|
||||||
|
// --- 1. Получаем данные из бэкенда ---
|
||||||
|
let payslipData = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const backendUrl = appConfig.backendUrl || 'http://localhost:3001';
|
||||||
|
const url = `${backendUrl}/api/payslip/data/${data.tnom}?period=${data.period}`;
|
||||||
|
logger.info('[PRINT-PAYSLIP] Запрос к бэкенду:', url);
|
||||||
|
|
||||||
|
const response = await axios.get(url, { timeout: 10000 });
|
||||||
|
payslipData = response.data;
|
||||||
|
logger.info('[PRINT-PAYSLIP] Данные получены');
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('[PRINT-PAYSLIP] Не удалось получить данные из бэкенда:', error.message);
|
||||||
|
// Заглушка на случай, если бэкенд не отвечает
|
||||||
|
payslipData = {
|
||||||
|
fullName: 'Иванов Иван Иванович',
|
||||||
|
tabNumber: data.tnom || '00001',
|
||||||
|
department: 'Отдел разработки',
|
||||||
|
position: 'Инженер-программист',
|
||||||
|
daysWorked: '22',
|
||||||
|
month: 'Ноябрь',
|
||||||
|
year: '2025',
|
||||||
|
period: data.period || '112025',
|
||||||
|
accrued: [
|
||||||
|
{ code: '01', description: 'Оклад', amount: 2891.87 },
|
||||||
|
{ code: '02', description: 'Премия', amount: 0 }
|
||||||
|
],
|
||||||
|
totalAccrued: 2891.87,
|
||||||
|
deductions: [
|
||||||
|
{ code: '101', description: 'НДФЛ', amount: 376.36 },
|
||||||
|
{ code: '102', description: 'Профсоюз', amount: 85.50 }
|
||||||
|
],
|
||||||
|
totalDeductions: 461.86,
|
||||||
|
totalPaid: 2430.01,
|
||||||
|
bankTransfers: [
|
||||||
|
{ bank: 'Сбербанк', amount: 2430.01 }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. Генерируем PDF в памяти (Buffer) ---
|
||||||
|
const pdfBuffer = await generatePayslipPDF(payslipData);
|
||||||
|
logger.info('[PRINT-PAYSLIP] PDF сгенерирован, размер:', pdfBuffer.length, 'байт');
|
||||||
|
|
||||||
|
if (pdfBuffer.length < 100) {
|
||||||
|
throw new Error('Сгенерированный PDF слишком маленький');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. Преобразуем Buffer в base64 для передачи через IPC ---
|
||||||
|
const pdfBase64 = pdfBuffer.toString('base64');
|
||||||
|
|
||||||
|
logger.info('[PRINT-PAYSLIP] PDF сконвертирован в base64, длина:', pdfBase64.length);
|
||||||
|
|
||||||
|
// --- 4. Возвращаем base64 PDF ---
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
pdfBase64: pdfBase64,
|
||||||
|
fileName: `${data.tnom || 'payslip'}.pdf`
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[PRINT-PAYSLIP] ❌ Ошибка:', error.message);
|
||||||
|
logger.error('[PRINT-PAYSLIP] Стек:', error.stack);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ 5. Обработчик получения списка принтеров
|
||||||
|
ipcMain.handle('get-printers', async () => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'GP-80250 Series',
|
||||||
|
displayName: 'GP-80250 Series',
|
||||||
|
isDefault: true,
|
||||||
|
status: 'ready'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ 6. Обработчик получения конфига
|
||||||
|
ipcMain.handle('get-app-config', async (event) => {
|
||||||
|
return appConfig;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 🚀 СОЗДАНИЕ ГЛАВНОГО ОКНА
|
||||||
|
// ============================================
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
const preloadPath = path.join(__dirname, 'preload.js');
|
const preloadPath = path.join(__dirname, 'preload.js');
|
||||||
try {
|
try {
|
||||||
@@ -65,13 +591,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
|
||||||
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,6 +617,10 @@ function createWindow() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 🚀 ЗАПУСК
|
||||||
|
// ============================================
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
createWindow();
|
createWindow();
|
||||||
global.allowMinimize = false;
|
global.allowMinimize = false;
|
||||||
@@ -113,14 +642,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,64 +666,3 @@ app.on('activate', () => {
|
|||||||
app.on('will-quit', () => {
|
app.on('will-quit', () => {
|
||||||
globalShortcut.unregisterAll();
|
globalShortcut.unregisterAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
//считыватель
|
|
||||||
SerialPort.list().then(ports => {
|
|
||||||
logger.info('Доступные порты:', ports.map(p => p.path));
|
|
||||||
}).catch(err => {
|
|
||||||
logger.error('Ошибка при получении списка портов:', err);
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const port = new SerialPort({ path: COM_PORT, baudRate: parseInt(COM_SPEED, 10)});
|
|
||||||
let buffer = '';
|
|
||||||
port.on('open', () => {
|
|
||||||
logger.info(' Serial port открыт');
|
|
||||||
});
|
|
||||||
port.on('data', (data) => {
|
|
||||||
logger.info('RAW DATA:', data);
|
|
||||||
buffer += data.toString('utf8');
|
|
||||||
if (buffer.includes('\n')) {
|
|
||||||
const lines = buffer.split('\n');
|
|
||||||
lines.forEach((line, idx) => {
|
|
||||||
if (idx < lines.length - 1) {
|
|
||||||
const uid = line.trim().replace(/\D/g, '');
|
|
||||||
if (uid) {
|
|
||||||
console.log(`UID карты: ${uid}`);
|
|
||||||
console.log('[DEBUG] Отправляю событие на admin-server:', {
|
|
||||||
event: 'CARD_PLACED',
|
|
||||||
uid: uid,
|
|
||||||
timestamp: Date.now()
|
|
||||||
});
|
|
||||||
axios.post(`${config.adminUrl}}/api/card-event`, {
|
|
||||||
event: 'CARD_PLACED',
|
|
||||||
uid: uid,
|
|
||||||
timestamp: Date.now()
|
|
||||||
}).catch(err => {
|
|
||||||
console.error('[ADMIN][CARD_LOG] Ошибка отправки:', err.message);
|
|
||||||
});
|
|
||||||
if (mainWindow && mainWindow.webContents) {
|
|
||||||
mainWindow.webContents.send('card-detected', uid);
|
|
||||||
console.log(` Отправка события 'card-detected' в рендерер с UID: ${uid}`);
|
|
||||||
} else {
|
|
||||||
console.warn('mainWindow или webContents не определены, событие не отправлено');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
buffer = lines[lines.length - 1];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
port.on('error', (err) => {
|
|
||||||
console.error('Ошибка serialport:', err);
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('!!! FATAL: FAILED TO INITIALIZE SERIALPORT !!!', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
ipcMain.handle('get-app-config', async (event) => {
|
|
||||||
console.log('[IPC] Renderer requested app config. Sending:', appConfig);
|
|
||||||
return appConfig;
|
|
||||||
});
|
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// pdfGenerator.js - АЛЬТЕРНАТИВНАЯ ВЕРСИЯ (для pdfmake 0.1.72)
|
||||||
|
const pdfmake = require('pdfmake');
|
||||||
|
|
||||||
|
// Для версии 0.1.72 используется другой способ
|
||||||
|
const fonts = {
|
||||||
|
Roboto: {
|
||||||
|
normal: 'C:/Windows/Fonts/arial.ttf',
|
||||||
|
bold: 'C:/Windows/Fonts/arialbd.ttf',
|
||||||
|
italics: 'C:/Windows/Fonts/ariali.ttf',
|
||||||
|
bolditalics: 'C:/Windows/Fonts/arialbi.ttf'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Для старой версии pdfmake
|
||||||
|
const printer = new pdfmake(fonts);
|
||||||
|
|
||||||
|
function buildDocDefinition(data) {
|
||||||
|
return {
|
||||||
|
pageSize: { width: 80 * 2.83465, height: 297 * 2.83465 },
|
||||||
|
pageMargins: [5, 5, 5, 5],
|
||||||
|
content: [
|
||||||
|
{ text: 'РАСЧЕТНЫЙ ЛИСТОК', style: 'header' },
|
||||||
|
{ text: `${data.month || ''} ${data.year || ''} (${data.period || ''})`, style: 'subheader' },
|
||||||
|
{ text: ' ', style: 'divider' },
|
||||||
|
|
||||||
|
{ text: `ФИО: ${data.fullName || ''}`, style: 'row' },
|
||||||
|
{ text: `Таб.№: ${data.tabNumber || ''}`, style: 'row' },
|
||||||
|
{ text: `Подразделение: ${data.department || ''}`, style: 'row' },
|
||||||
|
{ text: `Должность: ${data.position || ''}`, style: 'row' },
|
||||||
|
{ text: `Отработано: ${data.daysWorked || ''}`, style: 'row' },
|
||||||
|
{ text: ' ', style: 'divider' },
|
||||||
|
|
||||||
|
{ text: 'НАЧИСЛЕНО', style: 'sectionTitle' },
|
||||||
|
...(data.accrued || []).map(item => ({
|
||||||
|
columns: [
|
||||||
|
{ text: `${item.code || ''} ${item.description || ''}`, width: '*', style: 'itemDesc' },
|
||||||
|
{ text: (item.amount || 0).toFixed(2), alignment: 'right', style: 'itemAmount' }
|
||||||
|
],
|
||||||
|
style: 'itemRow'
|
||||||
|
})),
|
||||||
|
{ text: `ИТОГО НАЧИСЛЕНО: ${(data.totalAccrued || 0).toFixed(2)}`, style: 'totalRow' },
|
||||||
|
{ text: ' ', style: 'divider' },
|
||||||
|
|
||||||
|
{ text: 'УДЕРЖАНО', style: 'sectionTitle' },
|
||||||
|
...(data.deductions || []).map(item => ({
|
||||||
|
columns: [
|
||||||
|
{ text: `${item.code || ''} ${item.description || ''}`, width: '*', style: 'itemDesc' },
|
||||||
|
{ text: (item.amount || 0).toFixed(2), alignment: 'right', style: 'itemAmount' }
|
||||||
|
],
|
||||||
|
style: 'itemRow'
|
||||||
|
})),
|
||||||
|
{ text: `ИТОГО УДЕРЖАНО: ${(data.totalDeductions || 0).toFixed(2)}`, style: 'totalRow' },
|
||||||
|
{ text: ' ', style: 'divider' },
|
||||||
|
|
||||||
|
{ text: `К ВЫПЛАТЕ: ${(data.totalPaid || 0).toFixed(2)}`, style: 'totalRow big' },
|
||||||
|
{ text: ' ', style: 'divider' },
|
||||||
|
|
||||||
|
...(data.bankTransfers && data.bankTransfers.length > 0 ? [
|
||||||
|
{ text: 'БАНКОВСКИЕ ПЕРЕВОДЫ', style: 'sectionTitle' },
|
||||||
|
...data.bankTransfers.map(bank => ({
|
||||||
|
columns: [
|
||||||
|
{ text: bank.bank || '', width: '*', style: 'itemDesc' },
|
||||||
|
{ text: (bank.amount || 0).toFixed(2), alignment: 'right', style: 'itemAmount' }
|
||||||
|
],
|
||||||
|
style: 'itemRow'
|
||||||
|
}))
|
||||||
|
] : []),
|
||||||
|
|
||||||
|
...(data.other && data.other.length > 0 ? [
|
||||||
|
{ text: 'ПРОЧЕЕ', style: 'sectionTitle' },
|
||||||
|
...data.other.map(item => ({
|
||||||
|
columns: [
|
||||||
|
{ text: `${item.code || ''} ${item.description || ''}`, width: '*', style: 'itemDesc' },
|
||||||
|
{ text: (item.amount || 0).toFixed(2), alignment: 'right', style: 'itemAmount' }
|
||||||
|
],
|
||||||
|
style: 'itemRow'
|
||||||
|
})),
|
||||||
|
{ text: `ИТОГО ПРОЧЕЕ: ${(data.totalOther || 0).toFixed(2)}`, style: 'totalRow' }
|
||||||
|
] : []),
|
||||||
|
|
||||||
|
{ text: ' ', style: 'divider' },
|
||||||
|
{ text: `Сформировано: ${new Date().toLocaleString('ru-RU')}`, style: 'footer' }
|
||||||
|
],
|
||||||
|
styles: {
|
||||||
|
header: { fontSize: 14, bold: true, alignment: 'center', margin: [0, 0, 0, 4] },
|
||||||
|
subheader: { fontSize: 12, alignment: 'center', margin: [0, 0, 0, 4] },
|
||||||
|
row: { fontSize: 10, margin: [0, 2, 0, 2] },
|
||||||
|
divider: { margin: [0, 4, 0, 4], border: [false, true, false, false] },
|
||||||
|
sectionTitle: { fontSize: 12, bold: true, margin: [0, 6, 0, 2] },
|
||||||
|
itemRow: { fontSize: 10, margin: [0, 1, 0, 1] },
|
||||||
|
itemDesc: { fontSize: 10 },
|
||||||
|
itemAmount: { fontSize: 10, alignment: 'right' },
|
||||||
|
totalRow: { fontSize: 12, bold: true, margin: [0, 4, 0, 0] },
|
||||||
|
'totalRow big': { fontSize: 14, bold: true, margin: [0, 8, 0, 0] },
|
||||||
|
footer: { fontSize: 8, alignment: 'center', color: '#666', margin: [0, 8, 0, 0] }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatePayslipPDF(data) {
|
||||||
|
const docDefinition = buildDocDefinition(data);
|
||||||
|
const pdfDoc = printer.createPdfKitDocument(docDefinition);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks = [];
|
||||||
|
pdfDoc.on('data', chunk => chunks.push(chunk));
|
||||||
|
pdfDoc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
pdfDoc.on('error', reject);
|
||||||
|
pdfDoc.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { generatePayslipPDF };
|
||||||
@@ -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,15 @@ 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'),
|
||||||
|
// ✅ НОВАЯ ФУНКЦИЯ: печать формы расчетного листка
|
||||||
|
printPayslipForm: (data) => ipcRenderer.invoke('print-payslip-form', data),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.log('[Preload] Script finished. electronAPI exposed.');
|
console.log('[Preload] Script finished. electronAPI exposed.');
|
||||||
window.dispatchEvent(new Event('electronApiReady'));
|
window.dispatchEvent(new Event('electronApiReady'));
|
||||||
@@ -1,30 +1,111 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import './styles/main.css';
|
import './styles/main.css';
|
||||||
|
import './styles/animations.css';
|
||||||
import LoginWindow from './components/LoginWindow';
|
import LoginWindow from './components/LoginWindow';
|
||||||
import EmployeeInfo from './components/EmployeeInfo';
|
|
||||||
import GuestMode from './components/GuestMode';
|
import GuestMode from './components/GuestMode';
|
||||||
|
import MainMenu from './components/MenuComponent';
|
||||||
|
import VideoBackground from './components/VideoBackground';
|
||||||
|
import WeatherWidget from './components/WeatherWidget';
|
||||||
|
import BottomBar from './components/BottomBar';
|
||||||
import PayslipViewer from './components/PayslipViewer';
|
import PayslipViewer from './components/PayslipViewer';
|
||||||
|
import EmployeeInfo from './components/EmployeeInfo';
|
||||||
|
import { useActivityTimer } from './components/useActivityTimer';
|
||||||
import config from './config.json';
|
import config from './config.json';
|
||||||
|
|
||||||
|
// Импорты разделов
|
||||||
|
import Finance from './components/Finance';
|
||||||
|
import Medicine from './components/Medicine';
|
||||||
|
import TradeUnion from './components/TradeUnion';
|
||||||
|
import LaborSafety from './components/LaborSafety';
|
||||||
|
import Contacts from './components/Contacts';
|
||||||
|
import News from './components/News';
|
||||||
|
import Profile from './components/Profile';
|
||||||
|
import FoodMenu from './components/FoodMenu';
|
||||||
|
|
||||||
interface Employee {
|
interface Employee {
|
||||||
id: string;
|
id: string;
|
||||||
tnom: string;
|
tnom: string;
|
||||||
famaly: string;
|
famaly: string;
|
||||||
ima: string;
|
ima: string;
|
||||||
otch: string;
|
otch: string;
|
||||||
category: string;
|
category?: string;
|
||||||
organization: string;
|
organization: string;
|
||||||
identNumber: string;
|
identNumber?: string;
|
||||||
|
departmentName?: string;
|
||||||
|
profession?: string;
|
||||||
|
cardNumber?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
// 📊 Состояния приложения
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
const [isGuestMode, setIsGuestMode] = useState(false);
|
const [isGuestMode, setIsGuestMode] = useState(false);
|
||||||
const [employee, setEmployee] = useState<Employee | null>(null);
|
const [employee, setEmployee] = useState<Employee | null>(null);
|
||||||
|
const [loginError, setLoginError] = useState('');
|
||||||
|
const [showVideo, setShowVideo] = useState(true);
|
||||||
|
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 [loginError, setLoginError] = useState('');
|
const [payslipFileUrl, setPayslipFileUrl] = useState<string>('');
|
||||||
|
|
||||||
|
// 📊 Состояния для таймера
|
||||||
|
const [showInactiveWarning, setShowInactiveWarning] = useState(false);
|
||||||
|
|
||||||
|
// 📌 Реф для поля ввода логина
|
||||||
|
const loginInputRef = useRef<HTMLInputElement>(null!);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 🛑 Функция ПОЛНОГО сброса приложения (как при запуске)
|
||||||
|
// ============================================
|
||||||
|
const resetToInitialState = () => {
|
||||||
|
console.log('🔄 ПОЛНЫЙ СБРОС ПРИЛОЖЕНИЯ — как при запуске');
|
||||||
|
|
||||||
|
setIsLoggedIn(false);
|
||||||
|
setIsGuestMode(false);
|
||||||
|
setEmployee(null);
|
||||||
|
setCurrentSection(null);
|
||||||
|
setShowPayslip(false);
|
||||||
|
setSelectedPeriod('');
|
||||||
|
setPayslipFileUrl('');
|
||||||
|
setShowInactiveWarning(false);
|
||||||
|
setLoginError('');
|
||||||
|
|
||||||
|
setShowVideo(true);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (loginInputRef.current) {
|
||||||
|
loginInputRef.current.focus();
|
||||||
|
console.log('✅ Фокус на поле ввода логина');
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 🕐 Таймер неактивности
|
||||||
|
// ============================================
|
||||||
|
const { isWarning, timeLeft, resetTimer } = useActivityTimer({
|
||||||
|
timeout: 30000,
|
||||||
|
warningTimeout: 10000,
|
||||||
|
onInactive: () => {
|
||||||
|
console.log('🛑 Пользователь неактивен! ПОЛНЫЙ СБРОС ПРИЛОЖЕНИЯ');
|
||||||
|
resetToInitialState();
|
||||||
|
},
|
||||||
|
onWarning: () => {
|
||||||
|
console.log('⚠️ Предупреждение о неактивности!');
|
||||||
|
setShowInactiveWarning(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleUserActivity = () => {
|
||||||
|
if (showInactiveWarning) {
|
||||||
|
setShowInactiveWarning(false);
|
||||||
|
}
|
||||||
|
resetTimer();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 🔐 Вход в систему
|
||||||
|
// ============================================
|
||||||
const handleLogin = async (id: string) => {
|
const handleLogin = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
const isCardUid = /^\d{7}$/.test(id);
|
const isCardUid = /^\d{7}$/.test(id);
|
||||||
@@ -37,6 +118,7 @@ function App() {
|
|||||||
setEmployee(data);
|
setEmployee(data);
|
||||||
setIsLoggedIn(true);
|
setIsLoggedIn(true);
|
||||||
setLoginError('');
|
setLoginError('');
|
||||||
|
handleUserActivity();
|
||||||
} else {
|
} else {
|
||||||
setLoginError('Сотрудник не найден');
|
setLoginError('Сотрудник не найден');
|
||||||
}
|
}
|
||||||
@@ -48,50 +130,230 @@ function App() {
|
|||||||
|
|
||||||
const handleGuestMode = () => {
|
const handleGuestMode = () => {
|
||||||
setIsGuestMode(true);
|
setIsGuestMode(true);
|
||||||
|
handleUserActivity();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
setIsLoggedIn(false);
|
resetToInitialState();
|
||||||
setEmployee(null);
|
|
||||||
setIsGuestMode(false);
|
|
||||||
setShowPayslip(false);
|
|
||||||
setSelectedPeriod('');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleViewPayslip = (period: string) => {
|
const handleBackToMenu = () => {
|
||||||
setSelectedPeriod(period);
|
setCurrentSection(null);
|
||||||
|
setShowPayslip(false);
|
||||||
|
setSelectedPeriod('');
|
||||||
|
setPayslipFileUrl('');
|
||||||
|
handleUserActivity();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSectionSelect = (section: string) => {
|
||||||
|
setCurrentSection(section);
|
||||||
|
handleUserActivity();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewPayslip = (fileUrl: string) => {
|
||||||
|
setPayslipFileUrl(fileUrl);
|
||||||
setShowPayslip(true);
|
setShowPayslip(true);
|
||||||
|
handleUserActivity();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePayslipBack = () => {
|
const handlePayslipBack = () => {
|
||||||
setShowPayslip(false);
|
setShowPayslip(false);
|
||||||
setSelectedPeriod('');
|
setSelectedPeriod('');
|
||||||
|
setPayslipFileUrl('');
|
||||||
|
handleUserActivity();
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPayslipUrl = (employee: Employee, period: string) => {
|
// ============================================
|
||||||
const fileName = `${employee.tnom}.pdf`;
|
// ⚠️ Предупреждение о неактивности
|
||||||
|
// ============================================
|
||||||
|
const renderWarning = () => {
|
||||||
|
if (!showInactiveWarning) return null;
|
||||||
|
|
||||||
return `${config.backendUrl}/api/payslip/${period}/${fileName}`;
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: 'rgba(0, 0, 0, 0.6)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 9999,
|
||||||
|
backdropFilter: 'blur(5px)',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(255, 193, 7, 0.98)',
|
||||||
|
padding: '35px 45px',
|
||||||
|
borderRadius: '20px',
|
||||||
|
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||||
|
textAlign: 'center',
|
||||||
|
maxWidth: '400px',
|
||||||
|
animation: 'pulseWarning 1s ease-in-out infinite',
|
||||||
|
border: '3px solid #ff6b6b',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '48px', marginBottom: '10px' }}>⚠️</div>
|
||||||
|
<h3 style={{ margin: '0 0 10px 0', color: '#2d3436', fontSize: '22px' }}>
|
||||||
|
Сессия истекает!
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: '0 0 5px 0', color: '#636e72', fontSize: '14px' }}>
|
||||||
|
Вы были неактивны. Приложение будет сброшено через:
|
||||||
|
</p>
|
||||||
|
<p style={{
|
||||||
|
margin: '10px 0 20px 0',
|
||||||
|
fontSize: '48px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#d32f2f',
|
||||||
|
animation: 'blinkText 0.5s ease-in-out infinite'
|
||||||
|
}}>
|
||||||
|
{Math.ceil(timeLeft / 1000)}с
|
||||||
|
</p>
|
||||||
|
<p style={{
|
||||||
|
color: '#636e72',
|
||||||
|
fontSize: '13px',
|
||||||
|
margin: '0 0 15px 0',
|
||||||
|
opacity: 0.7
|
||||||
|
}}>
|
||||||
|
👆 Нажмите или начните двигать мышью для отмены
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowInactiveWarning(false);
|
||||||
|
handleUserActivity();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '14px 45px',
|
||||||
|
background: '#28a745',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '12px',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '18px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
boxShadow: '0 4px 15px rgba(40,167,69,0.3)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = '#218838';
|
||||||
|
e.currentTarget.style.transform = 'scale(1.05)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = '#28a745';
|
||||||
|
e.currentTarget.style.transform = 'scale(1)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Продолжить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
// ============================================
|
||||||
<div className="app">
|
// 📄 Рендер контента
|
||||||
{!isLoggedIn && !isGuestMode ? (
|
// ============================================
|
||||||
<LoginWindow onLogin={handleLogin} onGuestMode={handleGuestMode} loginError={loginError} />
|
const renderContent = () => {
|
||||||
) : isGuestMode ? (
|
// 🎬 Видео-заставка
|
||||||
<GuestMode onBack={handleBack} />
|
if (showVideo) {
|
||||||
) : showPayslip ? (
|
return <VideoBackground onComplete={() => setShowVideo(false)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔐 Экран логина
|
||||||
|
if (!isLoggedIn && !isGuestMode) {
|
||||||
|
return (
|
||||||
|
<LoginWindow
|
||||||
|
onLogin={handleLogin}
|
||||||
|
onGuestMode={handleGuestMode}
|
||||||
|
loginError={loginError}
|
||||||
|
inputRef={loginInputRef}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 👤 Режим гостя
|
||||||
|
if (isGuestMode) {
|
||||||
|
return <GuestMode onBack={handleBack} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 📄 Просмотр расчетного листка
|
||||||
|
if (showPayslip) {
|
||||||
|
return (
|
||||||
<PayslipViewer
|
<PayslipViewer
|
||||||
fileUrl={employee && selectedPeriod ? getPayslipUrl(employee, selectedPeriod) : "/payslips/test.pdf"}
|
fileUrl={payslipFileUrl || "/payslips/test.pdf"}
|
||||||
onBack={handlePayslipBack}
|
onBack={handlePayslipBack}
|
||||||
/>
|
/>
|
||||||
) : employee ? (
|
);
|
||||||
<EmployeeInfo
|
}
|
||||||
employee={employee}
|
|
||||||
onViewPayslip={handleViewPayslip}
|
// 📂 Разделы приложения
|
||||||
onBack={handleBack}
|
if (currentSection) {
|
||||||
/>
|
switch (currentSection) {
|
||||||
) : null}
|
case 'employee-info':
|
||||||
|
return <EmployeeInfo employee={employee!} onViewPayslip={handleViewPayslip} onBack={handleBackToMenu} />;
|
||||||
|
case 'finance':
|
||||||
|
return <Finance onBack={handleBackToMenu} onViewPayslip={handleViewPayslip} employee={employee!} />;
|
||||||
|
case 'medicine':
|
||||||
|
return <Medicine onBack={handleBackToMenu} employee={employee!} />;
|
||||||
|
case 'trade-union':
|
||||||
|
return <TradeUnion onBack={handleBackToMenu} employee={employee!} />;
|
||||||
|
case 'labor-safety':
|
||||||
|
return <LaborSafety onBack={handleBackToMenu} employee={employee!} />;
|
||||||
|
case 'contacts':
|
||||||
|
return <Contacts onBack={handleBackToMenu} />;
|
||||||
|
case 'news':
|
||||||
|
return <News onBack={handleBackToMenu} />;
|
||||||
|
case 'profile':
|
||||||
|
return <Profile onBack={handleBackToMenu} employee={employee!} />;
|
||||||
|
case 'food':
|
||||||
|
return <FoodMenu onBack={() => setCurrentSection('main')} employee={employee} />;
|
||||||
|
default:
|
||||||
|
return <MainMenu onSectionSelect={handleSectionSelect} employee={employee!} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <MainMenu onSectionSelect={handleSectionSelect} employee={employee!} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 🚀 Рендер приложения
|
||||||
|
// ============================================
|
||||||
|
const showTopBar = !showVideo && !showPayslip;
|
||||||
|
const topBarHeight = 70;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="app"
|
||||||
|
onClick={handleUserActivity}
|
||||||
|
onKeyDown={handleUserActivity}
|
||||||
|
onMouseMove={handleUserActivity}
|
||||||
|
onTouchStart={handleUserActivity}
|
||||||
|
onScroll={handleUserActivity}
|
||||||
|
onWheel={handleUserActivity}
|
||||||
|
>
|
||||||
|
{/* ⚠️ Предупреждение о неактивности */}
|
||||||
|
{renderWarning()}
|
||||||
|
|
||||||
|
{/* 🌤️ ВЕРХНИЙ ВИДЖЕТ: ПОГОДА + ВРЕМЯ + ПРИВЕТСТВИЕ + ВЫХОД */}
|
||||||
|
{showTopBar && (
|
||||||
|
<WeatherWidget onLogout={handleBack} employee={employee || undefined} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 📄 Основной контент с отступом сверху */}
|
||||||
|
<div style={{
|
||||||
|
paddingTop: showTopBar ? topBarHeight : 0,
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{renderContent()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 📊 НИЖНЯЯ ПАНЕЛЬ: ТОЛЬКО КУРСЫ ВАЛЮТ (БЕЗ ПРИВЕТСТВИЯ) */}
|
||||||
|
{showTopBar && (
|
||||||
|
<BottomBar />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,393 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
interface CurrencyRate {
|
||||||
|
currency: string;
|
||||||
|
rate: number;
|
||||||
|
change: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetalRate {
|
||||||
|
metal: string;
|
||||||
|
price: number;
|
||||||
|
change: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BottomBar: React.FC = () => {
|
||||||
|
const [currencyRates, setCurrencyRates] = useState<CurrencyRate[]>([
|
||||||
|
{ currency: 'USD', rate: 3.22, change: 0.5 },
|
||||||
|
{ currency: 'EUR', rate: 3.45, change: -0.2 },
|
||||||
|
{ currency: 'CNY', rate: 0.44, change: 0.1 },
|
||||||
|
{ currency: 'RUB', rate: 0.035, change: -0.3 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [metalRates, setMetalRates] = useState<MetalRate[]>([
|
||||||
|
{ metal: 'Золото', price: 1950, change: 0.8 },
|
||||||
|
{ metal: 'Серебро', price: 23.5, change: -0.5 },
|
||||||
|
{ metal: 'Платина', price: 920, change: 0.3 },
|
||||||
|
{ metal: 'Палладий', price: 1450, change: -0.7 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [isOnline, setIsOnline] = useState(navigator.onLine);
|
||||||
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [dataSource, setDataSource] = useState<'mysql' | 'excel' | 'default'>('default');
|
||||||
|
const [lastUpdate, setLastUpdate] = useState<string>('');
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const BACKEND_URL = 'http://localhost:3001';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 📊 ЗАГРУЗКА КУРСОВ
|
||||||
|
// ============================================
|
||||||
|
useEffect(() => {
|
||||||
|
const loadRates = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// ✅ Запрашиваем единый эндпоинт, который уже есть на бэкенде
|
||||||
|
const response = await fetch(`${BACKEND_URL}/api/rates`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.currency && data.currency.length > 0) {
|
||||||
|
setCurrencyRates(data.currency);
|
||||||
|
setMetalRates(data.metals || []);
|
||||||
|
setDataSource(data.source || 'default');
|
||||||
|
setLastUpdate(new Date().toLocaleString('ru-RU'));
|
||||||
|
|
||||||
|
// Сохраняем в localStorage для оффлайн-режима
|
||||||
|
localStorage.setItem('ratesData', JSON.stringify({
|
||||||
|
currency: data.currency,
|
||||||
|
metals: data.metals,
|
||||||
|
source: data.source,
|
||||||
|
lastUpdate: new Date().toISOString()
|
||||||
|
}));
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если не удалось — берём из localStorage
|
||||||
|
const stored = localStorage.getItem('ratesData');
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(stored);
|
||||||
|
if (data.currency) {
|
||||||
|
setCurrencyRates(data.currency);
|
||||||
|
setMetalRates(data.metals || []);
|
||||||
|
setDataSource(data.source || 'default');
|
||||||
|
if (data.lastUpdate) {
|
||||||
|
setLastUpdate(new Date(data.lastUpdate).toLocaleString('ru-RU'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Ошибка загрузки из localStorage:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Ошибка загрузки курсов:', error);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadRates();
|
||||||
|
const interval = setInterval(loadRates, 60000); // Обновление каждую минуту
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 🌐 СТАТУС ИНТЕРНЕТА
|
||||||
|
// ============================================
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOnline = () => setIsOnline(true);
|
||||||
|
const handleOffline = () => setIsOnline(false);
|
||||||
|
window.addEventListener('online', handleOnline);
|
||||||
|
window.addEventListener('offline', handleOffline);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', handleOnline);
|
||||||
|
window.removeEventListener('offline', handleOffline);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 🎮 УПРАВЛЕНИЕ ПАУЗОЙ
|
||||||
|
// ============================================
|
||||||
|
const handleMouseEnter = () => setIsPaused(true);
|
||||||
|
const handleMouseLeave = () => setIsPaused(false);
|
||||||
|
const handleTouchStart = () => setIsPaused(true);
|
||||||
|
const handleTouchEnd = () => setIsPaused(false);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 📦 ВСЕ ЭЛЕМЕНТЫ ДЛЯ БЕГУЩЕЙ СТРОКИ
|
||||||
|
// ============================================
|
||||||
|
const getAllItems = () => {
|
||||||
|
const currencyItems = currencyRates.map(item => ({
|
||||||
|
id: `currency-${item.currency}`,
|
||||||
|
type: 'currency' as const,
|
||||||
|
label: item.currency,
|
||||||
|
value: item.rate,
|
||||||
|
change: item.change,
|
||||||
|
displayValue: item.rate.toFixed(item.rate < 1 ? 3 : 2),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const metalItems = metalRates.map(item => ({
|
||||||
|
id: `metal-${item.metal}`,
|
||||||
|
type: 'metal' as const,
|
||||||
|
label: item.metal,
|
||||||
|
value: item.price,
|
||||||
|
change: item.change,
|
||||||
|
displayValue: item.price.toFixed(item.price < 10 ? 2 : 0),
|
||||||
|
currencySymbol: 'Br',
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [...currencyItems, ...metalItems];
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = getAllItems();
|
||||||
|
const doubledItems = [...items, ...items, ...items];
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 🔄 ИНДИКАТОР ЗАГРУЗКИ
|
||||||
|
// ============================================
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 5,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
background: 'rgba(44, 62, 80, 0.95)',
|
||||||
|
color: 'rgba(255,255,255,0.5)',
|
||||||
|
padding: '10px 20px',
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: '12px',
|
||||||
|
zIndex: 1000,
|
||||||
|
borderTop: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
height: '50px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
⏳ Загрузка курсов...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 🎨 ОСНОВНОЙ РЕНДЕР
|
||||||
|
// ============================================
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="bottom-bar"
|
||||||
|
initial={{ y: 100 }}
|
||||||
|
animate={{ y: 0 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 5,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
background: 'rgba(44, 62, 80, 0.95)',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
color: '#fff',
|
||||||
|
padding: '0 20px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '16px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
zIndex: 1000,
|
||||||
|
height: '50px',
|
||||||
|
borderTop: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
fontSize: '18px',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
}}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
>
|
||||||
|
{/* Статус и источник данных */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
flexShrink: 0,
|
||||||
|
zIndex: 2,
|
||||||
|
background: 'rgba(44, 62, 80, 0.95)',
|
||||||
|
paddingRight: '12px',
|
||||||
|
height: '100%',
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
color: isOnline ? '#2ecc71' : '#e74c3c',
|
||||||
|
fontSize: '10px',
|
||||||
|
}}>
|
||||||
|
{isOnline ? '●' : '○'}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontWeight: 600, opacity: 0.6, fontSize: '10px' }}>КУРСЫ</span>
|
||||||
|
|
||||||
|
{/* ✅ Индикатор источника данных (без кнопки) */}
|
||||||
|
<span style={{
|
||||||
|
padding: '2px 8px',
|
||||||
|
background: dataSource === 'mysql' ? 'rgba(46, 204, 113, 0.2)' :
|
||||||
|
dataSource === 'excel' ? 'rgba(52, 152, 219, 0.2)' :
|
||||||
|
'rgba(255, 193, 7, 0.2)',
|
||||||
|
color: dataSource === 'mysql' ? '#2ecc71' :
|
||||||
|
dataSource === 'excel' ? '#3498db' :
|
||||||
|
'#f39c12',
|
||||||
|
border: '1px solid ' + (dataSource === 'mysql' ? 'rgba(46,204,113,0.3)' :
|
||||||
|
dataSource === 'excel' ? 'rgba(52,152,219,0.3)' :
|
||||||
|
'rgba(255,193,7,0.3)'),
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '8px',
|
||||||
|
fontWeight: 600,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
{dataSource === 'mysql' ? 'MySQL' :
|
||||||
|
dataSource === 'excel' ? '📊' :
|
||||||
|
'💾'}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
width: '1px',
|
||||||
|
height: '20px',
|
||||||
|
background: 'rgba(255,255,255,0.15)',
|
||||||
|
marginLeft: '4px',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Бегущая строка */}
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
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: isPaused ? 0 : ['0%', '-33.33%'],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 35,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: 'linear',
|
||||||
|
repeatType: 'loop',
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '30px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
willChange: 'transform',
|
||||||
|
transform: 'translateZ(0)',
|
||||||
|
backfaceVisibility: 'hidden',
|
||||||
|
WebkitFontSmoothing: 'antialiased',
|
||||||
|
MozOsxFontSmoothing: 'grayscale',
|
||||||
|
textRendering: 'geometricPrecision',
|
||||||
|
paddingRight: '30px',
|
||||||
|
height: '100%',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{doubledItems.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={`${item.id}-${index}`}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '6px',
|
||||||
|
alignItems: 'center',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'rgba(255,255,255,0.9)',
|
||||||
|
fontSize: '12px',
|
||||||
|
letterSpacing: '0.3px',
|
||||||
|
}}>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
color: 'rgba(255,255,255,0.8)',
|
||||||
|
fontSize: '12px',
|
||||||
|
letterSpacing: '0.2px',
|
||||||
|
}}>
|
||||||
|
{item.displayValue}
|
||||||
|
</span>
|
||||||
|
{item.type === 'metal' && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: '8px',
|
||||||
|
opacity: 0.3,
|
||||||
|
marginLeft: '-2px',
|
||||||
|
}}>
|
||||||
|
{item.currencySymbol || 'Br'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span style={{
|
||||||
|
color: item.change >= 0 ? '#2ecc71' : '#e74c3c',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 500,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '2px',
|
||||||
|
letterSpacing: '0.2px',
|
||||||
|
}}>
|
||||||
|
{item.change >= 0 ? '▲' : '▼'}
|
||||||
|
{Math.abs(item.change).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Время обновления и источник */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
flexShrink: 0,
|
||||||
|
paddingLeft: '8px',
|
||||||
|
}}>
|
||||||
|
{lastUpdate && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: '8px',
|
||||||
|
opacity: 0.3,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
{lastUpdate}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Пауза */}
|
||||||
|
{isPaused && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
fontSize: '10px',
|
||||||
|
color: 'rgba(255,255,255,0.3)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 3,
|
||||||
|
background: 'rgba(0,0,0,0.5)',
|
||||||
|
padding: '2px 12px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
backdropFilter: 'blur(5px)',
|
||||||
|
}}>
|
||||||
|
⏸ Пауза
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BottomBar;
|
||||||
@@ -0,0 +1,823 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useCallback, memo } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { FaArrowLeft, FaSearch, FaArrowUp, FaArrowDown } from 'react-icons/fa';
|
||||||
|
import MapViewer from './MapViewer';
|
||||||
|
import config from '../config.json';
|
||||||
|
|
||||||
|
interface ContactsProps {
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 🔧 Парсер телефонов из HTML
|
||||||
|
// ============================================
|
||||||
|
function parseHandbookBlocks(html: string) {
|
||||||
|
const blocks: (string | { type: 'phones', rows: { desc: string, internal: string, city: string }[] })[] = [];
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.innerHTML = html;
|
||||||
|
let phoneRows: { desc: string, internal: string, city: string }[] = [];
|
||||||
|
let lastDesc = '';
|
||||||
|
const phoneRegex = /(\d{2,3}-\d{2,3}(?:-\d{2,3})?|\d{4}\s\d{6,7})/g;
|
||||||
|
const children = Array.from(container.childNodes);
|
||||||
|
let htmlBuffer = '';
|
||||||
|
const flushHtmlBuffer = () => {
|
||||||
|
if (htmlBuffer) {
|
||||||
|
blocks.push(htmlBuffer);
|
||||||
|
htmlBuffer = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const flushPhoneRows = () => {
|
||||||
|
if (phoneRows.length > 1) {
|
||||||
|
blocks.push({ type: 'phones', rows: phoneRows });
|
||||||
|
} else if (phoneRows.length === 1) {
|
||||||
|
const row = phoneRows[0];
|
||||||
|
blocks.push(`<p>${row.desc} ${row.internal}${row.city ? ' ' + row.city : ''}</p>`);
|
||||||
|
}
|
||||||
|
phoneRows = [];
|
||||||
|
};
|
||||||
|
children.forEach(node => {
|
||||||
|
if (node.nodeType === 1) {
|
||||||
|
const tag = (node as HTMLElement).tagName.toLowerCase();
|
||||||
|
const text = (node as HTMLElement).textContent?.trim() || '';
|
||||||
|
const phones = Array.from(text.matchAll(phoneRegex), m => m[1]);
|
||||||
|
if ((tag === 'p' || tag === 'li') && phones.length >= 1) {
|
||||||
|
flushHtmlBuffer();
|
||||||
|
if (phones.length === 2) {
|
||||||
|
const desc = text.replace(phoneRegex, '').trim();
|
||||||
|
phoneRows.push({ desc, internal: phones[0], city: phones[1] });
|
||||||
|
lastDesc = desc;
|
||||||
|
} else if (phones.length === 1) {
|
||||||
|
phoneRows.push({ desc: lastDesc, internal: phones[0], city: '' });
|
||||||
|
} else if (text && !phones.length) {
|
||||||
|
lastDesc = text;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (phoneRows.length) {
|
||||||
|
flushPhoneRows();
|
||||||
|
}
|
||||||
|
if ((node as HTMLElement).outerHTML) {
|
||||||
|
htmlBuffer += (node as HTMLElement).outerHTML;
|
||||||
|
} else {
|
||||||
|
htmlBuffer += node.textContent || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (node.nodeType === 3) {
|
||||||
|
htmlBuffer += node.textContent;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (phoneRows.length) {
|
||||||
|
flushPhoneRows();
|
||||||
|
}
|
||||||
|
if (htmlBuffer) {
|
||||||
|
blocks.push(htmlBuffer);
|
||||||
|
}
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 📚 Функции рендеринга
|
||||||
|
// ============================================
|
||||||
|
const ADMIN_SERVER_URL = config.adminServerUrl || 'http://localhost:3005';
|
||||||
|
|
||||||
|
function patchMediaSrc(html: string): string {
|
||||||
|
return html
|
||||||
|
.replace(/<img([^>]+)src=["'](\/uploads\/[^"']+)["']/g, `<img$1src="${ADMIN_SERVER_URL}$2"`)
|
||||||
|
.replace(/<video([^>]+)src=["'](\/uploads\/[^"']+)["']/g, `<video$1src="${ADMIN_SERVER_URL}$2"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Простая подсветка через replace (без DOM-обхода)
|
||||||
|
function highlightText(html: string, searchTerm: string): string {
|
||||||
|
if (!searchTerm.trim()) return html;
|
||||||
|
|
||||||
|
const term = searchTerm.trim();
|
||||||
|
const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const regex = new RegExp(`(${escapedTerm})`, 'gi');
|
||||||
|
|
||||||
|
return html.replace(/>([^<]*)</g, (match: string, text: string) => {
|
||||||
|
const highlighted = text.replace(regex, (m: string) =>
|
||||||
|
`<mark style="background: #ffdd57; color: #1a3c34; padding: 0 4px; border-radius: 3px; font-weight: 600;">${m}</mark>`
|
||||||
|
);
|
||||||
|
return `>${highlighted}<`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function countMatches(html: string, searchTerm: string): number {
|
||||||
|
if (!searchTerm.trim()) return 0;
|
||||||
|
const term = searchTerm.trim();
|
||||||
|
const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const regex = new RegExp(escapedTerm, 'gi');
|
||||||
|
const matches = html.match(regex);
|
||||||
|
return matches ? matches.length : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 📦 Компонент для рендеринга справочника (ОПТИМИЗИРОВАННЫЙ)
|
||||||
|
// ============================================
|
||||||
|
const HandbookRenderer = memo(({
|
||||||
|
blocks,
|
||||||
|
searchTerm,
|
||||||
|
onMatchCount
|
||||||
|
}: {
|
||||||
|
blocks: any[];
|
||||||
|
searchTerm: string;
|
||||||
|
onMatchCount: (count: number) => void;
|
||||||
|
}) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [renderedBlocks, setRenderedBlocks] = useState<any[]>([]);
|
||||||
|
const [totalMatches, setTotalMatches] = useState(0);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const processingRef = useRef(false);
|
||||||
|
|
||||||
|
// ✅ Обработка блоков с задержкой через requestAnimationFrame
|
||||||
|
useEffect(() => {
|
||||||
|
if (!blocks.length) return;
|
||||||
|
|
||||||
|
// ✅ Если идет обработка — пропускаем
|
||||||
|
if (processingRef.current) return;
|
||||||
|
processingRef.current = true;
|
||||||
|
|
||||||
|
// ✅ Показываем индикатор поиска
|
||||||
|
setIsSearching(true);
|
||||||
|
|
||||||
|
// ✅ Используем requestAnimationFrame для отложенной обработки
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
// ✅ Еще одна задержка через setTimeout для накопления изменений
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
const processed = blocks.map(block => {
|
||||||
|
if (typeof block === 'string') {
|
||||||
|
const content = patchMediaSrc(block);
|
||||||
|
if (searchTerm.trim()) {
|
||||||
|
return highlightText(content, searchTerm);
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
} else if (block.type === 'phones') {
|
||||||
|
const term = searchTerm.trim().toLowerCase();
|
||||||
|
let rows = block.rows;
|
||||||
|
if (term) {
|
||||||
|
rows = rows.filter((row: { desc: string; internal: string; city: string }) =>
|
||||||
|
row.desc.toLowerCase().includes(term) ||
|
||||||
|
row.internal.includes(term) ||
|
||||||
|
row.city.includes(term)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
let tableHtml = `<table class="handbook-table">`;
|
||||||
|
tableHtml += `
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Описание</th>
|
||||||
|
<th>АТС предприятия</th>
|
||||||
|
<th>Городская АТС</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>`;
|
||||||
|
|
||||||
|
rows.forEach((row: { desc: string; internal: string; city: string }) => {
|
||||||
|
let descHtml = row.desc;
|
||||||
|
let internalHtml = row.internal;
|
||||||
|
let cityHtml = row.city;
|
||||||
|
|
||||||
|
if (term) {
|
||||||
|
descHtml = highlightText(row.desc, searchTerm);
|
||||||
|
internalHtml = highlightText(row.internal, searchTerm);
|
||||||
|
cityHtml = highlightText(row.city, searchTerm);
|
||||||
|
}
|
||||||
|
|
||||||
|
tableHtml += `
|
||||||
|
<tr>
|
||||||
|
<td>${descHtml}</td>
|
||||||
|
<td>${internalHtml}</td>
|
||||||
|
<td>${cityHtml}</td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
tableHtml += `</tbody></table>`;
|
||||||
|
return tableHtml;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}).filter(Boolean);
|
||||||
|
|
||||||
|
setRenderedBlocks(processed);
|
||||||
|
setIsSearching(false);
|
||||||
|
processingRef.current = false;
|
||||||
|
|
||||||
|
// ✅ Подсчет совпадений
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}, [blocks, searchTerm, onMatchCount]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="handbook-content">
|
||||||
|
{isSearching && (
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '8px',
|
||||||
|
color: '#636e72',
|
||||||
|
fontSize: '13px',
|
||||||
|
borderBottom: '1px solid #f1f2f6',
|
||||||
|
marginBottom: '16px'
|
||||||
|
}}>
|
||||||
|
<span style={{ display: 'inline-block', animation: 'spin 1s linear infinite', marginRight: '8px' }}>⟳</span>
|
||||||
|
Поиск...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{renderedBlocks.map((block, idx) => (
|
||||||
|
<div key={idx} style={{ marginBottom: 16 }} dangerouslySetInnerHTML={{ __html: block }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 🏠 Основной компонент Contacts
|
||||||
|
// ============================================
|
||||||
|
const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [activeTab, setActiveTab] = useState<'map' | 'directory'>('directory');
|
||||||
|
const [showMapViewer, setShowMapViewer] = useState(false);
|
||||||
|
const [blocks, setBlocks] = useState<any[]>([]);
|
||||||
|
const [loadingHandbook, setLoadingHandbook] = useState(false);
|
||||||
|
const [errorHandbook, setErrorHandbook] = useState<string | null>(null);
|
||||||
|
const [matchCount, setMatchCount] = useState(0);
|
||||||
|
const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// ✅ Debounce для поиска (300ms)
|
||||||
|
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('');
|
||||||
|
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const MAP_FILE_URL = '/assets/maps/enterprise-map.tiff';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'directory' && blocks.length === 0 && !loadingHandbook) {
|
||||||
|
loadHandbook();
|
||||||
|
}
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
|
// ✅ DEBOUNCE: обновляем debouncedSearchTerm с задержкой 300ms
|
||||||
|
useEffect(() => {
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
debounceTimerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
debounceTimerRef.current = setTimeout(() => {
|
||||||
|
setDebouncedSearchTerm(searchTerm);
|
||||||
|
debounceTimerRef.current = null;
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
debounceTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [searchTerm]);
|
||||||
|
|
||||||
|
const loadHandbook = async () => {
|
||||||
|
setLoadingHandbook(true);
|
||||||
|
setErrorHandbook(null);
|
||||||
|
try {
|
||||||
|
const url = `${config.backendUrl}/api/handbook/html`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const html = await response.text();
|
||||||
|
const parsed = parseHandbookBlocks(html);
|
||||||
|
setBlocks(parsed);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Contacts] Ошибка загрузки справочника:', error);
|
||||||
|
setErrorHandbook(error instanceof Error ? error.message : 'Неизвестная ошибка');
|
||||||
|
} finally {
|
||||||
|
setLoadingHandbook(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenMap = () => setShowMapViewer(true);
|
||||||
|
const handleMapBack = () => setShowMapViewer(false);
|
||||||
|
|
||||||
|
const goToNextMatch = useCallback(() => {
|
||||||
|
if (matchCount === 0) return;
|
||||||
|
const nextIndex = (currentMatchIndex + 1) % matchCount;
|
||||||
|
setCurrentMatchIndex(nextIndex);
|
||||||
|
scrollToMatch(nextIndex);
|
||||||
|
}, [matchCount, currentMatchIndex]);
|
||||||
|
|
||||||
|
const goToPrevMatch = useCallback(() => {
|
||||||
|
if (matchCount === 0) return;
|
||||||
|
const prevIndex = (currentMatchIndex - 1 + matchCount) % matchCount;
|
||||||
|
setCurrentMatchIndex(prevIndex);
|
||||||
|
scrollToMatch(prevIndex);
|
||||||
|
}, [matchCount, currentMatchIndex]);
|
||||||
|
|
||||||
|
const scrollToMatch = useCallback((index: number) => {
|
||||||
|
const container = contentRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const marks = container.querySelectorAll('mark');
|
||||||
|
if (marks.length === 0) return;
|
||||||
|
|
||||||
|
const targetMark = marks[index];
|
||||||
|
if (!targetMark) return;
|
||||||
|
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const markRect = targetMark.getBoundingClientRect();
|
||||||
|
const scrollTop = container.scrollTop + markRect.top - containerRect.top - 80;
|
||||||
|
container.scrollTo({ top: scrollTop, behavior: 'smooth' });
|
||||||
|
|
||||||
|
const markElement = targetMark as HTMLElement;
|
||||||
|
markElement.style.outline = '2px solid #ff6b6b';
|
||||||
|
markElement.style.outlineOffset = '2px';
|
||||||
|
setTimeout(() => {
|
||||||
|
markElement.style.outline = 'none';
|
||||||
|
markElement.style.outlineOffset = 'none';
|
||||||
|
}, 2000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (matchCount > 0) {
|
||||||
|
goToNextMatch();
|
||||||
|
}
|
||||||
|
inputRef.current?.blur();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentMatchIndex(0);
|
||||||
|
}, [searchTerm]);
|
||||||
|
|
||||||
|
if (showMapViewer) {
|
||||||
|
return <MapViewer fileUrl={MAP_FILE_URL} onBack={handleMapBack} title="Карта предприятия" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="contacts-section"
|
||||||
|
style={{
|
||||||
|
padding: '20px',
|
||||||
|
maxWidth: '1200px',
|
||||||
|
margin: '0 auto',
|
||||||
|
paddingBottom: '80px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: 'calc(100vh - 80px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="top-bar" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '25px', flexWrap: 'wrap', gap: '10px', flexShrink: 0 }}>
|
||||||
|
<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
|
||||||
|
whileHover={{ y: -5, boxShadow: '0 8px 30px rgba(46, 204, 113, 0.3)' }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
onClick={handleOpenMap}
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #2ecc71, #27ae60)',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '25px 30px',
|
||||||
|
marginBottom: '25px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
boxShadow: '0 4px 20px rgba(46, 204, 113, 0.25)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
minHeight: '80px',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '20px' }}>
|
||||||
|
<div style={{ fontSize: '36px', color: 'white' }}>🗺️</div>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 5px 0', color: 'white', fontSize: '20px', fontWeight: 500 }}>
|
||||||
|
Карта предприятия
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: 0, color: 'rgba(255,255,255,0.85)', fontSize: '14px' }}>
|
||||||
|
Схема проезда, план здания, расположение корпусов
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'white', fontSize: '20px', opacity: 0.8 }}>→</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '10px',
|
||||||
|
marginBottom: '25px',
|
||||||
|
borderBottom: '2px solid #f0f0f0',
|
||||||
|
paddingBottom: '10px',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('directory')}
|
||||||
|
style={{
|
||||||
|
padding: '10px 24px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '15px',
|
||||||
|
fontWeight: activeTab === 'directory' ? 600 : 400,
|
||||||
|
color: activeTab === 'directory' ? '#6c5ce7' : '#636e72',
|
||||||
|
borderBottom: activeTab === 'directory' ? '3px solid #6c5ce7' : '3px solid transparent',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📘 Справочник
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('map')}
|
||||||
|
style={{
|
||||||
|
padding: '10px 24px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '15px',
|
||||||
|
fontWeight: activeTab === 'map' ? 600 : 400,
|
||||||
|
color: activeTab === 'map' ? '#6c5ce7' : '#636e72',
|
||||||
|
borderBottom: activeTab === 'map' ? '3px solid #6c5ce7' : '3px solid transparent',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🗺️ Карта
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{activeTab === 'directory' && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', height: '100%' }}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '16px 20px',
|
||||||
|
boxShadow: '0 4px 20px rgba(0,0,0,0.06)',
|
||||||
|
marginBottom: '20px',
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
gap: '10px',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
background: '#f8f9fa',
|
||||||
|
borderRadius: '10px',
|
||||||
|
padding: '4px 16px',
|
||||||
|
border: '2px solid #dfe6e9',
|
||||||
|
flex: 1,
|
||||||
|
minWidth: '200px',
|
||||||
|
}}>
|
||||||
|
🔍
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
inputMode="search"
|
||||||
|
autoFocus
|
||||||
|
placeholder="Поиск по справочнику..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchTerm(e.target.value);
|
||||||
|
}}
|
||||||
|
onKeyPress={handleKeyPress}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
padding: '10px 0',
|
||||||
|
fontSize: '15px',
|
||||||
|
outline: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{searchTerm.trim() && matchCount > 0 && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: '13px',
|
||||||
|
color: '#636e72',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}>
|
||||||
|
{currentMatchIndex + 1} / {matchCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '6px',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={goToPrevMatch}
|
||||||
|
disabled={matchCount === 0}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
background: matchCount > 0 ? 'linear-gradient(135deg, #2ecc71, #27ae60)' : '#dfe6e9',
|
||||||
|
color: matchCount > 0 ? 'white' : '#b2bec3',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
cursor: matchCount > 0 ? 'pointer' : 'default',
|
||||||
|
fontSize: '14px',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minWidth: '40px',
|
||||||
|
minHeight: '40px',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (matchCount > 0) {
|
||||||
|
e.currentTarget.style.transform = 'scale(1.05)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'scale(1)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaArrowUp size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={goToNextMatch}
|
||||||
|
disabled={matchCount === 0}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
background: matchCount > 0 ? 'linear-gradient(135deg, #2ecc71, #27ae60)' : '#dfe6e9',
|
||||||
|
color: matchCount > 0 ? 'white' : '#b2bec3',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
cursor: matchCount > 0 ? 'pointer' : 'default',
|
||||||
|
fontSize: '14px',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minWidth: '40px',
|
||||||
|
minHeight: '40px',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (matchCount > 0) {
|
||||||
|
e.currentTarget.style.transform = 'scale(1.05)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'scale(1)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaArrowDown size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={contentRef}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: 'auto',
|
||||||
|
background: '#ffffff',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '24px 28px',
|
||||||
|
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.06)',
|
||||||
|
border: '1px solid #f1f2f6',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loadingHandbook ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: '40px',
|
||||||
|
height: '40px',
|
||||||
|
border: '3px solid #f3f3f3',
|
||||||
|
borderTop: '3px solid #6c5ce7',
|
||||||
|
borderRadius: '50%',
|
||||||
|
animation: 'spin 1s linear infinite',
|
||||||
|
}} />
|
||||||
|
<p style={{ color: '#636e72' }}>Загрузка справочника...</p>
|
||||||
|
</div>
|
||||||
|
) : errorHandbook ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||||
|
<p style={{ color: '#e74c3c' }}>Ошибка загрузки: {errorHandbook}</p>
|
||||||
|
<button
|
||||||
|
onClick={loadHandbook}
|
||||||
|
style={{
|
||||||
|
padding: '8px 20px',
|
||||||
|
background: '#6c5ce7',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginTop: '10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Повторить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : blocks.length > 0 ? (
|
||||||
|
<HandbookRenderer
|
||||||
|
blocks={blocks}
|
||||||
|
searchTerm={debouncedSearchTerm}
|
||||||
|
onMatchCount={setMatchCount}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p style={{ textAlign: 'center', color: '#b2bec3' }}>Нет данных для отображения</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'map' && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '60px 40px',
|
||||||
|
boxShadow: '0 4px 20px rgba(0,0,0,0.06)',
|
||||||
|
textAlign: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
border: '2px dashed #dfe6e9',
|
||||||
|
minHeight: '300px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
onClick={handleOpenMap}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = '#2ecc71';
|
||||||
|
e.currentTarget.style.boxShadow = '0 8px 30px rgba(46, 204, 113, 0.15)';
|
||||||
|
e.currentTarget.style.transform = 'translateY(-3px)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = '#dfe6e9';
|
||||||
|
e.currentTarget.style.boxShadow = '0 4px 20px rgba(0,0,0,0.06)';
|
||||||
|
e.currentTarget.style.transform = 'translateY(0)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '64px', marginBottom: '15px' }}>🗺️</div>
|
||||||
|
<h3 style={{ color: '#2d3436', marginBottom: '10px', fontSize: '22px' }}>
|
||||||
|
Карта предприятия
|
||||||
|
</h3>
|
||||||
|
<p style={{ color: '#636e72', marginBottom: '20px', fontSize: '15px' }}>
|
||||||
|
Нажмите для просмотра карты в полноэкранном режиме
|
||||||
|
</p>
|
||||||
|
<div style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '12px 30px',
|
||||||
|
background: 'linear-gradient(135deg, #2ecc71, #27ae60)',
|
||||||
|
color: 'white',
|
||||||
|
borderRadius: '10px',
|
||||||
|
fontSize: '15px',
|
||||||
|
fontWeight: 500,
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}>
|
||||||
|
Открыть карту →
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.handbook-content {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #2d3436;
|
||||||
|
}
|
||||||
|
.handbook-content h1, .handbook-content h2, .handbook-content h3, .handbook-content h4 {
|
||||||
|
margin: 16px 0 8px 0;
|
||||||
|
color: #2d3436;
|
||||||
|
}
|
||||||
|
.handbook-content p {
|
||||||
|
margin: 8px 0;
|
||||||
|
color: #2d3436;
|
||||||
|
}
|
||||||
|
.handbook-content ul, .handbook-content ol {
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
||||||
|
.handbook-content li {
|
||||||
|
margin: 4px 0;
|
||||||
|
color: #2d3436;
|
||||||
|
}
|
||||||
|
.handbook-content table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 16px 0;
|
||||||
|
color: #2d3436;
|
||||||
|
}
|
||||||
|
.handbook-content th, .handbook-content td {
|
||||||
|
border: 1px solid #dfe6e9;
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: left;
|
||||||
|
color: #2d3436;
|
||||||
|
}
|
||||||
|
.handbook-content th {
|
||||||
|
background: #f8f9fa;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.handbook-content a {
|
||||||
|
color: #2d3436;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.handbook-content strong, .handbook-content b {
|
||||||
|
color: #2d3436;
|
||||||
|
}
|
||||||
|
.handbook-content mark {
|
||||||
|
background: #ffdd57;
|
||||||
|
color: #1a3c34;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #f1f2f6;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #b2bec3;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #6c5ce7;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Contacts;
|
||||||
@@ -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();
|
||||||
@@ -48,54 +66,698 @@ const EmployeeInfo: React.FC<EmployeeInfoProps> = ({
|
|||||||
|
|
||||||
const periods = generatePeriods();
|
const periods = generatePeriods();
|
||||||
const monthNames = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
|
const monthNames = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
|
||||||
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'];
|
'Июль', 'Август', 'Сентябрь', 'Окторябрь', 'Ноябрь', 'Декабрь'];
|
||||||
|
|
||||||
|
const checkPayslip = async () => {
|
||||||
|
if (!employee?.tnom) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setCheckingPayslip(true);
|
||||||
|
const backendUrl = config.backendUrl || 'http://localhost:3001';
|
||||||
|
const response = await fetch(`${backendUrl}/api/payslip/url/${employee.tnom}?period=${selectedPeriod}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setHasPayslip(data.exists);
|
||||||
|
if (data.exists && data.url) {
|
||||||
|
setPayslipUrl(data.url);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setHasPayslip(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка проверки расчетного листка:', error);
|
||||||
|
setHasPayslip(false);
|
||||||
|
} finally {
|
||||||
|
setCheckingPayslip(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkPayslip();
|
||||||
|
}, [selectedPeriod, employee]);
|
||||||
|
|
||||||
|
// ✅ ФУНКЦИЯ: формируем PDF в памяти и открываем предпросмотр
|
||||||
|
const handlePreviewPdf = async () => {
|
||||||
|
if (!employee?.tnom) return;
|
||||||
|
|
||||||
|
setShowPdfPreview(true);
|
||||||
|
setPdfLoading(true);
|
||||||
|
setPdfError(null);
|
||||||
|
setIsFullscreen(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const electronAPI = (window as any).electronAPI;
|
||||||
|
|
||||||
|
console.log('🔍 [PREVIEW] electronAPI доступен?', !!electronAPI);
|
||||||
|
|
||||||
|
if (electronAPI && electronAPI.printPayslipForm) {
|
||||||
|
console.log('🔄 [PREVIEW] Формируем PDF через Electron...');
|
||||||
|
|
||||||
|
const result = await electronAPI.printPayslipForm({
|
||||||
|
tnom: employee.tnom,
|
||||||
|
period: selectedPeriod,
|
||||||
|
printerName: 'GP-80250 Series'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('📄 [PREVIEW] Результат от Electron:', result);
|
||||||
|
|
||||||
|
if (result && result.success === false) {
|
||||||
|
throw new Error(result.error || 'Ошибка формирования PDF');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result && result.pdfBase64) {
|
||||||
|
// ✅ Сохраняем base64 для печати
|
||||||
|
(window as any).__pdfBase64 = result.pdfBase64;
|
||||||
|
|
||||||
|
// ✅ Преобразуем base64 в Blob URL для предпросмотра
|
||||||
|
const byteCharacters = atob(result.pdfBase64);
|
||||||
|
const byteNumbers = new Array(byteCharacters.length);
|
||||||
|
for (let i = 0; i < byteCharacters.length; i++) {
|
||||||
|
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||||
|
}
|
||||||
|
const byteArray = new Uint8Array(byteNumbers);
|
||||||
|
const blob = new Blob([byteArray], { type: 'application/pdf' });
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
setPayslipUrl(blobUrl);
|
||||||
|
setPdfLoading(false);
|
||||||
|
console.log('✅ [PREVIEW] PDF загружен в Blob URL');
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
throw new Error('PDF не создан');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Fallback: используем старый метод через бэкенд
|
||||||
|
console.log('🔄 [PREVIEW] Используем fallback через бэкенд...');
|
||||||
|
const backendUrl = config.backendUrl || 'http://localhost:3001';
|
||||||
|
const response = await fetch(`${backendUrl}/api/payslip/url/${employee.tnom}?period=${selectedPeriod}`);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.exists && data.url) {
|
||||||
|
const fullUrl = data.url.startsWith('http') ? data.url : `${backendUrl}${data.url}`;
|
||||||
|
setPayslipUrl(fullUrl);
|
||||||
|
setPdfLoading(false);
|
||||||
|
} else {
|
||||||
|
setPdfError('Расчетный листок не найден');
|
||||||
|
setPdfLoading(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setPdfError('Ошибка при проверке расчетного листка');
|
||||||
|
setPdfLoading(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [PREVIEW] Ошибка формирования PDF:', error);
|
||||||
|
setPdfError(error instanceof Error ? error.message : 'Не удалось сформировать расчетный листок');
|
||||||
|
setPdfLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClosePreview = () => {
|
||||||
|
setShowPdfPreview(false);
|
||||||
|
setPdfLoading(false);
|
||||||
|
setPdfError(null);
|
||||||
|
setIsFullscreen(false);
|
||||||
|
|
||||||
|
// Очищаем URL, чтобы не было утечек памяти
|
||||||
|
if (payslipUrl && payslipUrl.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(payslipUrl);
|
||||||
|
}
|
||||||
|
setPayslipUrl(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ✅ ФУНКЦИЯ ПЕЧАТИ (отправляет base64 напрямую в print-pdf)
|
||||||
|
const handlePrint = async () => {
|
||||||
|
try {
|
||||||
|
setIsPrinting(true);
|
||||||
|
setPrintStatus('sending');
|
||||||
|
setPrintMessage('Отправка документа на печать...');
|
||||||
|
|
||||||
|
const electronAPI = (window as any).electronAPI;
|
||||||
|
|
||||||
|
// ✅ Получаем base64 из памяти
|
||||||
|
const pdfBase64 = (window as any).__pdfBase64;
|
||||||
|
|
||||||
|
if (!pdfBase64) {
|
||||||
|
throw new Error('PDF не сформирован. Сначала откройте предпросмотр.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (electronAPI && electronAPI.printPdf) {
|
||||||
|
console.log('🖨️ [PRINT] Печатаем PDF из памяти (base64)');
|
||||||
|
|
||||||
|
// ✅ Отправляем base64 напрямую в print-pdf
|
||||||
|
const result = await electronAPI.printPdf({
|
||||||
|
pdfBase64: pdfBase64,
|
||||||
|
deviceName: 'GP-80250 Series'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result && result.success) {
|
||||||
|
setPrintStatus('success');
|
||||||
|
setPrintMessage('✅ Отправлено на принтер');
|
||||||
|
} else {
|
||||||
|
throw new Error(result?.error || 'Ошибка печати');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('Electron API не доступен');
|
||||||
|
}
|
||||||
|
|
||||||
|
} 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 (
|
return (
|
||||||
<div className="employee-info">
|
<>
|
||||||
<div className="top-bar">
|
<motion.div
|
||||||
<button className="back-button" onClick={onBack}>
|
initial={{ opacity: 0, y: 20 }}
|
||||||
← Назад
|
animate={{ opacity: 1, y: 0 }}
|
||||||
</button>
|
exit={{ opacity: 0, y: -20 }}
|
||||||
</div>
|
transition={{ duration: 0.3 }}
|
||||||
|
style={{
|
||||||
<div className="employee-header">
|
padding: '20px',
|
||||||
<div className="employee-photo">
|
maxWidth: '1200px',
|
||||||
<div className="photo-placeholder">Фото</div>
|
margin: '0 auto',
|
||||||
</div>
|
paddingBottom: '80px',
|
||||||
<div className="employee-details">
|
}}
|
||||||
<h2>{`${employee.famaly} ${employee.ima} ${employee.otch}`}</h2>
|
>
|
||||||
<p>Табельный номер: {employee.tnom}</p>
|
<div style={{
|
||||||
{employee.category?.trim() && <p>Категория: {employee.category}</p>}
|
display: 'flex',
|
||||||
{employee.departmentName && (
|
justifyContent: 'space-between',
|
||||||
<p>Подразделение: {employee.departmentName}</p>
|
alignItems: 'center',
|
||||||
)}
|
marginBottom: '25px',
|
||||||
<p>Организация: {employee.organization}</p>
|
}}>
|
||||||
{employee.profession && <p>Профессия: {employee.profession}</p>}
|
<button
|
||||||
</div>
|
onClick={onBack}
|
||||||
</div>
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
<div className="payslip-section">
|
background: '#6c757d',
|
||||||
<h3>Расчётный лист</h3>
|
color: 'white',
|
||||||
<div className="period-selector">
|
border: 'none',
|
||||||
<label htmlFor="period-select">Выберите период:</label>
|
borderRadius: '8px',
|
||||||
<select
|
cursor: 'pointer',
|
||||||
id="period-select"
|
fontSize: '14px',
|
||||||
value={selectedPeriod}
|
transition: 'all 0.3s ease',
|
||||||
onChange={(e) => setSelectedPeriod(e.target.value)}
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = '#5a6268';
|
||||||
|
e.currentTarget.style.transform = 'translateX(-2px)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = '#6c757d';
|
||||||
|
e.currentTarget.style.transform = 'translateX(0)';
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{periods.map(period => (
|
← Назад
|
||||||
<option key={period} value={period}>
|
</button>
|
||||||
{monthNames[parseInt(period.substring(0, 2)) - 1]} {period.substring(2)}
|
<h3 style={{
|
||||||
</option>
|
color: 'black',
|
||||||
))}
|
fontSize: '25px',
|
||||||
</select>
|
fontWeight: 600,
|
||||||
|
}}>
|
||||||
|
📋 Расчетный лист
|
||||||
|
</h3>
|
||||||
|
<div style={{ width: '100px' }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button onClick={() => onViewPayslip(selectedPeriod)}>
|
<motion.div
|
||||||
Посмотреть расчётный лист
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
</button>
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
</div>
|
transition={{ delay: 0.1 }}
|
||||||
</div>
|
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: '12px 35px',
|
||||||
|
background: hasPayslip ? 'linear-gradient(135deg, #00b894, #00cec9)' : '#dfe6e9',
|
||||||
|
color: hasPayslip ? 'white' : '#b2bec3',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '10px',
|
||||||
|
fontSize: '15px',
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: hasPayslip ? 'pointer' : 'not-allowed',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
boxShadow: hasPayslip ? '0 4px 15px rgba(0, 206, 201, 0.3)' : 'none',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
}}
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* ✅ МОДАЛКА ПРЕДПРОСМОТРА PDF */}
|
||||||
|
{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 || pdfLoading || !payslipUrl}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
background: isPrinting || pdfLoading || !payslipUrl ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.2)',
|
||||||
|
color: 'white',
|
||||||
|
border: '1px solid rgba(255,255,255,0.3)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
cursor: isPrinting || pdfLoading || !payslipUrl ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
opacity: isPrinting || pdfLoading || !payslipUrl ? 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!pdfLoading && payslipUrl && !pdfError && (
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
src={payslipUrl}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
minHeight: '600px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '12px',
|
||||||
|
background: '#ffffff',
|
||||||
|
display: 'block',
|
||||||
|
boxShadow: '0 2px 12px rgba(0,0,0,0.3)',
|
||||||
|
}}
|
||||||
|
title="PDF Preview"
|
||||||
|
onError={() => {
|
||||||
|
console.error('❌ [IFRAME] Ошибка загрузки PDF:', payslipUrl);
|
||||||
|
setPdfError('Не удалось загрузить PDF. Возможно, файл повреждён или путь неверный.');
|
||||||
|
setPdfLoading(false);
|
||||||
|
}}
|
||||||
|
onLoad={() => {
|
||||||
|
console.log('✅ [IFRAME] PDF загружен успешно');
|
||||||
|
setPdfLoading(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
<div style={{ fontSize: '12px', color: '#888', maxWidth: '400px', wordBreak: 'break-all' }}>
|
||||||
|
URL: {payslipUrl || 'не указан'}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🔄 Попробовать снова
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,711 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { FaArrowLeft, FaSpinner, FaEye, FaPrint, FaTimes, FaExpand, FaCompress, FaCheckCircle, FaExclamationCircle } from 'react-icons/fa';
|
||||||
|
import config from '../config.json';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
interface FinanceProps {
|
||||||
|
onBack: () => void;
|
||||||
|
onViewPayslip: (fileUrl: string) => void;
|
||||||
|
employee: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Finance: React.FC<FinanceProps> = ({ onBack, onViewPayslip, employee }) => {
|
||||||
|
const [hasPayslip, setHasPayslip] = useState<boolean | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [payslipUrl, setPayslipUrl] = useState<string | null>(null);
|
||||||
|
const [showPdfPreview, setShowPdfPreview] = useState(false);
|
||||||
|
const [pdfLoading, setPdfLoading] = useState(false);
|
||||||
|
const [pdfError, setPdfError] = useState<string | null>(null);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
|
const [isPrinting, setIsPrinting] = useState(false);
|
||||||
|
const [printStatus, setPrintStatus] = useState<'idle' | 'success' | 'error' | 'sending'>('idle');
|
||||||
|
const [printMessage, setPrintMessage] = useState('');
|
||||||
|
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
|
|
||||||
|
const [selectedPeriod, setSelectedPeriod] = useState(() => {
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
return `${month}${year}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const generatePeriods = () => {
|
||||||
|
const periods = [];
|
||||||
|
const now = new Date();
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const date = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
periods.push(`${month}${year}`);
|
||||||
|
}
|
||||||
|
return periods;
|
||||||
|
};
|
||||||
|
const periods = generatePeriods();
|
||||||
|
const monthNames = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
|
||||||
|
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkPayslip = async () => {
|
||||||
|
if (!employee?.tnom) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const backendUrl = config.backendUrl || 'http://localhost:3001';
|
||||||
|
const response = await fetch(`${backendUrl}/api/payslip/url/${employee.tnom}?period=${selectedPeriod}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setHasPayslip(data.exists);
|
||||||
|
if (data.exists && data.url) {
|
||||||
|
setPayslipUrl(data.url);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setHasPayslip(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка проверки расчетного листка:', error);
|
||||||
|
setHasPayslip(false);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkPayslip();
|
||||||
|
}, [employee, selectedPeriod]);
|
||||||
|
|
||||||
|
const handlePreviewPdf = () => {
|
||||||
|
if (payslipUrl) {
|
||||||
|
setShowPdfPreview(true);
|
||||||
|
setPdfLoading(true);
|
||||||
|
setPdfError(null);
|
||||||
|
setIsFullscreen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClosePreview = () => {
|
||||||
|
setShowPdfPreview(false);
|
||||||
|
setPdfLoading(false);
|
||||||
|
setPdfError(null);
|
||||||
|
setIsFullscreen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrint = async () => {
|
||||||
|
try {
|
||||||
|
setIsPrinting(true);
|
||||||
|
setPrintStatus('sending');
|
||||||
|
setPrintMessage('Отправка документа на печать...');
|
||||||
|
|
||||||
|
const urlToPrint = payslipUrl; // payslipUrl для Employee/Finance, fileUrl для Viewer
|
||||||
|
if (!urlToPrint) throw new Error('URL документа отсутствует');
|
||||||
|
|
||||||
|
const fullUrl = urlToPrint.startsWith('http') ? urlToPrint : `http://localhost:3001${urlToPrint}`;
|
||||||
|
|
||||||
|
if ((window as any).electronAPI && (window as any).electronAPI.printPdf) {
|
||||||
|
console.log('🖨️ [PRINT] Печатаем тихо через Electron API');
|
||||||
|
await (window as any).electronAPI.printPdf({
|
||||||
|
pdfUrl: fullUrl,
|
||||||
|
deviceName: '' // <-- Оставляем пустым! Electron сам плюнет на дефолтный принтер
|
||||||
|
});
|
||||||
|
|
||||||
|
setPrintStatus('success');
|
||||||
|
setPrintMessage('✅ Отправлено на принтер');
|
||||||
|
}
|
||||||
|
// 🌐 ВАРИАНТ 2: Мы в браузере (Chrome/Edge)
|
||||||
|
else {
|
||||||
|
console.log('🖨️ [PRINT] Electron API нет. Обходим CORS и печатаем через браузер');
|
||||||
|
|
||||||
|
// Скачиваем PDF как Blob, чтобы сделать его "своим" для браузера
|
||||||
|
const response = await fetch(fullUrl);
|
||||||
|
if (!response.ok) throw new Error('Не удалось скачать PDF с сервера');
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
// Создаем невидимый iframe
|
||||||
|
const printIframe = document.createElement('iframe');
|
||||||
|
printIframe.style.display = 'none';
|
||||||
|
printIframe.src = blobUrl;
|
||||||
|
document.body.appendChild(printIframe);
|
||||||
|
|
||||||
|
printIframe.onload = () => {
|
||||||
|
try {
|
||||||
|
printIframe.contentWindow?.focus();
|
||||||
|
printIframe.contentWindow?.print();
|
||||||
|
setPrintStatus('success');
|
||||||
|
setPrintMessage('✅ Документ отправлен на печать');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Ошибка iframe печати:', e);
|
||||||
|
throw new Error('Браузер заблокировал печать');
|
||||||
|
} finally {
|
||||||
|
// Чистим за собой мусор через 5 секунд
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.removeChild(printIframe);
|
||||||
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [PRINT] Ошибка печати:', error);
|
||||||
|
setPrintStatus('error');
|
||||||
|
setPrintMessage('❌ Ошибка: ' + (error instanceof Error ? error.message : 'Неизвестная ошибка'));
|
||||||
|
} finally {
|
||||||
|
setIsPrinting(false);
|
||||||
|
setTimeout(() => {
|
||||||
|
setPrintStatus('idle');
|
||||||
|
setPrintMessage('');
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFullscreen = () => {
|
||||||
|
setIsFullscreen(!isFullscreen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFullUrl = (url: string) => {
|
||||||
|
return url.startsWith('http') ? url : `http://localhost:3001${url}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const financialIndicators = [
|
||||||
|
{ title: 'Средняя ЗП по предприятию', value: '1 850', unit: 'руб.' },
|
||||||
|
{ title: 'Премия по итогам месяца', value: '15', unit: '%' },
|
||||||
|
{ title: 'Материальная помощь к отпуску', value: '500', unit: 'руб.' },
|
||||||
|
{ title: 'Выплаты к Новому году', value: '300', unit: 'руб.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
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',
|
||||||
|
flex: '0 0 100px',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
← Назад
|
||||||
|
</button>
|
||||||
|
<h2 style={{
|
||||||
|
margin: 0,
|
||||||
|
color: '#2d3436',
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: 300,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
}}>
|
||||||
|
💰 Финансы
|
||||||
|
</h2>
|
||||||
|
<div style={{ width: '100px' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ delay: 0.1 }}
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '25px 30px',
|
||||||
|
color: 'white',
|
||||||
|
marginBottom: '30px',
|
||||||
|
boxShadow: '0 10px 30px rgba(102, 126, 234, 0.3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '14px', opacity: 0.8, marginBottom: '5px' }}>
|
||||||
|
{employee?.organization || 'Организация'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '20px', fontWeight: 500 }}>
|
||||||
|
{employee?.famaly} {employee?.ima} {employee?.otch}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', opacity: 0.9, marginTop: '5px' }}>
|
||||||
|
Табельный номер: {employee?.tnom}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div style={{ fontSize: '12px', opacity: 0.7 }}>Доступно</div>
|
||||||
|
<div style={{ fontSize: '28px', fontWeight: 600 }}>0.00</div>
|
||||||
|
<div style={{ fontSize: '12px', opacity: 0.7 }}>руб.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '25px',
|
||||||
|
boxShadow: '0 4px 20px rgba(0,0,0,0.06)',
|
||||||
|
marginBottom: '25px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ margin: '0 0 20px 0', color: '#2d3436', fontSize: '18px', fontWeight: 500 }}>
|
||||||
|
📊 Финансово-экономические показатели
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '15px' }}>
|
||||||
|
{financialIndicators.map((item, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={index}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.1 * index }}
|
||||||
|
style={{
|
||||||
|
background: '#f8f9fa',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '16px 20px',
|
||||||
|
border: '1px solid #f1f2f6',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '13px', color: '#636e72', marginBottom: '8px' }}>{item.title}</div>
|
||||||
|
<div style={{ fontSize: '24px', fontWeight: 600, color: '#2d3436' }}>
|
||||||
|
{item.value}
|
||||||
|
{item.unit && (
|
||||||
|
<span style={{ fontSize: '14px', fontWeight: 400, color: '#636e72', marginLeft: '4px' }}>
|
||||||
|
{item.unit}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.3 }}
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '25px',
|
||||||
|
boxShadow: '0 4px 20px rgba(0,0,0,0.06)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ margin: '0 0 20px 0', color: '#2d3436', fontSize: '18px', fontWeight: 500 }}>
|
||||||
|
📄 Расчетный листок
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '15px',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '20px',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
|
<label style={{ fontSize: '14px', color: '#636e72' }}>
|
||||||
|
Выберите период:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedPeriod}
|
||||||
|
onChange={(e) => setSelectedPeriod(e.target.value)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 14px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid #dfe6e9',
|
||||||
|
fontSize: '14px',
|
||||||
|
background: 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{periods.map(period => {
|
||||||
|
const month = parseInt(period.substring(0, 2)) - 1;
|
||||||
|
const year = period.substring(2);
|
||||||
|
return (
|
||||||
|
<option key={period} value={period}>
|
||||||
|
{monthNames[month]} {year}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '30px', gap: '12px' }}>
|
||||||
|
<FaSpinner style={{ animation: 'spin 1s linear infinite', fontSize: '24px' }} />
|
||||||
|
<span>Проверка наличия расчетного листка...</span>
|
||||||
|
</div>
|
||||||
|
) : hasPayslip ? (
|
||||||
|
<div style={{ display: 'flex', gap: '15px', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '16px 20px',
|
||||||
|
background: '#f0fdf4',
|
||||||
|
borderRadius: '10px',
|
||||||
|
border: '1px solid #bbf7d0',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '13px', color: '#166534', marginBottom: '4px' }}>
|
||||||
|
✅ Расчетный листок доступен
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#14532d' }}>
|
||||||
|
Табельный номер: <strong>{employee?.tnom}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handlePreviewPdf}
|
||||||
|
style={{
|
||||||
|
padding: '12px 30px',
|
||||||
|
background: 'linear-gradient(135deg, #00b894, #00cec9)',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '10px',
|
||||||
|
fontSize: '15px',
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
boxShadow: '0 4px 15px rgba(0, 206, 201, 0.3)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaEye />
|
||||||
|
Предпросмотр
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
padding: '16px 20px',
|
||||||
|
background: '#fef2f2',
|
||||||
|
borderRadius: '10px',
|
||||||
|
border: '1px solid #fecaca',
|
||||||
|
color: '#991b1b',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '13px', marginBottom: '4px' }}>
|
||||||
|
❌ Расчетный листок не найден
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '13px', opacity: 0.8 }}>
|
||||||
|
Для табельного номера <strong>{employee?.tnom}</strong> файл отсутствует.
|
||||||
|
{employee?.tnom && ` Файл должен называться: ${employee.tnom}.pdf`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
<style>{`
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{printStatus !== 'idle' && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -50, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: -50, scale: 0.9 }}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: '20px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: 99999,
|
||||||
|
padding: '16px 32px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
background: printStatus === 'success' ? '#10b981' :
|
||||||
|
printStatus === 'error' ? '#ef4444' :
|
||||||
|
'#f59e0b',
|
||||||
|
color: 'white',
|
||||||
|
boxShadow: '0 10px 40px rgba(0,0,0,0.2)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 500,
|
||||||
|
minWidth: '300px',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{printStatus === 'sending' && <FaSpinner style={{ animation: 'spin 1s linear infinite' }} />}
|
||||||
|
{printStatus === 'success' && <FaCheckCircle />}
|
||||||
|
{printStatus === 'error' && <FaExclamationCircle />}
|
||||||
|
{printMessage}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{showPdfPreview && payslipUrl && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: 'rgba(0, 0, 0, 0.85)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
WebkitBackdropFilter: 'blur(8px)',
|
||||||
|
zIndex: 9999,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '20px',
|
||||||
|
}}
|
||||||
|
onClick={handleClosePreview}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.9, y: 30, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, y: 0, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.9, y: 30, opacity: 0 }}
|
||||||
|
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||||
|
style={{
|
||||||
|
background: '#1a1a2e',
|
||||||
|
borderRadius: '24px',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: isFullscreen ? '100%' : '900px',
|
||||||
|
height: isFullscreen ? '100%' : '90vh',
|
||||||
|
maxHeight: isFullscreen ? '100%' : '90vh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
boxShadow: '0 50px 100px rgba(0,0,0,0.5)',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
padding: '16px 24px',
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
minHeight: '60px',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<span style={{ fontSize: '20px' }}>📄</span>
|
||||||
|
<span style={{ color: 'white', fontSize: '16px', fontWeight: 500 }}>
|
||||||
|
Расчетный листок
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
color: 'rgba(255,255,255,0.5)',
|
||||||
|
fontSize: '12px',
|
||||||
|
background: 'rgba(255,255,255,0.15)',
|
||||||
|
padding: '2px 10px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
}}>
|
||||||
|
{employee?.tnom}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<button
|
||||||
|
onClick={handlePrint}
|
||||||
|
disabled={isPrinting}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
background: isPrinting ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.2)',
|
||||||
|
color: 'white',
|
||||||
|
border: '1px solid rgba(255,255,255,0.3)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
cursor: isPrinting ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
opacity: isPrinting ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPrinting ? <FaSpinner style={{ animation: 'spin 1s linear infinite' }} /> : <FaPrint size={16} />}
|
||||||
|
{isPrinting ? 'Отправка...' : 'Печать'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={toggleFullscreen}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '36px',
|
||||||
|
height: '36px',
|
||||||
|
background: 'rgba(255,255,255,0.2)',
|
||||||
|
color: 'white',
|
||||||
|
border: '1px solid rgba(255,255,255,0.3)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isFullscreen ? <FaCompress size={16} /> : <FaExpand size={16} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleClosePreview}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '36px',
|
||||||
|
height: '36px',
|
||||||
|
background: 'rgba(255,255,255,0.2)',
|
||||||
|
color: 'white',
|
||||||
|
border: '1px solid rgba(255,255,255,0.3)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaTimes size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '20px',
|
||||||
|
overflow: 'auto',
|
||||||
|
background: '#0a0a0a',
|
||||||
|
position: 'relative',
|
||||||
|
WebkitOverflowScrolling: 'touch',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
{pdfLoading && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '20px',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: '60px',
|
||||||
|
height: '60px',
|
||||||
|
border: '4px solid #2d2d2d',
|
||||||
|
borderTop: '4px solid #667eea',
|
||||||
|
borderRadius: '50%',
|
||||||
|
animation: 'spin 1s linear infinite',
|
||||||
|
}} />
|
||||||
|
<span style={{ color: '#888', fontSize: '16px' }}>
|
||||||
|
Загрузка документа...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
src={`${getFullUrl(payslipUrl)}#toolbar=0&navpanes=0&scrollbar=1&view=FitH`}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
minHeight: '600px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '12px',
|
||||||
|
background: '#0a0a0a',
|
||||||
|
display: pdfLoading ? 'none' : 'block',
|
||||||
|
boxShadow: '0 2px 12px rgba(0,0,0,0.3)',
|
||||||
|
}}
|
||||||
|
title="PDF Preview"
|
||||||
|
onLoad={() => setPdfLoading(false)}
|
||||||
|
onError={() => {
|
||||||
|
setPdfLoading(false);
|
||||||
|
setPdfError('Не удалось загрузить PDF');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{pdfError && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '15px',
|
||||||
|
padding: '40px',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '64px' }}>📄</div>
|
||||||
|
<div style={{ fontSize: '18px', color: '#e74c3c', fontWeight: 500 }}>
|
||||||
|
{pdfError}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setPdfError(null);
|
||||||
|
setPdfLoading(true);
|
||||||
|
const iframe = document.querySelector('iframe[title="PDF Preview"]') as HTMLIFrameElement;
|
||||||
|
if (iframe) {
|
||||||
|
iframe.src = `${getFullUrl(payslipUrl)}#toolbar=0&navpanes=0&scrollbar=1&view=FitH`;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '10px 30px',
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🔄 Попробовать снова
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
padding: '12px 24px',
|
||||||
|
background: 'rgba(255,255,255,0.05)',
|
||||||
|
borderTop: '1px solid rgba(255,255,255,0.05)',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
fontSize: '13px',
|
||||||
|
color: 'rgba(255,255,255,0.5)',
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
👤 {employee?.famaly} {employee?.ima}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
📋 Табельный номер: <strong style={{ color: 'rgba(255,255,255,0.7)' }}>{employee?.tnom}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
🏢 {employee?.organization}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Finance;
|
||||||
@@ -0,0 +1,853 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
FaArrowLeft, FaShoppingCart, FaPlus, FaMinus, FaTrashAlt,
|
||||||
|
FaWallet, FaPaperPlane, FaCheckCircle, FaSpinner, FaUtensils, FaTimes
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
|
||||||
|
const API_BASE = 'http://localhost:3001';
|
||||||
|
const BOTTOM_BAR_HEIGHT = 70;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ТИПЫ
|
||||||
|
// ============================================================
|
||||||
|
interface FoodItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
price: number;
|
||||||
|
imageUrl?: string;
|
||||||
|
weightGrams?: number;
|
||||||
|
}
|
||||||
|
interface Category {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
emoji?: string;
|
||||||
|
items: FoodItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FoodMenuProps {
|
||||||
|
onBack: () => void;
|
||||||
|
employee: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
type View = 'menu' | 'success';
|
||||||
|
|
||||||
|
const CATEGORY_EMOJI_FALLBACK: Record<number, string> = {
|
||||||
|
1: '🍲', 2: '🥣', 3: '🥪', 4: '🥤', 5: '🍰',
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInitials = (fullName: string) =>
|
||||||
|
fullName.split(' ').filter(Boolean).slice(0, 2)
|
||||||
|
.map((s) => s[0]?.toUpperCase()).join('');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ГЛАВНЫЙ КОМПОНЕНТ
|
||||||
|
// ============================================================
|
||||||
|
const FoodMenu: React.FC<FoodMenuProps> = ({ onBack, employee }) => {
|
||||||
|
const tnom = employee?.tnom || '';
|
||||||
|
const fullName = employee
|
||||||
|
? `${employee.famaly || ''} ${employee.ima || ''} ${employee.otch || ''}`.trim()
|
||||||
|
: 'Сотрудник';
|
||||||
|
|
||||||
|
const [company, setCompany] = useState('АзотСервис');
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [activeCat, setActiveCat] = useState<number | null>(null);
|
||||||
|
const [balance, setBalance] = useState<number>(0);
|
||||||
|
|
||||||
|
const [cart, setCart] = useState<Record<number, number>>({});
|
||||||
|
const [expandedItem, setExpandedItem] = useState<number | null>(null);
|
||||||
|
const [imageErrors, setImageErrors] = useState<Record<number, boolean>>({});
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [view, setView] = useState<View>('menu');
|
||||||
|
const [isCartOpen, setIsCartOpen] = useState(false);
|
||||||
|
const [successOrder, setSuccessOrder] = useState<{ id: number; total: number } | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Состояния для "Мои заказы"
|
||||||
|
const [showMyOrders, setShowMyOrders] = useState(false);
|
||||||
|
const [myOrders, setMyOrders] = useState<any[]>([]);
|
||||||
|
const [loadingOrders, setLoadingOrders] = useState(false);
|
||||||
|
const [cancelConfirm, setCancelConfirm] = useState<{ id: number; total: number } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [menuRes, balRes] = await Promise.all([
|
||||||
|
fetch(`${API_BASE}/api/food/menu`).then((r) => r.json()),
|
||||||
|
tnom ? fetch(`${API_BASE}/api/food/balance/${tnom}`).then((r) => r.json())
|
||||||
|
: Promise.resolve({ balance: 0 }),
|
||||||
|
]);
|
||||||
|
setCompany(menuRes.company || 'АзотСервис');
|
||||||
|
setCategories(menuRes.categories || []);
|
||||||
|
setActiveCat(menuRes.categories?.[0]?.id ?? null);
|
||||||
|
setBalance(Number(balRes.balance) || 0);
|
||||||
|
} catch (e) {
|
||||||
|
setError('Не удалось загрузить меню');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [tnom]);
|
||||||
|
|
||||||
|
const allItems = useMemo(() => {
|
||||||
|
const map: Record<number, FoodItem> = {};
|
||||||
|
categories.forEach((c) => c.items.forEach((i) => (map[i.id] = i)));
|
||||||
|
return map;
|
||||||
|
}, [categories]);
|
||||||
|
|
||||||
|
const cartCount = useMemo(
|
||||||
|
() => Object.values(cart).reduce((s, q) => s + q, 0), [cart]
|
||||||
|
);
|
||||||
|
const cartTotal = useMemo(
|
||||||
|
() => +Object.entries(cart).reduce((s, [id, qty]) => {
|
||||||
|
const it = allItems[Number(id)];
|
||||||
|
return it ? s + it.price * qty : s;
|
||||||
|
}, 0).toFixed(2),
|
||||||
|
[cart, allItems]
|
||||||
|
);
|
||||||
|
|
||||||
|
const changeQty = (itemId: number, delta: number) => {
|
||||||
|
setCart((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
const qty = (next[itemId] || 0) + delta;
|
||||||
|
if (qty <= 0) delete next[itemId];
|
||||||
|
else next[itemId] = qty;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = (itemId: number) => {
|
||||||
|
setCart((prev) => {
|
||||||
|
const n = { ...prev };
|
||||||
|
delete n[itemId];
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeItems = categories.find((c) => c.id === activeCat)?.items || [];
|
||||||
|
|
||||||
|
const submitOrder = async () => {
|
||||||
|
if (cartCount === 0) return;
|
||||||
|
setSending(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const items = Object.entries(cart).map(([id, qty]) => ({
|
||||||
|
itemId: Number(id), quantity: qty,
|
||||||
|
}));
|
||||||
|
const res = await fetch(`${API_BASE}/api/food/orders`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ tnom, items }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error || 'Ошибка отправки заказа');
|
||||||
|
setSending(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSuccessOrder({ id: data.orderId, total: data.total });
|
||||||
|
setBalance((b) => +(b - data.total).toFixed(2));
|
||||||
|
setCart({});
|
||||||
|
setIsCartOpen(false);
|
||||||
|
setView('success');
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || 'Ошибка сети');
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDone = () => {
|
||||||
|
setSuccessOrder(null);
|
||||||
|
setView('menu');
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// МОИ ЗАКАЗЫ
|
||||||
|
// ============================================
|
||||||
|
const fetchMyOrders = async () => {
|
||||||
|
if (!tnom) return;
|
||||||
|
setLoadingOrders(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/food/my-orders/${tnom}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setMyOrders(data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setLoadingOrders(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenMyOrders = () => {
|
||||||
|
setShowMyOrders(true);
|
||||||
|
fetchMyOrders();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelClick = (orderId: number, totalAmount: number) => {
|
||||||
|
setCancelConfirm({ id: orderId, total: totalAmount });
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmCancelOrder = async () => {
|
||||||
|
if (!cancelConfirm) return;
|
||||||
|
const { id, total } = cancelConfirm;
|
||||||
|
setCancelConfirm(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/food/my-orders/${id}/cancel`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ tnom }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
setBalance((b) => +(b + data.refund).toFixed(2));
|
||||||
|
fetchMyOrders();
|
||||||
|
} else {
|
||||||
|
alert(data.error || 'Ошибка отмены заказа');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Ошибка сети при отмене заказа');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={loadingStyle}>
|
||||||
|
<div style={spinnerStyle} />
|
||||||
|
<style>{keyframes}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view === 'success' && successOrder) {
|
||||||
|
return (
|
||||||
|
<div style={successWrapperStyle}>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.85, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
style={successCardStyle}
|
||||||
|
>
|
||||||
|
<div style={{ background: '#E8F5E9', borderRadius: '50%', padding: 20, display: 'inline-block', marginBottom: 20 }}>
|
||||||
|
<FaCheckCircle style={{ fontSize: 80, color: '#4CAF50' }} />
|
||||||
|
</div>
|
||||||
|
<h1 style={{ margin: '0 0 12px', color: '#2E3B32', fontSize: 36, fontWeight: 800 }}>
|
||||||
|
Заказ оформлен!
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: '#607D8B', fontSize: 20, margin: '8px 0' }}>
|
||||||
|
Номер заказа: <b style={{ color: '#2E3B32' }}>#{successOrder.id}</b>
|
||||||
|
</p>
|
||||||
|
<p style={{ color: '#607D8B', fontSize: 20, margin: '8px 0 32px' }}>
|
||||||
|
Сумма: <b style={{ color: '#2E3B32' }}>{successOrder.total.toFixed(2)} BYN</b>
|
||||||
|
</p>
|
||||||
|
<button onClick={handleDone} style={successBtnStyle}>
|
||||||
|
Готово
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = Object.entries(cart);
|
||||||
|
const notEnough = cartTotal > balance;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={menuRootStyle}>
|
||||||
|
{/* ============ ШАПКА ============ */}
|
||||||
|
<div style={headerZoneStyle}>
|
||||||
|
{/* 1-я строка: Кнопка Назад (10%) + Название столовой + Мои заказы */}
|
||||||
|
<div style={headerRow1Style}>
|
||||||
|
<button onClick={onBack} style={btnRectBackStyle}>
|
||||||
|
<FaArrowLeft size={20} />
|
||||||
|
</button>
|
||||||
|
<div style={companyTitleStyle}>
|
||||||
|
<FaUtensils size={32} style={{ color: '#4CAF50' }} />
|
||||||
|
<span>{company}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Кнопка Мои заказы */}
|
||||||
|
<button
|
||||||
|
onClick={handleOpenMyOrders}
|
||||||
|
style={{
|
||||||
|
marginLeft: 'auto',
|
||||||
|
background: '#FFFFFF', color: '#4CAF50', border: '2px solid #4CAF50',
|
||||||
|
borderRadius: 12, padding: '10px 20px', fontSize: 16, fontWeight: 700,
|
||||||
|
cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.05)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaShoppingCart size={18} /> Мои заказы
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2-я строка: Сотрудник + Баланс */}
|
||||||
|
<div style={headerRow2Style}>
|
||||||
|
<div style={headerCardStyle}>
|
||||||
|
<div style={avatarCircleStyle}>{getInitials(fullName) || '👤'}</div>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={headerLabelStyle}>Сотрудник</div>
|
||||||
|
<div style={headerValueStyle} title={fullName}>{fullName}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
...headerCardStyle,
|
||||||
|
background: 'linear-gradient(135deg, #81C784 0%, #4CAF50 100%)',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: '0 8px 24px rgba(76,175,80,0.25)',
|
||||||
|
flex: '0 1 auto',
|
||||||
|
minWidth: 260,
|
||||||
|
}}>
|
||||||
|
<FaWallet size={28} style={{ color: '#fff' }} />
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ ...headerLabelStyle, color: 'rgba(255,255,255,0.9)' }}>Баланс</div>
|
||||||
|
<div style={{ ...headerValueStyle, color: '#fff', fontSize: 22 }}>
|
||||||
|
{balance.toFixed(2)} BYN
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ============ ОСНОВНАЯ ЗОНА ============ */}
|
||||||
|
<div style={mainZoneStyle}>
|
||||||
|
{/* Вертикальная колонка категорий */}
|
||||||
|
<div style={categoriesPanelStyle}>
|
||||||
|
<div style={categoriesTitleStyle}>КАТЕГОРИИ</div>
|
||||||
|
<div style={categoriesListStyle}>
|
||||||
|
{categories.map((c) => {
|
||||||
|
const isActive = c.id === activeCat;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
onClick={() => { setActiveCat(c.id); setExpandedItem(null); }}
|
||||||
|
style={{
|
||||||
|
...categoryBtnStyle,
|
||||||
|
background: isActive ? '#E2F0D9' : 'transparent',
|
||||||
|
color: isActive ? '#4CAF50' : '#546E7A',
|
||||||
|
fontWeight: isActive ? 800 : 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 32 }}>{c.emoji || '🍽️'}</span>
|
||||||
|
<span style={{ fontSize: 18 }}>{c.name}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Сетка блюд */}
|
||||||
|
<div style={itemsPanelStyle}>
|
||||||
|
{activeItems.map((it) => {
|
||||||
|
const qty = cart[it.id] || 0;
|
||||||
|
const cat = categories.find((c) => c.id === activeCat);
|
||||||
|
const emoji = CATEGORY_EMOJI_FALLBACK[cat?.id || 0] || '🍽️';
|
||||||
|
const isExpanded = expandedItem === it.id || qty > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={it.id}
|
||||||
|
style={{
|
||||||
|
...itemCardStyle,
|
||||||
|
border: isExpanded ? '2px solid #81C784' : '2px solid transparent',
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (qty === 0) {
|
||||||
|
changeQty(it.id, 1);
|
||||||
|
setExpandedItem(it.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={itemImgStyle}>
|
||||||
|
{it.imageUrl && !imageErrors[it.id] ? (
|
||||||
|
<img
|
||||||
|
src={`${API_BASE}${it.imageUrl}`}
|
||||||
|
alt={it.name}
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
onError={() => {
|
||||||
|
setImageErrors(prev => ({ ...prev, [it.id]: true }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
emoji
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={itemInfoStyle}>
|
||||||
|
<div style={itemNameStyle}>{it.name}</div>
|
||||||
|
{it.description && <div style={itemDescStyle}>{it.description}</div>}
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
<div style={itemBottomStyle}>
|
||||||
|
<div style={itemPriceStyle}>
|
||||||
|
{it.price.toFixed(2)}
|
||||||
|
<span style={{ fontSize: 14, color: '#90A4AE', marginLeft: 4, fontWeight: 500 }}>BYN</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isExpanded && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.8 }}
|
||||||
|
style={qtyInlineStyle}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
changeQty(it.id, -1);
|
||||||
|
if (qty <= 1) setExpandedItem(null);
|
||||||
|
}}
|
||||||
|
style={qtyItemBtnStyle}
|
||||||
|
>
|
||||||
|
<FaMinus size={16} />
|
||||||
|
</button>
|
||||||
|
<span style={qtyInlineNumStyle}>{qty}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => changeQty(it.id, 1)}
|
||||||
|
style={qtyItemBtnStyle}
|
||||||
|
>
|
||||||
|
<FaPlus size={16} />
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ============ ПЛАВАЮЩАЯ ПЛАШКА КОРЗИНЫ ============ */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{cartCount > 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: 100, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
exit={{ y: 100, opacity: 0 }}
|
||||||
|
style={cartBarStyle}
|
||||||
|
onClick={() => setIsCartOpen(true)}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.2)', borderRadius: '50%', width: 48, height: 48, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<FaShoppingCart size={22} />
|
||||||
|
</div>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 20 }}>
|
||||||
|
{cartCount} поз.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 20 }}>
|
||||||
|
<span style={{ fontWeight: 800, fontSize: 24 }}>
|
||||||
|
{cartTotal.toFixed(2)} BYN
|
||||||
|
</span>
|
||||||
|
<div style={{ background: '#fff', color: '#4CAF50', borderRadius: '50%', width: 48, height: 48, display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 'bold', fontSize: 24 }}>
|
||||||
|
→
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* ============ ВЫЕЗЖАЮЩАЯ КОРЗИНА СПРАВА ============ */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isCartOpen && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||||
|
style={{ position: 'fixed', inset: 0, background: 'rgba(46,59,50,0.4)', backdropFilter: 'blur(4px)', zIndex: 200 }}
|
||||||
|
onClick={() => setIsCartOpen(false)}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }}
|
||||||
|
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
||||||
|
style={{
|
||||||
|
position: 'fixed', top: 0, right: 0, bottom: 0, width: 540,
|
||||||
|
background: '#FFFFFF', boxShadow: '-10px 0 40px rgba(0,0,0,0.1)',
|
||||||
|
zIndex: 201, display: 'flex', flexDirection: 'column',
|
||||||
|
paddingBottom: BOTTOM_BAR_HEIGHT
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '24px', borderBottom: '1px solid #E0E6ED', background: '#E2F0D9' }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 28, fontWeight: 800, color: '#2E3B32' }}>Ваш заказ</h2>
|
||||||
|
<button onClick={() => setIsCartOpen(false)} style={{ background: '#FFFFFF', border: 'none', width: 48, height: 48, borderRadius: '50%', cursor: 'pointer', color: '#4CAF50', boxShadow: '0 2px 10px rgba(0,0,0,0.05)' }}>
|
||||||
|
<FaTimes size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
{entries.map(([idStr, qty]) => {
|
||||||
|
const id = Number(idStr);
|
||||||
|
const it = allItems[id];
|
||||||
|
if (!it) return null;
|
||||||
|
const cat = categories.find((c) => c.items.some((i) => i.id === id));
|
||||||
|
const emoji = CATEGORY_EMOJI_FALLBACK[cat?.id || 0] || '🍽️';
|
||||||
|
return (
|
||||||
|
<div key={id} style={cartItemStyle}>
|
||||||
|
<div style={cartItemImgStyle}>
|
||||||
|
{it.imageUrl && !imageErrors[it.id] ? (
|
||||||
|
<img
|
||||||
|
src={`${API_BASE}${it.imageUrl}`}
|
||||||
|
alt={it.name}
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
onError={() => {
|
||||||
|
setImageErrors(prev => ({ ...prev, [it.id]: true }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
emoji
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={cartItemNameStyle}>{it.name}</div>
|
||||||
|
<div style={cartItemPriceStyle}>{it.price.toFixed(2)} BYN</div>
|
||||||
|
</div>
|
||||||
|
<div style={qtyControlsStyle}>
|
||||||
|
<button onClick={() => changeQty(id, -1)} style={qtyBigBtnStyle}><FaMinus size={16} /></button>
|
||||||
|
<span style={qtyNumStyle}>{qty}</span>
|
||||||
|
<button onClick={() => changeQty(id, 1)} style={qtyBigBtnStyle}><FaPlus size={16} /></button>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 800, color: '#2E3B32', minWidth: 80, textAlign: 'right' }}>
|
||||||
|
{(it.price * qty).toFixed(2)}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => removeItem(id)} style={removeBtnStyle}><FaTrashAlt size={20} /></button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '24px', borderTop: '1px solid #E0E6ED', background: '#F7F9F6' }}>
|
||||||
|
{error && <div style={errorStyle}>{error}</div>}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 700, color: '#546E7A' }}>Итого:</div>
|
||||||
|
<div style={{ fontSize: 40, fontWeight: 800, color: '#2E3B32' }}>
|
||||||
|
{cartTotal.toFixed(2)} <span style={{ fontSize: 20, color: '#78909C' }}>BYN</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{notEnough && <div style={{ fontSize: 16, color: '#E74C3C', marginBottom: 16, fontWeight: 600, textAlign: 'center' }}>Недостаточно средств (баланс {balance.toFixed(2)})</div>}
|
||||||
|
<button
|
||||||
|
onClick={submitOrder}
|
||||||
|
disabled={sending || notEnough || entries.length === 0}
|
||||||
|
style={{
|
||||||
|
...submitBtnStyle,
|
||||||
|
background: sending || notEnough || entries.length === 0 ? '#B0BEC5' : 'linear-gradient(135deg, #81C784 0%, #4CAF50 100%)',
|
||||||
|
cursor: sending || notEnough || entries.length === 0 ? 'not-allowed' : 'pointer',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sending ? <><FaSpinner size={24} style={{ animation: 'spin 1s linear infinite' }} /> Отправка…</> : <><FaPaperPlane size={24} /> Отправить заказ</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* ============ ВЫЕЗЖАЮЩАЯ ПАНЕЛЬ "МОИ ЗАКАЗЫ" ============ */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showMyOrders && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||||
|
style={{ position: 'fixed', inset: 0, background: 'rgba(46,59,50,0.4)', backdropFilter: 'blur(4px)', zIndex: 200 }}
|
||||||
|
onClick={() => setShowMyOrders(false)}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }}
|
||||||
|
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
||||||
|
style={{
|
||||||
|
position: 'fixed', top: 0, right: 0, bottom: 0, width: 540,
|
||||||
|
background: '#FFFFFF', boxShadow: '-10px 0 40px rgba(0,0,0,0.1)',
|
||||||
|
zIndex: 201, display: 'flex', flexDirection: 'column',
|
||||||
|
paddingBottom: BOTTOM_BAR_HEIGHT
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '24px', borderBottom: '1px solid #E0E6ED', background: '#E2F0D9' }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 28, fontWeight: 800, color: '#2E3B32' }}>Мои заказы</h2>
|
||||||
|
<button onClick={() => setShowMyOrders(false)} style={{ background: '#FFFFFF', border: 'none', width: 48, height: 48, borderRadius: '50%', cursor: 'pointer', color: '#4CAF50', boxShadow: '0 2px 10px rgba(0,0,0,0.05)' }}>
|
||||||
|
<FaTimes size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
{loadingOrders ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: 40, color: '#78909C' }}>Загрузка...</div>
|
||||||
|
) : myOrders.length === 0 ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: 40, color: '#78909C', fontSize: 18 }}>У вас пока нет заказов</div>
|
||||||
|
) : (
|
||||||
|
myOrders.map((order) => {
|
||||||
|
let statusColor = '#78909C';
|
||||||
|
let statusText = order.status;
|
||||||
|
if (order.status === 'new') { statusColor = '#F39C12'; statusText = 'В обработке'; }
|
||||||
|
if (order.status === 'accepted') { statusColor = '#3498DB'; statusText = 'Готовится'; }
|
||||||
|
if (order.status === 'done') { statusColor = '#2ECC71'; statusText = 'Готов'; }
|
||||||
|
if (order.status === 'cancelled') { statusColor = '#E74C3C'; statusText = 'Отменен'; }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={order.id} style={{ background: '#F7F9F6', borderRadius: 16, padding: 20, border: '1px solid #E0E6ED' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||||
|
<div style={{ fontWeight: 800, fontSize: 18, color: '#2E3B32' }}>Заказ #{order.id}</div>
|
||||||
|
<div style={{
|
||||||
|
padding: '4px 12px', borderRadius: 20, fontSize: 14, fontWeight: 700,
|
||||||
|
background: `${statusColor}20`, color: statusColor,
|
||||||
|
border: `1px solid ${statusColor}50`
|
||||||
|
}}>
|
||||||
|
{statusText}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 14, color: '#78909C', marginBottom: 12 }}>
|
||||||
|
{new Date(order.createdAt).toLocaleString('ru-RU')}
|
||||||
|
</div>
|
||||||
|
<ul style={{ margin: 0, paddingLeft: 20, fontSize: 16, color: '#2E3B32', marginBottom: 12 }}>
|
||||||
|
{order.items.map((it: any, i: number) => (
|
||||||
|
<li key={i}>{it.name} <span style={{ color: '#78909C' }}>x{it.quantity}</span></li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12, paddingTop: 12, borderTop: '1px solid #E0E6ED' }}>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 800, color: '#4CAF50' }}>
|
||||||
|
{order.totalAmount.toFixed(2)} BYN
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{order.status === 'new' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleCancelClick(order.id, order.totalAmount)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
background: '#FFEBEE',
|
||||||
|
flex: '0 0 100px',
|
||||||
|
color: '#E74C3C',
|
||||||
|
border: '2px solid #E74C3C',
|
||||||
|
borderRadius: 8,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 700,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaTimes size={16} /> Отменить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* ============ МИНИ-МОДАЛКА ПОДТВЕРЖДЕНИЯ ОТМЕНЫ ============ */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{cancelConfirm && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||||
|
style={{
|
||||||
|
position: 'fixed', inset: 0, background: 'rgba(46,59,50,0.5)', backdropFilter: 'blur(4px)',
|
||||||
|
zIndex: 300, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.9, y: 20 }} animate={{ scale: 1, y: 0 }} exit={{ scale: 0.9, y: 20 }}
|
||||||
|
style={{
|
||||||
|
background: '#FFFFFF', borderRadius: 20, padding: '32px',
|
||||||
|
maxWidth: 460, width: '100%', textAlign: 'center',
|
||||||
|
boxShadow: '0 24px 80px rgba(0,0,0,0.2)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
width: 64, height: 64, borderRadius: '50%',
|
||||||
|
background: '#FFEBEE', color: '#E74C3C',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
margin: '0 auto 16px', fontSize: 28
|
||||||
|
}}>
|
||||||
|
<FaTimes />
|
||||||
|
</div>
|
||||||
|
<h3 style={{ margin: '0 0 8px', fontSize: 22, fontWeight: 800, color: '#2E3B32' }}>
|
||||||
|
Отменить заказ?
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: '0 0 24px', color: '#607D8B', fontSize: 16 }}>
|
||||||
|
Заказ #{cancelConfirm.id} будет отменён.<br />
|
||||||
|
<b style={{ color: '#2E3B32' }}>{cancelConfirm.total.toFixed(2)} BYN</b> вернутся на баланс.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setCancelConfirm(null)}
|
||||||
|
style={{
|
||||||
|
flex: 1, padding: '14px', border: '2px solid #E0E6ED',
|
||||||
|
background: '#FFFFFF', color: '#546E7A', borderRadius: 12,
|
||||||
|
fontSize: 16, fontWeight: 700, cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={confirmCancelOrder}
|
||||||
|
style={{
|
||||||
|
flex: 1, padding: '14px',
|
||||||
|
background: 'linear-gradient(135deg, #EF5350 0%, #E74C3C 100%)',
|
||||||
|
color: '#FFFFFF', border: 'none', borderRadius: 12,
|
||||||
|
fontSize: 16, fontWeight: 700, cursor: 'pointer',
|
||||||
|
boxShadow: '0 8px 24px rgba(231,76,60,0.3)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Да, отменить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<style>{keyframes}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// СТИЛИ (FRESH MINT + КНОПКА 10%)
|
||||||
|
// ============================================================
|
||||||
|
const keyframes = `@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }`;
|
||||||
|
|
||||||
|
const loadingStyle: React.CSSProperties = { position: 'fixed', inset: 0, background: '#F7F9F6', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 50 };
|
||||||
|
const spinnerStyle: React.CSSProperties = { width: 60, height: 60, border: '5px solid #E2F0D9', borderTop: '5px solid #4CAF50', borderRadius: '50%', animation: 'spin 1s linear infinite' };
|
||||||
|
|
||||||
|
const menuRootStyle: React.CSSProperties = {
|
||||||
|
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||||
|
paddingTop: 90, paddingBottom: BOTTOM_BAR_HEIGHT,
|
||||||
|
background: '#F7F9F6', display: 'flex', flexDirection: 'column', overflow: 'hidden', boxSizing: 'border-box',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Шапка
|
||||||
|
const headerZoneStyle: React.CSSProperties = {
|
||||||
|
padding: '16px 24px',
|
||||||
|
background: '#E2F0D9',
|
||||||
|
borderBottom: '1px solid #C8E6C9',
|
||||||
|
flexShrink: 0, zIndex: 10, boxShadow: '0 4px 16px rgba(76,175,80,0.05)',
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 16,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Первая строка шапки
|
||||||
|
const headerRow1Style: React.CSSProperties = {
|
||||||
|
display: 'flex', alignItems: 'center', gap: 20, justifyContent: 'flex-start'
|
||||||
|
};
|
||||||
|
const btnRectBackStyle: React.CSSProperties = {
|
||||||
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||||
|
flex: '0 0 100px',
|
||||||
|
height: 52,
|
||||||
|
background: '#FFFFFF', color: '#4CAF50',
|
||||||
|
border: 'none', borderRadius: 12, cursor: 'pointer',
|
||||||
|
fontSize: 16, fontWeight: 800, flexShrink: 0,
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.05)', transition: 'all 0.2s',
|
||||||
|
};
|
||||||
|
const companyTitleStyle: React.CSSProperties = {
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12,
|
||||||
|
fontSize: 36, fontWeight: 800, color: '#2E3B32',
|
||||||
|
letterSpacing: '-0.5px'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Вторая строка шапки (Сотрудник + Баланс)
|
||||||
|
const headerRow2Style: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap' };
|
||||||
|
const headerCardStyle: React.CSSProperties = {
|
||||||
|
flex: '1 1 220px', display: 'flex', alignItems: 'center', gap: 14, padding: '12px 20px',
|
||||||
|
background: '#FFFFFF', borderRadius: 16, minWidth: 0, boxShadow: '0 2px 12px rgba(0,0,0,0.03)',
|
||||||
|
};
|
||||||
|
const headerLabelStyle: React.CSSProperties = { fontSize: 12, color: '#607D8B', textTransform: 'uppercase', letterSpacing: 0.5, fontWeight: 800 };
|
||||||
|
const headerValueStyle: React.CSSProperties = { fontSize: 18, fontWeight: 700, color: '#2E3B32', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', marginTop: 2 };
|
||||||
|
const avatarCircleStyle: React.CSSProperties = { width: 44, height: 44, borderRadius: '50%', background: '#E2F0D9', color: '#4CAF50', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 18, fontWeight: 800, flexShrink: 0 };
|
||||||
|
|
||||||
|
|
||||||
|
const mainZoneStyle: React.CSSProperties = { flex: 1, display: 'flex', minHeight: 0, overflow: 'hidden' };
|
||||||
|
|
||||||
|
// Категории
|
||||||
|
const categoriesPanelStyle: React.CSSProperties = {
|
||||||
|
width: 220, background: '#FFFFFF', borderRight: '1px solid #E0E6ED',
|
||||||
|
display: 'flex', flexDirection: 'column', padding: '16px 0', overflowY: 'auto', flexShrink: 0,
|
||||||
|
};
|
||||||
|
const categoriesTitleStyle: React.CSSProperties = { fontSize: 13, fontWeight: 800, color: '#607D8B', padding: '0 24px 16px', letterSpacing: 0.5 };
|
||||||
|
const categoriesListStyle: React.CSSProperties = { flex: 1, overflowY: 'auto' };
|
||||||
|
const categoryBtnStyle: React.CSSProperties = {
|
||||||
|
width: '100%', padding: '20px 24px', border: 'none', borderRadius: 0, cursor: 'pointer',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 16, textAlign: 'left', transition: 'all 0.2s',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Сетка блюд
|
||||||
|
const itemsPanelStyle: React.CSSProperties = {
|
||||||
|
flex: 1, display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
|
||||||
|
gap: 20, alignContent: 'start', overflowY: 'auto', padding: '24px', minHeight: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemCardStyle: React.CSSProperties = {
|
||||||
|
background: '#FFFFFF', borderRadius: 20, overflow: 'hidden',
|
||||||
|
boxShadow: '0 4px 20px rgba(0,0,0,0.03)', display: 'flex', flexDirection: 'column', minWidth: 0, transition: 'all 0.2s',
|
||||||
|
};
|
||||||
|
const itemImgStyle: React.CSSProperties = {
|
||||||
|
height: 140, background: '#F7F9F6',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 72, overflow: 'hidden',
|
||||||
|
};
|
||||||
|
const itemInfoStyle: React.CSSProperties = { padding: '16px', flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 };
|
||||||
|
const itemNameStyle: React.CSSProperties = { fontSize: 17, fontWeight: 800, color: '#2E3B32', lineHeight: 1.3 };
|
||||||
|
const itemDescStyle: React.CSSProperties = {
|
||||||
|
fontSize: 13, color: '#78909C', marginTop: 6,
|
||||||
|
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden',
|
||||||
|
};
|
||||||
|
const itemBottomStyle: React.CSSProperties = { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 16, gap: 8, flexWrap: 'wrap' };
|
||||||
|
const itemPriceStyle: React.CSSProperties = { fontSize: 20, fontWeight: 800, color: '#4CAF50' };
|
||||||
|
|
||||||
|
const qtyInlineStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 4, background: '#F0F4F8', borderRadius: 999, padding: '4px' };
|
||||||
|
const qtyItemBtnStyle: React.CSSProperties = {
|
||||||
|
width: 40, height: 40, borderRadius: '50%', background: '#FFFFFF', border: 'none',
|
||||||
|
cursor: 'pointer', color: '#4CAF50', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
flexShrink: 0, boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
|
||||||
|
};
|
||||||
|
const qtyInlineNumStyle: React.CSSProperties = { minWidth: 32, textAlign: 'center', fontWeight: 800, fontSize: 18, color: '#2E3B32' };
|
||||||
|
|
||||||
|
const cartBarStyle: React.CSSProperties = {
|
||||||
|
position: 'fixed', bottom: BOTTOM_BAR_HEIGHT + 12, left: '50%', transform: 'translateX(-50%)',
|
||||||
|
background: '#2E3B32', color: '#fff', borderRadius: 999, padding: '10px 16px 10px 12px',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 32, boxShadow: '0 12px 40px rgba(46,59,50,0.3)',
|
||||||
|
cursor: 'pointer', zIndex: 100, minWidth: 380, justifyContent: 'space-between',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Выезжающая корзина
|
||||||
|
const cartItemStyle: React.CSSProperties = {
|
||||||
|
background: '#FFFFFF', borderRadius: 16, padding: 16, display: 'flex', alignItems: 'center', gap: 16,
|
||||||
|
border: '1px solid #F0F4F8',
|
||||||
|
};
|
||||||
|
const cartItemImgStyle: React.CSSProperties = {
|
||||||
|
width: 60, height: 60, borderRadius: 12, background: '#F7F9F6',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 32, overflow: 'hidden', flexShrink: 0,
|
||||||
|
};
|
||||||
|
const cartItemNameStyle: React.CSSProperties = { fontSize: 16, fontWeight: 700, color: '#2E3B32', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' };
|
||||||
|
const cartItemPriceStyle: React.CSSProperties = { fontSize: 14, color: '#78909C', marginTop: 4, fontWeight: 500 };
|
||||||
|
const qtyControlsStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 8, background: '#F0F4F8', borderRadius: 999, padding: '4px 6px' };
|
||||||
|
const qtyBigBtnStyle: React.CSSProperties = {
|
||||||
|
width: 36, height: 36, borderRadius: '50%', background: '#FFFFFF', border: 'none',
|
||||||
|
cursor: 'pointer', color: '#2E3B32', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
|
||||||
|
};
|
||||||
|
const qtyNumStyle: React.CSSProperties = { minWidth: 28, textAlign: 'center', fontWeight: 800, fontSize: 16, color: '#2E3B32' };
|
||||||
|
const removeBtnStyle: React.CSSProperties = { background: 'transparent', border: 'none', color: '#EF5350', cursor: 'pointer', padding: 8 };
|
||||||
|
|
||||||
|
const submitBtnStyle: React.CSSProperties = {
|
||||||
|
color: '#fff', border: 'none', borderRadius: 16, padding: '20px 40px',
|
||||||
|
fontSize: 22, fontWeight: 800, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 12,
|
||||||
|
boxShadow: '0 8px 24px rgba(76,175,80,0.3)', transition: 'all 0.2s',
|
||||||
|
};
|
||||||
|
const errorStyle: React.CSSProperties = { background: '#FFEBEE', color: '#C62828', padding: '12px 18px', borderRadius: 12, fontSize: 16, marginBottom: 16, fontWeight: 600, textAlign: 'center' };
|
||||||
|
|
||||||
|
// SUCCESS
|
||||||
|
const successWrapperStyle: React.CSSProperties = { position: 'fixed', inset: 0, background: 'rgba(46,59,50,0.7)', backdropFilter: 'blur(8px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 300, padding: 20 };
|
||||||
|
const successCardStyle: React.CSSProperties = { background: '#FFFFFF', borderRadius: 32, padding: '56px 64px', textAlign: 'center', maxWidth: 600, boxShadow: '0 24px 80px rgba(0,0,0,0.2)' };
|
||||||
|
const successBtnStyle: React.CSSProperties = { padding: '20px 64px', background: 'linear-gradient(135deg, #81C784 0%, #4CAF50 100%)', color: '#fff', border: 'none', borderRadius: 16, fontSize: 22, fontWeight: 800, cursor: 'pointer', boxShadow: '0 8px 24px rgba(76,175,80,0.3)' };
|
||||||
|
|
||||||
|
export default FoodMenu;
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import config from "../../src/config.json";
|
import { motion } from 'framer-motion';
|
||||||
|
import config from '../config.json';
|
||||||
|
|
||||||
interface GuestModeProps {
|
interface GuestModeProps {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}
|
}
|
||||||
@@ -176,39 +178,136 @@ const GuestMode: React.FC<GuestModeProps> = ({ onBack }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="guest-mode">
|
<motion.div
|
||||||
<div className="top-bar">
|
className="guest-mode"
|
||||||
<button className="back-button" onClick={onBack}>
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
style={{
|
||||||
|
maxWidth: '1200px',
|
||||||
|
margin: '20px auto',
|
||||||
|
padding: '30px',
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '16px',
|
||||||
|
boxShadow: '0 10px 40px rgba(0, 0, 0, 0.08)',
|
||||||
|
minHeight: '80vh',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="top-bar" style={{ marginBottom: '20px' }}>
|
||||||
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="guest-content">
|
<div className="guest-content">
|
||||||
<h2>Гостевой режим</h2>
|
<h2 style={{
|
||||||
<div className="tab-navigation">
|
textAlign: 'center',
|
||||||
|
color: '#2d3436',
|
||||||
|
fontSize: '28px',
|
||||||
|
fontWeight: 300,
|
||||||
|
marginBottom: '30px',
|
||||||
|
}}>
|
||||||
|
Гостевой режим
|
||||||
|
</h2>
|
||||||
|
<div className="tab-navigation" style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '10px',
|
||||||
|
marginBottom: '25px',
|
||||||
|
borderBottom: '2px solid #f0f0f0',
|
||||||
|
paddingBottom: '10px',
|
||||||
|
}}>
|
||||||
<button
|
<button
|
||||||
className={`tab-button ${activeTab === 'handbook' ? 'active' : ''}`}
|
className={`tab-button ${activeTab === 'handbook' ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab('handbook')}
|
onClick={() => setActiveTab('handbook')}
|
||||||
|
style={{
|
||||||
|
padding: '12px 28px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: activeTab === 'handbook' ? 600 : 400,
|
||||||
|
color: activeTab === 'handbook' ? '#6c5ce7' : '#636e72',
|
||||||
|
borderBottom: activeTab === 'handbook' ? '3px solid #6c5ce7' : '3px solid transparent',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Справочник
|
Справочник
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`tab-button ${activeTab === 'union' ? 'active' : ''}`}
|
className={`tab-button ${activeTab === 'union' ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab('union')}
|
onClick={() => setActiveTab('union')}
|
||||||
|
style={{
|
||||||
|
padding: '12px 28px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: activeTab === 'union' ? 600 : 400,
|
||||||
|
color: activeTab === 'union' ? '#6c5ce7' : '#636e72',
|
||||||
|
borderBottom: activeTab === 'union' ? '3px solid #6c5ce7' : '3px solid transparent',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Профсоюз
|
Профсоюз
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`tab-button ${activeTab === 'info' ? 'active' : ''}`}
|
className={`tab-button ${activeTab === 'info' ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab('info')}
|
onClick={() => setActiveTab('info')}
|
||||||
|
style={{
|
||||||
|
padding: '12px 28px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: activeTab === 'info' ? 600 : 400,
|
||||||
|
color: activeTab === 'info' ? '#6c5ce7' : '#636e72',
|
||||||
|
borderBottom: activeTab === 'info' ? '3px solid #6c5ce7' : '3px solid transparent',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Общая информация
|
Общая информация
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="tab-content">
|
<div className="tab-content" style={{ minHeight: '400px' }}>
|
||||||
{activeTab === 'handbook' && (
|
{activeTab === 'handbook' && (
|
||||||
<div className="handbook-content">
|
<div className="handbook-content">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="loading">Загрузка справочника...</div>
|
<div className="loading" style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#636e72',
|
||||||
|
padding: '40px',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: '40px',
|
||||||
|
height: '40px',
|
||||||
|
border: '3px solid #f3f3f3',
|
||||||
|
borderTop: '3px solid #6c5ce7',
|
||||||
|
borderRadius: '50%',
|
||||||
|
animation: 'spin 1s linear infinite',
|
||||||
|
}} />
|
||||||
|
<p>Загрузка справочника...</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<HandbookBlocks html={handbookContent} />
|
<HandbookBlocks html={handbookContent} />
|
||||||
)}
|
)}
|
||||||
@@ -217,7 +316,22 @@ const GuestMode: React.FC<GuestModeProps> = ({ onBack }) => {
|
|||||||
{activeTab === 'union' && (
|
{activeTab === 'union' && (
|
||||||
<div className="union-content">
|
<div className="union-content">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="loading">Загрузка...</div>
|
<div className="loading" style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#636e72',
|
||||||
|
padding: '40px',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: '40px',
|
||||||
|
height: '40px',
|
||||||
|
border: '3px solid #f3f3f3',
|
||||||
|
borderTop: '3px solid #6c5ce7',
|
||||||
|
borderRadius: '50%',
|
||||||
|
animation: 'spin 1s linear infinite',
|
||||||
|
}} />
|
||||||
|
<p>Загрузка...</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
unionBlocks.length ? renderContentBlocks(unionBlocks) : <p>Нет информации о профсоюзе.</p>
|
unionBlocks.length ? renderContentBlocks(unionBlocks) : <p>Нет информации о профсоюзе.</p>
|
||||||
)}
|
)}
|
||||||
@@ -226,7 +340,22 @@ const GuestMode: React.FC<GuestModeProps> = ({ onBack }) => {
|
|||||||
{activeTab === 'info' && (
|
{activeTab === 'info' && (
|
||||||
<div className="info-content">
|
<div className="info-content">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="loading">Загрузка...</div>
|
<div className="loading" style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#636e72',
|
||||||
|
padding: '40px',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: '40px',
|
||||||
|
height: '40px',
|
||||||
|
border: '3px solid #f3f3f3',
|
||||||
|
borderTop: '3px solid #6c5ce7',
|
||||||
|
borderRadius: '50%',
|
||||||
|
animation: 'spin 1s linear infinite',
|
||||||
|
}} />
|
||||||
|
<p>Загрузка...</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
infoBlocks.length ? renderContentBlocks(infoBlocks) : <p>Нет общей информации.</p>
|
infoBlocks.length ? renderContentBlocks(infoBlocks) : <p>Нет общей информации.</p>
|
||||||
)}
|
)}
|
||||||
@@ -234,7 +363,15 @@ const GuestMode: React.FC<GuestModeProps> = ({ onBack }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<style>
|
||||||
|
{`
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
</motion.div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
interface LaborSafetyProps {
|
||||||
|
onBack: () => void;
|
||||||
|
employee: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LaborSafety: React.FC<LaborSafetyProps> = ({ onBack, employee }) => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="labor-safety-section"
|
||||||
|
style={{ padding: '20px', maxWidth: '1200px', margin: '0 auto', paddingBottom: '80px' }}
|
||||||
|
>
|
||||||
|
<div className="top-bar" style={{ marginBottom: '25px' }}>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style={{ color: '#2d3436', fontSize: '24px', fontWeight: 300, marginBottom: '30px', display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
⛑️ Охрана труда
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: '20px' }}>
|
||||||
|
<motion.div whileHover={{ y: -5 }} style={{ background: 'white', borderRadius: '16px', padding: '25px', boxShadow: '0 4px 20px rgba(0,0,0,0.06)', cursor: 'pointer', transition: 'all 0.3s ease', border: '1px solid #f1f2f6' }}>
|
||||||
|
<div style={{ fontSize: '32px', color: '#e74c3c', marginBottom: '15px' }}>⚠️</div>
|
||||||
|
<h3 style={{ margin: '0 0 10px 0', color: '#2d3436', fontSize: '18px' }}>Изменения в документации</h3>
|
||||||
|
<p style={{ color: '#636e72', fontSize: '14px', lineHeight: 1.6, margin: 0 }}>Информирование о внесённых изменениях в ОЗИ и СТП</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div whileHover={{ y: -5 }} style={{ background: 'white', borderRadius: '16px', padding: '25px', boxShadow: '0 4px 20px rgba(0,0,0,0.06)', cursor: 'pointer', transition: 'all 0.3s ease', border: '1px solid #f1f2f6' }}>
|
||||||
|
<div style={{ fontSize: '32px', color: '#3498db', marginBottom: '15px' }}>📋</div>
|
||||||
|
<h3 style={{ margin: '0 0 10px 0', color: '#2d3436', fontSize: '18px' }}>Средства индивидуальной защиты (СИЗ)</h3>
|
||||||
|
<p style={{ color: '#636e72', fontSize: '14px', lineHeight: 1.6, margin: 0 }}>График выдачи, напоминания, информация по СИЗ</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div whileHover={{ y: -5 }} style={{ background: 'white', borderRadius: '16px', padding: '25px', boxShadow: '0 4px 20px rgba(0,0,0,0.06)', cursor: 'pointer', transition: 'all 0.3s ease', border: '1px solid #f1f2f6' }}>
|
||||||
|
<div style={{ fontSize: '32px', color: '#9b59b6', marginBottom: '15px' }}>📅</div>
|
||||||
|
<h3 style={{ margin: '0 0 10px 0', color: '#2d3436', fontSize: '18px' }}>График экзаменов</h3>
|
||||||
|
<p style={{ color: '#636e72', fontSize: '14px', lineHeight: 1.6, margin: 0 }}>Просмотр графика сдачи экзаменов по охране труда</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div whileHover={{ y: -5 }} style={{ background: 'white', borderRadius: '16px', padding: '25px', boxShadow: '0 4px 20px rgba(0,0,0,0.06)', cursor: 'pointer', transition: 'all 0.3s ease', border: '1px solid #f1f2f6' }}>
|
||||||
|
<div style={{ fontSize: '32px', color: '#2ecc71', marginBottom: '15px' }}>🔔</div>
|
||||||
|
<h3 style={{ margin: '0 0 10px 0', color: '#2d3436', fontSize: '18px' }}>Уведомления</h3>
|
||||||
|
<p style={{ color: '#636e72', fontSize: '14px', lineHeight: 1.6, margin: 0 }}>Напоминания о своевременном получении СИЗ</p>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '30px', padding: '20px', background: '#f8f9fa', borderRadius: '12px', border: '1px solid #f1f2f6', textAlign: 'center', color: '#636e72' }}>
|
||||||
|
<p style={{ margin: 0 }}>ℹ️ Раздел в разработке. Функционал будет доступен в ближайшее время.</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LaborSafety;
|
||||||
@@ -1,49 +1,89 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
interface LoginWindowProps {
|
interface LoginWindowProps {
|
||||||
onLogin: (id: string) => void;
|
onLogin: (id: string) => void;
|
||||||
onGuestMode: () => void;
|
onGuestMode: () => void;
|
||||||
loginError?: string;
|
loginError?: string;
|
||||||
|
inputRef?: React.RefObject<HTMLInputElement>; // ✅ Реф для управления фокусом
|
||||||
}
|
}
|
||||||
const LoginWindow: React.FC<LoginWindowProps> = ({ onLogin, onGuestMode, loginError }) => {
|
|
||||||
|
const LoginWindow: React.FC<LoginWindowProps> = ({
|
||||||
|
onLogin,
|
||||||
|
onGuestMode,
|
||||||
|
loginError,
|
||||||
|
inputRef
|
||||||
|
}) => {
|
||||||
const [id, setId] = useState('');
|
const [id, setId] = useState('');
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const [isCardDetected, setIsCardDetected] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const internalInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const activeInputRef = inputRef || internalInputRef;
|
||||||
|
|
||||||
|
// ✅ Фокус при монтировании
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeInputRef.current) {
|
||||||
|
setTimeout(() => {
|
||||||
|
activeInputRef.current?.focus();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}, [activeInputRef]);
|
||||||
|
|
||||||
|
// ✅ При ошибке — очищаем поле и ставим фокус
|
||||||
|
useEffect(() => {
|
||||||
|
if (loginError) {
|
||||||
|
setId(''); // ✅ Очищаем поле
|
||||||
|
setIsLoading(false);
|
||||||
|
if (activeInputRef.current) {
|
||||||
|
setTimeout(() => {
|
||||||
|
activeInputRef.current?.focus();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [loginError, activeInputRef]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const testConnection = async () => {
|
const testConnection = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('http://localhost:3001/api/test', {
|
const response = await fetch('http://localhost:3001/api/test', {
|
||||||
mode: 'cors', // Явно указываем режим CORS
|
mode: 'cors',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log('Backend response:', data);
|
console.log('Backend response:', data);
|
||||||
} else {
|
} else {
|
||||||
console.error('Backend returned error status:', response.status);
|
console.error('Backend returned error status:', response.status);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Connection test failed:', err);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
console.error('Connection test failed:', err);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
testConnection();
|
testConnection();
|
||||||
|
|
||||||
const setupCardListener = () => {
|
const setupCardListener = () => {
|
||||||
console.log('[LoginWindow] Setting up card listener...');
|
console.log('[LoginWindow] Setting up card listener...');
|
||||||
const handleCardDetected = (uid: string) => {
|
const handleCardDetected = (uid: string) => {
|
||||||
console.log('Card detected in renderer via preload:', uid);
|
console.log('Card detected in renderer via preload:', uid);
|
||||||
|
setIsCardDetected(true);
|
||||||
onLogin(uid);
|
onLogin(uid);
|
||||||
|
setTimeout(() => setIsCardDetected(false), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.electronAPI.onCardDetected(handleCardDetected);
|
if (window.electronAPI) {
|
||||||
|
window.electronAPI.onCardDetected(handleCardDetected);
|
||||||
|
} else {
|
||||||
|
console.log('[LoginWindow] electronAPI not ready, waiting for event...');
|
||||||
|
window.addEventListener('electronApiReady', setupCardListener, { once: true });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if (window.electronAPI) {
|
|
||||||
setupCardListener();
|
setupCardListener();
|
||||||
} else {
|
|
||||||
console.log('[LoginWindow] electronAPI not ready, waiting for event...');
|
|
||||||
window.addEventListener('electronApiReady', setupCardListener, { once: true });
|
|
||||||
}
|
|
||||||
return () => {
|
return () => {
|
||||||
console.log('[LoginWindow] Cleaning up listeners.');
|
console.log('[LoginWindow] Cleaning up listeners.');
|
||||||
window.removeEventListener('electronApiReady', setupCardListener);
|
window.removeEventListener('electronApiReady', setupCardListener);
|
||||||
@@ -53,52 +93,241 @@ const LoginWindow: React.FC<LoginWindowProps> = ({ onLogin, onGuestMode, loginEr
|
|||||||
};
|
};
|
||||||
}, [onLogin]);
|
}, [onLogin]);
|
||||||
|
|
||||||
useEffect(() => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
if (loginError) {
|
|
||||||
setId('');
|
|
||||||
inputRef.current?.focus();
|
|
||||||
}
|
|
||||||
}, [loginError]);
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (id.trim()) {
|
const trimmedId = id.trim();
|
||||||
onLogin(id.trim());
|
|
||||||
|
if (!trimmedId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onLogin(trimmedId);
|
||||||
|
// ✅ При успешном входе — очищаем поле
|
||||||
|
setId('');
|
||||||
|
} catch (error) {
|
||||||
|
// ✅ При ошибке — очищаем поле
|
||||||
|
setId('');
|
||||||
|
setIsLoading(false);
|
||||||
|
} finally {
|
||||||
|
// ✅ В любом случае снимаем блокировку
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="login-window">
|
<motion.div
|
||||||
<h2>Вход в систему</h2>
|
className="login-window"
|
||||||
<div className="card-prompt">
|
initial={{ opacity: 0, y: 20 }}
|
||||||
<p>Пожалуйста, приложите вашу карту пропуска к считывателю</p>
|
animate={{ opacity: 1, y: 0 }}
|
||||||
<div className="card-icon">💳</div>
|
transition={{ duration: 0.6, ease: 'easeOut' }}
|
||||||
|
style={{
|
||||||
|
maxWidth: '420px',
|
||||||
|
margin: '10% auto',
|
||||||
|
padding: '40px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.98)',
|
||||||
|
borderRadius: '20px',
|
||||||
|
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.15)',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.2)',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Индикатор считывания карты */}
|
||||||
|
{isCardDetected && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.8 }}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-10px',
|
||||||
|
right: '-10px',
|
||||||
|
background: '#2ecc71',
|
||||||
|
color: '#fff',
|
||||||
|
padding: '6px 12px',
|
||||||
|
borderRadius: '20px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 600,
|
||||||
|
boxShadow: '0 4px 15px rgba(46, 204, 113, 0.4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✅ Карта считана
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h2 style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#2d3436',
|
||||||
|
fontSize: '28px',
|
||||||
|
fontWeight: 300,
|
||||||
|
marginBottom: '10px',
|
||||||
|
letterSpacing: '1px',
|
||||||
|
}}>
|
||||||
|
Инфокиоск
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#636e72',
|
||||||
|
fontSize: '14px',
|
||||||
|
marginBottom: '30px',
|
||||||
|
opacity: 0.7,
|
||||||
|
}}>
|
||||||
|
Приложите карту пропуска
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: '20px',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '64px',
|
||||||
|
animation: 'pulse 2s ease-in-out infinite',
|
||||||
|
filter: isCardDetected ? 'drop-shadow(0 0 20px rgba(46, 204, 113, 0.5))' : 'none',
|
||||||
|
}}>
|
||||||
|
💳
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="input-group">
|
<div className="input-group" style={{ marginBottom: '15px' }}>
|
||||||
<label htmlFor="id"></label>
|
<label htmlFor="id" style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '8px',
|
||||||
|
color: '#636e72',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 500,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
}}>
|
||||||
|
Введите ID или приложите карту
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={activeInputRef}
|
||||||
type="password"
|
type="password"
|
||||||
id="id"
|
id="id"
|
||||||
value={id}
|
value={id}
|
||||||
onChange={(e) => setId(e.target.value)}
|
onChange={(e) => setId(e.target.value)}
|
||||||
placeholder=" "
|
placeholder="Введите табельный номер..."
|
||||||
autoFocus
|
autoFocus
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '14px 16px',
|
||||||
|
border: `2px solid ${loginError ? '#e74c3c' : '#dfe6e9'}`,
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontSize: '16px',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
background: '#f8f9fa',
|
||||||
|
outline: 'none',
|
||||||
|
}}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.target.style.borderColor = '#6c5ce7';
|
||||||
|
e.target.style.boxShadow = '0 0 0 4px rgba(108, 92, 231, 0.1)';
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.target.style.borderColor = loginError ? '#e74c3c' : '#dfe6e9';
|
||||||
|
e.target.style.boxShadow = 'none';
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{loginError && <div className="error" style={{ color: 'red', marginTop: 8 }}>{loginError}</div>}
|
|
||||||
<div className="button-group">
|
{loginError && (
|
||||||
<button type="submit" disabled={!id.trim()}>
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
style={{
|
||||||
|
color: '#e74c3c',
|
||||||
|
fontSize: '13px',
|
||||||
|
marginTop: '-10px',
|
||||||
|
marginBottom: '15px',
|
||||||
|
background: '#fde8e8',
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid #f5c6cb',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⚠️ {loginError}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '12px',
|
||||||
|
marginTop: '20px',
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!id.trim()}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '14px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: id.trim() ? 'pointer' : 'not-allowed',
|
||||||
|
background: id.trim() ? 'linear-gradient(135deg, #6c5ce7, #a29bfe)' : '#dfe6e9',
|
||||||
|
color: id.trim() ? '#fff' : '#b2bec3',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
boxShadow: id.trim() ? '0 4px 15px rgba(108, 92, 231, 0.3)' : 'none',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (id.trim()) {
|
||||||
|
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(0)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
Войти
|
Войти
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={onGuestMode}>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onGuestMode}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '14px',
|
||||||
|
border: '2px solid #dfe6e9',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: 'transparent',
|
||||||
|
color: '#636e72',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = '#6c5ce7';
|
||||||
|
e.currentTarget.style.color = '#6c5ce7';
|
||||||
|
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = '#dfe6e9';
|
||||||
|
e.currentTarget.style.color = '#636e72';
|
||||||
|
e.currentTarget.style.transform = 'translateY(0)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
Гостевой режим
|
Гостевой режим
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
|
<div style={{
|
||||||
|
marginTop: '20px',
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#b2bec3',
|
||||||
|
borderTop: '1px solid #f1f2f6',
|
||||||
|
paddingTop: '15px',
|
||||||
|
}}>
|
||||||
|
<span>Для автоматического входа приложите карту к считывателю</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,863 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
FaArrowLeft,
|
||||||
|
FaSpinner,
|
||||||
|
FaPlus,
|
||||||
|
FaMinus,
|
||||||
|
FaHome,
|
||||||
|
FaArrowUp,
|
||||||
|
FaArrowDown,
|
||||||
|
FaArrowLeft as FaArrowLeftIcon,
|
||||||
|
FaArrowRight,
|
||||||
|
FaSearch,
|
||||||
|
FaTimes,
|
||||||
|
FaMapMarkerAlt
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
|
||||||
|
interface MapViewerProps {
|
||||||
|
fileUrl: string;
|
||||||
|
onBack: () => void;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MapMarker {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MapViewer: React.FC<MapViewerProps> = ({
|
||||||
|
fileUrl,
|
||||||
|
onBack,
|
||||||
|
title = 'Карта предприятия'
|
||||||
|
}) => {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [zoom, setZoom] = useState(1);
|
||||||
|
const [minZoom, setMinZoom] = useState(1);
|
||||||
|
const [showControls, setShowControls] = useState(true);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [showSearch, setShowSearch] = useState(false);
|
||||||
|
const [searchResults, setSearchResults] = useState<MapMarker[]>([]);
|
||||||
|
|
||||||
|
// 📐 Реальные размеры изображения
|
||||||
|
const [imgWidth, setImgWidth] = useState(0);
|
||||||
|
const [imgHeight, setImgHeight] = useState(0);
|
||||||
|
const [imageLoaded, setImageLoaded] = useState(false);
|
||||||
|
|
||||||
|
// 📍 Панорамирование
|
||||||
|
const [offsetX, setOffsetX] = useState(0);
|
||||||
|
const [offsetY, setOffsetY] = useState(0);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [dragStartX, setDragStartX] = useState(0);
|
||||||
|
const [dragStartY, setDragStartY] = useState(0);
|
||||||
|
const [startOffsetX, setStartOffsetX] = useState(0);
|
||||||
|
const [startOffsetY, setStartOffsetY] = useState(0);
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const imageRef = useRef<HTMLImageElement>(null);
|
||||||
|
|
||||||
|
// 📍 Тестовые метки
|
||||||
|
const markers: MapMarker[] = [
|
||||||
|
{ id: '1', name: 'Главный вход', x: 50, y: 20, description: 'Вход для посетителей' },
|
||||||
|
{ id: '2', name: 'Проходная', x: 30, y: 50, description: 'КПП №1' },
|
||||||
|
{ id: '3', name: 'Склад', x: 70, y: 70, description: 'Склад готовой продукции' },
|
||||||
|
{ id: '4', name: 'Столовая', x: 45, y: 80, description: 'Столовая №2' },
|
||||||
|
{ id: '5', name: 'Администрация', x: 20, y: 30, description: 'Главный офис' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 📄 Загрузка изображения
|
||||||
|
useEffect(() => {
|
||||||
|
const loadImage = () => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
setImgWidth(img.width);
|
||||||
|
setImgHeight(img.height);
|
||||||
|
setImageLoaded(true);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
setError('Не удалось загрузить изображение');
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
img.src = fileUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
loadImage();
|
||||||
|
}, [fileUrl]);
|
||||||
|
|
||||||
|
// ✅ Расчет минимального масштаба ПОСЛЕ загрузки изображения и рендера контейнера
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imageLoaded || !containerRef.current) return;
|
||||||
|
|
||||||
|
const calculateMinZoom = () => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container || imgHeight === 0) return;
|
||||||
|
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const newMinZoom = rect.height / imgHeight;
|
||||||
|
setMinZoom(newMinZoom);
|
||||||
|
setZoom(newMinZoom);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ждем один кадр, чтобы DOM обновился
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
calculateMinZoom();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Также пересчитываем при ресайзе
|
||||||
|
const handleResize = () => {
|
||||||
|
if (imgHeight > 0 && containerRef.current) {
|
||||||
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
|
const newMinZoom = rect.height / imgHeight;
|
||||||
|
setMinZoom(newMinZoom);
|
||||||
|
setZoom(prev => Math.max(prev, newMinZoom));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
|
}, [imageLoaded, imgHeight]);
|
||||||
|
|
||||||
|
// 🔄 Масштабирование
|
||||||
|
const handleZoomIn = () => {
|
||||||
|
setZoom(prev => Math.min(prev + 0.2, 3));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleZoomOut = () => {
|
||||||
|
setZoom(prev => Math.max(prev - 0.2, minZoom));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleZoomReset = () => {
|
||||||
|
setZoom(minZoom);
|
||||||
|
setOffsetX(0);
|
||||||
|
setOffsetY(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 📍 Получение границ
|
||||||
|
const getBounds = useCallback(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container || imgWidth === 0 || imgHeight === 0) {
|
||||||
|
return { maxX: 0, maxY: 0 };
|
||||||
|
}
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const displayWidth = imgWidth * zoom;
|
||||||
|
const displayHeight = imgHeight * zoom;
|
||||||
|
const maxX = Math.max(0, (displayWidth - rect.width) / 2);
|
||||||
|
const maxY = Math.max(0, (displayHeight - rect.height) / 2);
|
||||||
|
return { maxX, maxY };
|
||||||
|
}, [imgWidth, imgHeight, zoom]);
|
||||||
|
|
||||||
|
// 📍 Ограничение смещения
|
||||||
|
const clampOffset = useCallback((x: number, y: number) => {
|
||||||
|
const { maxX, maxY } = getBounds();
|
||||||
|
return {
|
||||||
|
x: Math.max(-maxX, Math.min(maxX, x)),
|
||||||
|
y: Math.max(-maxY, Math.min(maxY, y))
|
||||||
|
};
|
||||||
|
}, [getBounds]);
|
||||||
|
|
||||||
|
// 📍 Панорамирование
|
||||||
|
const handlePan = useCallback((dx: number, dy: number) => {
|
||||||
|
const step = 80 / zoom;
|
||||||
|
const newX = offsetX + dx * step;
|
||||||
|
const newY = offsetY + dy * step;
|
||||||
|
const clamped = clampOffset(newX, newY);
|
||||||
|
setOffsetX(clamped.x);
|
||||||
|
setOffsetY(clamped.y);
|
||||||
|
}, [offsetX, offsetY, zoom, clampOffset]);
|
||||||
|
|
||||||
|
// 🖱️ Drag
|
||||||
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
setIsDragging(true);
|
||||||
|
setDragStartX(e.clientX);
|
||||||
|
setDragStartY(e.clientY);
|
||||||
|
setStartOffsetX(offsetX);
|
||||||
|
setStartOffsetY(offsetY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (e: React.MouseEvent) => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
const dx = e.clientX - dragStartX;
|
||||||
|
const dy = e.clientY - dragStartY;
|
||||||
|
const newX = startOffsetX + dx;
|
||||||
|
const newY = startOffsetY + dy;
|
||||||
|
const clamped = clampOffset(newX, newY);
|
||||||
|
setOffsetX(clamped.x);
|
||||||
|
setOffsetY(clamped.y);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchStart = (e: React.TouchEvent) => {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
setIsDragging(true);
|
||||||
|
setDragStartX(touch.clientX);
|
||||||
|
setDragStartY(touch.clientY);
|
||||||
|
setStartOffsetX(offsetX);
|
||||||
|
setStartOffsetY(offsetY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (e: React.TouchEvent) => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const dx = touch.clientX - dragStartX;
|
||||||
|
const dy = touch.clientY - dragStartY;
|
||||||
|
const newX = startOffsetX + dx;
|
||||||
|
const newY = startOffsetY + dy;
|
||||||
|
const clamped = clampOffset(newX, newY);
|
||||||
|
setOffsetX(clamped.x);
|
||||||
|
setOffsetY(clamped.y);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 🔍 Поиск
|
||||||
|
const handleSearch = (query: string) => {
|
||||||
|
setSearchQuery(query);
|
||||||
|
if (query.trim().length > 0) {
|
||||||
|
const results = markers.filter(marker =>
|
||||||
|
marker.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||||
|
(marker.description && marker.description.toLowerCase().includes(query.toLowerCase()))
|
||||||
|
);
|
||||||
|
setSearchResults(results);
|
||||||
|
|
||||||
|
if (results.length > 0) {
|
||||||
|
const marker = results[0];
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (container) {
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const displayWidth = imgWidth * zoom;
|
||||||
|
const displayHeight = imgHeight * zoom;
|
||||||
|
const targetX = (marker.x / 100) * displayWidth - rect.width / 2;
|
||||||
|
const targetY = (marker.y / 100) * displayHeight - rect.height / 2;
|
||||||
|
const clamped = clampOffset(-targetX, -targetY);
|
||||||
|
setOffsetX(clamped.x);
|
||||||
|
setOffsetY(clamped.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSearchResults([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSearch = () => {
|
||||||
|
setSearchQuery('');
|
||||||
|
setSearchResults([]);
|
||||||
|
setShowSearch(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ⏱️ Авто-скрытие
|
||||||
|
useEffect(() => {
|
||||||
|
let timeout: NodeJS.Timeout;
|
||||||
|
const handleActivity = () => {
|
||||||
|
setShowControls(true);
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
setShowControls(false);
|
||||||
|
}, 4000);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousemove', handleActivity);
|
||||||
|
document.addEventListener('touchstart', handleActivity);
|
||||||
|
document.addEventListener('keydown', handleActivity);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', handleActivity);
|
||||||
|
document.removeEventListener('touchstart', handleActivity);
|
||||||
|
document.removeEventListener('keydown', handleActivity);
|
||||||
|
clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ⌨️ ESC
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (showSearch) {
|
||||||
|
clearSearch();
|
||||||
|
} else {
|
||||||
|
onBack();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleEscape);
|
||||||
|
return () => document.removeEventListener('keydown', handleEscape);
|
||||||
|
}, [showSearch, onBack]);
|
||||||
|
|
||||||
|
// 🎨 Рендер
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: 'rgba(0, 0, 0, 0.9)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 10000,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaSpinner style={{
|
||||||
|
fontSize: '60px',
|
||||||
|
color: '#2ecc71',
|
||||||
|
animation: 'spin 1s linear infinite',
|
||||||
|
}} />
|
||||||
|
<span style={{ color: 'white', marginTop: '20px', fontSize: '18px' }}>
|
||||||
|
Загрузка карты...
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: 'rgba(0, 0, 0, 0.9)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 10000,
|
||||||
|
padding: '20px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '64px', marginBottom: '20px' }}>🗺️</div>
|
||||||
|
<h3 style={{ color: 'white', margin: '0 0 10px 0', fontSize: '24px' }}>
|
||||||
|
Карта не найдена
|
||||||
|
</h3>
|
||||||
|
<p style={{ color: '#b2bec3', margin: '0 0 10px 0', textAlign: 'center' }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
style={{
|
||||||
|
padding: '16px 40px',
|
||||||
|
background: '#2ecc71',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Вернуться
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: '#1a1a2e',
|
||||||
|
zIndex: 10000,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* ⬅️ Кнопка Назад */}
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '16px',
|
||||||
|
left: '16px',
|
||||||
|
zIndex: 100,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
padding: '10px 18px',
|
||||||
|
background: 'rgba(0, 0, 0, 0.7)',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
color: 'white',
|
||||||
|
border: '1px solid rgba(255,255,255,0.15)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaArrowLeft style={{ fontSize: '16px' }} />
|
||||||
|
<span>Назад</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 🔍 Поле поиска — сверху справа */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '16px',
|
||||||
|
right: '16px',
|
||||||
|
zIndex: 100,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.95)',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
borderRadius: '24px',
|
||||||
|
padding: '6px 12px 6px 16px',
|
||||||
|
border: showSearch ? '2px solid #4285f4' : '2px solid transparent',
|
||||||
|
boxShadow: showSearch ? '0 4px 20px rgba(66, 133, 244, 0.3)' : '0 2px 10px rgba(0,0,0,0.2)',
|
||||||
|
minWidth: showSearch ? '280px' : '44px',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
height: '44px',
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSearch(!showSearch)}
|
||||||
|
style={{
|
||||||
|
padding: '4px',
|
||||||
|
background: 'transparent',
|
||||||
|
color: '#5f6368',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '18px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minWidth: '28px',
|
||||||
|
minHeight: '28px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaSearch />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showSearch && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ width: 0, opacity: 0 }}
|
||||||
|
animate={{ width: 'auto', opacity: 1 }}
|
||||||
|
exit={{ width: 0, opacity: 0 }}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
|
placeholder="Поиск по карте..."
|
||||||
|
autoFocus
|
||||||
|
style={{
|
||||||
|
padding: '8px 0',
|
||||||
|
background: 'transparent',
|
||||||
|
color: '#202124',
|
||||||
|
border: 'none',
|
||||||
|
outline: 'none',
|
||||||
|
fontSize: '15px',
|
||||||
|
minWidth: '180px',
|
||||||
|
width: '100%',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={clearSearch}
|
||||||
|
style={{
|
||||||
|
padding: '4px 6px',
|
||||||
|
background: 'transparent',
|
||||||
|
color: '#5f6368',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '16px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaTimes />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 📍 Результаты поиска */}
|
||||||
|
{searchResults.length > 0 && showSearch && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '68px',
|
||||||
|
right: '16px',
|
||||||
|
zIndex: 99,
|
||||||
|
background: 'rgba(255, 255, 255, 0.98)',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
borderRadius: '16px',
|
||||||
|
border: '1px solid rgba(0,0,0,0.1)',
|
||||||
|
boxShadow: '0 8px 30px rgba(0,0,0,0.2)',
|
||||||
|
padding: '8px 0',
|
||||||
|
minWidth: '300px',
|
||||||
|
maxHeight: '300px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}>
|
||||||
|
{searchResults.map((marker) => (
|
||||||
|
<button
|
||||||
|
key={marker.id}
|
||||||
|
onClick={() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (container) {
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const displayWidth = imgWidth * zoom;
|
||||||
|
const displayHeight = imgHeight * zoom;
|
||||||
|
const targetX = (marker.x / 100) * displayWidth - rect.width / 2;
|
||||||
|
const targetY = (marker.y / 100) * displayHeight - rect.height / 2;
|
||||||
|
const clamped = clampOffset(-targetX, -targetY);
|
||||||
|
setOffsetX(clamped.x);
|
||||||
|
setOffsetY(clamped.y);
|
||||||
|
}
|
||||||
|
setSearchQuery('');
|
||||||
|
setSearchResults([]);
|
||||||
|
setShowSearch(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
padding: '12px 20px',
|
||||||
|
background: 'transparent',
|
||||||
|
color: '#202124',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
width: '100%',
|
||||||
|
textAlign: 'left',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = '#f1f3f4';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'transparent';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaMapMarkerAlt style={{ color: '#ea4335', fontSize: '18px', flexShrink: 0 }} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500 }}>{marker.name}</div>
|
||||||
|
{marker.description && (
|
||||||
|
<div style={{ fontSize: '12px', color: '#5f6368' }}>
|
||||||
|
{marker.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 🗺️ Контейнер с картой */}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
background: '#1a1a2e',
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
cursor: isDragging ? 'grabbing' : 'grab',
|
||||||
|
touchAction: 'none',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
}}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseLeave={handleMouseUp}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
>
|
||||||
|
{/* ✅ TIFF изображение */}
|
||||||
|
<img
|
||||||
|
ref={imageRef}
|
||||||
|
src={fileUrl}
|
||||||
|
alt="Карта предприятия"
|
||||||
|
style={{
|
||||||
|
width: `${imgWidth * zoom}px`,
|
||||||
|
height: `${imgHeight * zoom}px`,
|
||||||
|
transform: `translate(${offsetX}px, ${offsetY}px)`,
|
||||||
|
transformOrigin: 'center center',
|
||||||
|
transition: isDragging ? 'none' : 'transform 0.3s ease',
|
||||||
|
willChange: 'transform',
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
marginLeft: `${-imgWidth * zoom / 2}px`,
|
||||||
|
marginTop: `${-imgHeight * zoom / 2}px`,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
objectFit: 'contain',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 👆 Подсказка */}
|
||||||
|
{!isDragging && showControls && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '110px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
color: 'rgba(255,255,255,0.5)',
|
||||||
|
fontSize: '13px',
|
||||||
|
textAlign: 'center',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 50,
|
||||||
|
background: 'rgba(0,0,0,0.5)',
|
||||||
|
padding: '8px 20px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
backdropFilter: 'blur(5px)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
👆 Перетащите для перемещения
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 🎮 Нижняя панель управления */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showControls && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: 100, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
exit={{ y: 100, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 100,
|
||||||
|
background: 'linear-gradient(0deg, rgba(0,0,0,0.9) 0%, transparent 100%)',
|
||||||
|
padding: '30px 16px 20px 16px',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '16px',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Кнопки панорамирования */}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '56px 56px 56px',
|
||||||
|
gridTemplateRows: '56px 56px 56px',
|
||||||
|
gap: '5px',
|
||||||
|
background: 'rgba(0,0,0,0.6)',
|
||||||
|
backdropFilter: 'blur(20px)',
|
||||||
|
padding: '8px',
|
||||||
|
borderRadius: '16px',
|
||||||
|
border: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={() => handlePan(0, 1)}
|
||||||
|
style={{
|
||||||
|
gridColumn: '2',
|
||||||
|
gridRow: '1',
|
||||||
|
padding: '4px',
|
||||||
|
background: 'rgba(255,255,255,0.10)',
|
||||||
|
color: 'white',
|
||||||
|
border: '1px solid rgba(255,255,255,0.06)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '18px',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaArrowUp />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handlePan(1, 0)}
|
||||||
|
style={{
|
||||||
|
gridColumn: '1',
|
||||||
|
gridRow: '2',
|
||||||
|
padding: '4px',
|
||||||
|
background: 'rgba(255,255,255,0.10)',
|
||||||
|
color: 'white',
|
||||||
|
border: '1px solid rgba(255,255,255,0.06)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '18px',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaArrowLeftIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleZoomReset}
|
||||||
|
style={{
|
||||||
|
gridColumn: '2',
|
||||||
|
gridRow: '2',
|
||||||
|
padding: '4px',
|
||||||
|
background: 'rgba(46, 204, 113, 0.25)',
|
||||||
|
color: '#2ecc71',
|
||||||
|
border: '1px solid rgba(46, 204, 113, 0.15)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '18px',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaHome />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handlePan(-1, 0)}
|
||||||
|
style={{
|
||||||
|
gridColumn: '3',
|
||||||
|
gridRow: '2',
|
||||||
|
padding: '4px',
|
||||||
|
background: 'rgba(255,255,255,0.10)',
|
||||||
|
color: 'white',
|
||||||
|
border: '1px solid rgba(255,255,255,0.06)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '18px',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaArrowRight />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handlePan(0, -1)}
|
||||||
|
style={{
|
||||||
|
gridColumn: '2',
|
||||||
|
gridRow: '3',
|
||||||
|
padding: '4px',
|
||||||
|
background: 'rgba(255,255,255,0.10)',
|
||||||
|
color: 'white',
|
||||||
|
border: '1px solid rgba(255,255,255,0.06)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '18px',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaArrowDown />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Кнопки масштабирования */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '6px',
|
||||||
|
background: 'rgba(0,0,0,0.6)',
|
||||||
|
backdropFilter: 'blur(20px)',
|
||||||
|
padding: '8px',
|
||||||
|
borderRadius: '16px',
|
||||||
|
border: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={handleZoomOut}
|
||||||
|
disabled={zoom <= minZoom}
|
||||||
|
style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
background: 'rgba(255,255,255,0.10)',
|
||||||
|
color: 'white',
|
||||||
|
border: '1px solid rgba(255,255,255,0.06)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
cursor: zoom > minZoom ? 'pointer' : 'default',
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: 600,
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
minWidth: '48px',
|
||||||
|
minHeight: '48px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
opacity: zoom > minZoom ? 1 : 0.3,
|
||||||
|
pointerEvents: zoom > minZoom ? 'auto' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaMinus />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span style={{
|
||||||
|
color: 'rgba(255,255,255,0.8)',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 600,
|
||||||
|
minWidth: '44px',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
{Math.round(zoom * 100)}%
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleZoomIn}
|
||||||
|
style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
background: 'rgba(255,255,255,0.10)',
|
||||||
|
color: 'white',
|
||||||
|
border: '1px solid rgba(255,255,255,0.06)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: 600,
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
minWidth: '48px',
|
||||||
|
minHeight: '48px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaPlus />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MapViewer;
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
interface MedicineProps {
|
||||||
|
onBack: () => void;
|
||||||
|
employee: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Medicine: React.FC<MedicineProps> = ({ onBack, employee }) => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="medicine-section"
|
||||||
|
style={{ padding: '20px', maxWidth: '1200px', margin: '0 auto', paddingBottom: '80px' }}
|
||||||
|
>
|
||||||
|
<div className="top-bar" style={{ marginBottom: '25px' }}>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style={{ color: '#2d3436', fontSize: '24px', fontWeight: 300, marginBottom: '30px', display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
🏥 Медицина
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: '20px' }}>
|
||||||
|
<motion.div whileHover={{ y: -5 }} style={{ background: 'white', borderRadius: '16px', padding: '25px', boxShadow: '0 4px 20px rgba(0,0,0,0.06)', cursor: 'pointer', transition: 'all 0.3s ease', border: '1px solid #f1f2f6' }}>
|
||||||
|
<div style={{ fontSize: '32px', color: '#3498db', marginBottom: '15px' }}>📋</div>
|
||||||
|
<h3 style={{ margin: '0 0 10px 0', color: '#2d3436', fontSize: '18px' }}>Медицинская карта</h3>
|
||||||
|
<p style={{ color: '#636e72', fontSize: '14px', lineHeight: 1.6, margin: 0 }}>Просмотр информации из медицинской карты</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div whileHover={{ y: -5 }} style={{ background: 'white', borderRadius: '16px', padding: '25px', boxShadow: '0 4px 20px rgba(0,0,0,0.06)', cursor: 'pointer', transition: 'all 0.3s ease', border: '1px solid #f1f2f6' }}>
|
||||||
|
<div style={{ fontSize: '32px', color: '#2ecc71', marginBottom: '15px' }}>🏥</div>
|
||||||
|
<h3 style={{ margin: '0 0 10px 0', color: '#2d3436', fontSize: '18px' }}>Запись в поликлинику</h3>
|
||||||
|
<p style={{ color: '#636e72', fontSize: '14px', lineHeight: 1.6, margin: 0 }}>Онлайн запись к врачу</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div whileHover={{ y: -5 }} style={{ background: 'white', borderRadius: '16px', padding: '25px', boxShadow: '0 4px 20px rgba(0,0,0,0.06)', cursor: 'pointer', transition: 'all 0.3s ease', border: '1px solid #f1f2f6' }}>
|
||||||
|
<div style={{ fontSize: '32px', color: '#f39c12', marginBottom: '15px' }}>🛡️</div>
|
||||||
|
<h3 style={{ margin: '0 0 10px 0', color: '#2d3436', fontSize: '18px' }}>Страховая компания</h3>
|
||||||
|
<p style={{ color: '#636e72', fontSize: '14px', lineHeight: 1.6, margin: 0 }}>Оформление заявок на медицинские услуги</p>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '30px', padding: '20px', background: '#f8f9fa', borderRadius: '12px', border: '1px solid #f1f2f6', textAlign: 'center', color: '#636e72' }}>
|
||||||
|
<p style={{ margin: 0 }}>ℹ️ Раздел в разработке. Функционал будет доступен в ближайшее время.</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Medicine;
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface MainMenuProps {
|
||||||
|
onSectionSelect: (section: string) => void;
|
||||||
|
employee: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MainMenu: React.FC<MainMenuProps> = ({ onSectionSelect, employee }) => {
|
||||||
|
console.log('🔵 MainMenu компонент рендерится!');
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ id: 'employee-info', emoji: '📋', description: 'Просмотр и печать' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const menuItems_2 = [
|
||||||
|
{ id: 'finance', title: 'Финансы', emoji: '💰' },
|
||||||
|
{ id: 'medicine', title: 'Медицина', emoji: '🏥' },
|
||||||
|
{ id: 'trade-union', title: 'Профсоюз', emoji: '🤝' },
|
||||||
|
{ id: 'labor-safety', title: 'Охрана труда', emoji: '⛑️' },
|
||||||
|
{ id: 'contacts', title: 'Контакты', emoji: '📞' },
|
||||||
|
{ id: 'news', title: 'Новости', emoji: '📰' },
|
||||||
|
{ id: 'profile', title: 'Лучший по профессии', emoji: '👤' },
|
||||||
|
{ id: 'food', title: 'Питание', emoji: '🍔' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '20px 20px',
|
||||||
|
marginTop: '-20px',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundImage: 'url(/assets/images/background.jpg)',
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
}}>
|
||||||
|
{/* ✅ Полупрозрачный оверлей для читаемости текста
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: 'rgba(232, 240, 254, 0.2)',
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
WebkitBackdropFilter: 'blur(4px)',
|
||||||
|
zIndex: 0,
|
||||||
|
}} /> */}
|
||||||
|
|
||||||
|
{/* ✅ Декоративные голубые размытые круги (поверх оверлея) */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-25%',
|
||||||
|
right: '-10%',
|
||||||
|
width: '55%',
|
||||||
|
height: '70%',
|
||||||
|
background: 'radial-gradient(ellipse, rgba(66, 133, 244, 0.08) 0%, rgba(66, 133, 244, 0.02) 60%, transparent 80%)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 0,
|
||||||
|
}} />
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '-20%',
|
||||||
|
left: '-15%',
|
||||||
|
width: '50%',
|
||||||
|
height: '60%',
|
||||||
|
background: 'radial-gradient(ellipse, rgba(52, 152, 219, 0.06) 0%, transparent 70%)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 0,
|
||||||
|
}} />
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '30%',
|
||||||
|
left: '25%',
|
||||||
|
width: '40%',
|
||||||
|
height: '50%',
|
||||||
|
background: 'radial-gradient(ellipse, rgba(41, 128, 185, 0.04) 0%, transparent 70%)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 0,
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* ✅ КНОПКА "РАСЧЕТНЫЙ ЛИСТ" */}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 900px))',
|
||||||
|
gap: '30px',
|
||||||
|
maxWidth: '960px',
|
||||||
|
width: '100%',
|
||||||
|
justifyContent: 'center',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
|
marginBottom: '20px',
|
||||||
|
}}>
|
||||||
|
{menuItems.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => onSectionSelect(item.id)}
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(145deg, rgba(255,255,255,0.92) 0%, rgba(235, 245, 255, 0.92) 100%)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
WebkitBackdropFilter: 'blur(8px)',
|
||||||
|
borderRadius: '28px',
|
||||||
|
padding: '58px 36px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||||
|
boxShadow: '0 8px 32px rgba(66, 133, 244, 0.10), 0 2px 8px rgba(66, 133, 244, 0.04), inset 0 1px 0 rgba(255,255,255,0.9)',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
alignItems: 'center',
|
||||||
|
minHeight: '110px',
|
||||||
|
gap: '20px',
|
||||||
|
border: '1px solid rgba(255,255,255,0.5)',
|
||||||
|
width: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(-4px) scale(1.01)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 24px 64px rgba(66, 133, 244, 0.18), 0 8px 24px rgba(66, 133, 244, 0.08), inset 0 1px 0 rgba(255,255,255,0.95)';
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(66, 133, 244, 0.2)';
|
||||||
|
e.currentTarget.style.background = 'linear-gradient(145deg, rgba(255,255,255,0.96) 0%, rgba(235, 245, 255, 0.96) 100%)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(0) scale(1)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 8px 32px rgba(66, 133, 244, 0.10), 0 2px 8px rgba(66, 133, 244, 0.04), inset 0 1px 0 rgba(255,255,255,0.9)';
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.5)';
|
||||||
|
e.currentTarget.style.background = 'linear-gradient(145deg, rgba(255,255,255,0.92) 0%, rgba(235, 245, 255, 0.92) 100%)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: '6px',
|
||||||
|
background: 'linear-gradient(180deg, #4285f4 0%, #5b9bd5 50%, #7cb9e8 100%)',
|
||||||
|
borderRadius: '0 3px 3px 0',
|
||||||
|
opacity: 0.7,
|
||||||
|
}} />
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
fontSize: '4.5rem',
|
||||||
|
marginLeft: '10px',
|
||||||
|
filter: 'drop-shadow(0 4px 12px rgba(66, 133, 244, 0.08))',
|
||||||
|
}}>{item.emoji}</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, textAlign: 'left' }}>
|
||||||
|
<h3 style={{
|
||||||
|
color: '#1a3a5c',
|
||||||
|
fontSize: '32px',
|
||||||
|
fontWeight: 600,
|
||||||
|
margin: 0,
|
||||||
|
letterSpacing: '-0.3px',
|
||||||
|
}}>
|
||||||
|
Расчетный лист
|
||||||
|
</h3>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '16px',
|
||||||
|
color: '#6a8fb1',
|
||||||
|
fontWeight: 400,
|
||||||
|
letterSpacing: '0.2px',
|
||||||
|
}}>
|
||||||
|
{item.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
fontSize: '20px',
|
||||||
|
color: 'rgba(66, 133, 244, 0.3)',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
padding: '8px 14px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
background: 'rgba(66, 133, 244, 0.05)',
|
||||||
|
}}>
|
||||||
|
→
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ✅ СЕТКА КНОПОК */}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(170px, 210px))',
|
||||||
|
gap: '22px',
|
||||||
|
maxWidth: '960px',
|
||||||
|
width: '100%',
|
||||||
|
justifyContent: 'center',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
|
}}>
|
||||||
|
{menuItems_2.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => onSectionSelect(item.id)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '14px',
|
||||||
|
padding: '30px 16px 26px',
|
||||||
|
background: 'rgba(255,255,255,0.85)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
WebkitBackdropFilter: 'blur(8px)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.5)',
|
||||||
|
borderRadius: '24px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
boxShadow: '0 6px 24px rgba(66, 133, 244, 0.06), 0 2px 8px rgba(66, 133, 244, 0.03), inset 0 1px 0 rgba(255,255,255,0.9)',
|
||||||
|
minHeight: '190px',
|
||||||
|
fontSize: '4rem',
|
||||||
|
transition: 'all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(-6px) scale(1.02)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 24px 64px rgba(66, 133, 244, 0.12), 0 8px 24px rgba(66, 133, 244, 0.06), inset 0 1px 0 rgba(255,255,255,0.95)';
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(66, 133, 244, 0.15)';
|
||||||
|
e.currentTarget.style.background = 'rgba(255,255,255,0.92)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(0) scale(1)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 6px 24px rgba(66, 133, 244, 0.06), 0 2px 8px rgba(66, 133, 244, 0.03), inset 0 1px 0 rgba(255,255,255,0.9)';
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.5)';
|
||||||
|
e.currentTarget.style.background = 'rgba(255,255,255,0.85)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div 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={{
|
||||||
|
display: 'block',
|
||||||
|
whiteSpace: 'normal',
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#2a4a6a',
|
||||||
|
textAlign: 'center',
|
||||||
|
letterSpacing: '0.2px',
|
||||||
|
lineHeight: 1.3,
|
||||||
|
maxWidth: '100%',
|
||||||
|
}}>
|
||||||
|
{item.title}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '8px',
|
||||||
|
left: '25%',
|
||||||
|
right: '25%',
|
||||||
|
height: '2px',
|
||||||
|
background: 'linear-gradient(90deg, transparent, rgba(66, 133, 244, 0.2), transparent)',
|
||||||
|
borderRadius: '2px',
|
||||||
|
opacity: 0,
|
||||||
|
transition: 'all 0.4s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
const indicator = e.currentTarget.parentElement?.querySelector('div:last-child');
|
||||||
|
if (indicator) {
|
||||||
|
(indicator as HTMLElement).style.opacity = '1';
|
||||||
|
(indicator as HTMLElement).style.background = 'linear-gradient(90deg, transparent, rgba(66, 133, 244, 0.3), transparent)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
const indicator = e.currentTarget.parentElement?.querySelector('div:last-child');
|
||||||
|
if (indicator) {
|
||||||
|
(indicator as HTMLElement).style.opacity = '0';
|
||||||
|
(indicator as HTMLElement).style.background = 'linear-gradient(90deg, transparent, rgba(66, 133, 244, 0.2), transparent)';
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
marginTop: '30px',
|
||||||
|
fontSize: '13px',
|
||||||
|
color: 'rgba(66, 133, 244, 0.08)',
|
||||||
|
letterSpacing: '2px',
|
||||||
|
fontWeight: 300,
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
|
}}>
|
||||||
|
⋆ ﹒ ˚ ﹒ ⋆ ﹒ ˚ ﹒ ⋆
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('🟢 MainMenu.tsx экспорт готов');
|
||||||
|
|
||||||
|
export default MainMenu;
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import React, { useState, type ComponentType, type SVGAttributes } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { FaNewspaper, FaCalendarAlt, FaUser, FaTag } from 'react-icons/fa';
|
||||||
|
|
||||||
|
interface NewsProps {
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NewsItem {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
date: string;
|
||||||
|
content: string;
|
||||||
|
category: string;
|
||||||
|
author: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IconWrapper = ({ icon: Icon, style }: { icon: any; style?: React.CSSProperties }) => (
|
||||||
|
<Icon style={style} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const News: React.FC<NewsProps> = ({ onBack }) => {
|
||||||
|
const [news, setNews] = useState<NewsItem[]>([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'День химика - поздравления коллективу!',
|
||||||
|
date: '2024-05-27',
|
||||||
|
content: 'Уважаемые сотрудники! Поздравляем вас с профессиональным праздником - Днем химика! Желаем успехов в работе, крепкого здоровья и благополучия.',
|
||||||
|
category: 'Праздники',
|
||||||
|
author: 'Администрация',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: 'Новые правила охраны труда',
|
||||||
|
date: '2024-05-25',
|
||||||
|
content: 'С 1 июня вводятся новые правила по охране труда. Просим ознакомиться с изменениями в разделе "Охрана труда".',
|
||||||
|
category: 'Охрана труда',
|
||||||
|
author: 'Отдел ОТ',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: 'Спортивные соревнования среди цехов',
|
||||||
|
date: '2024-05-20',
|
||||||
|
content: 'Приглашаем принять участие в ежегодных спортивных соревнованиях. Заявки принимаются до 10 июня.',
|
||||||
|
category: 'Спорт',
|
||||||
|
author: 'Профсоюз',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string>('Все');
|
||||||
|
const categories = ['Все', ...Array.from(new Set(news.map(item => item.category)))];
|
||||||
|
|
||||||
|
const filteredNews = selectedCategory === 'Все'
|
||||||
|
? news
|
||||||
|
: news.filter(item => item.category === selectedCategory);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="news-section"
|
||||||
|
style={{
|
||||||
|
padding: '20px',
|
||||||
|
maxWidth: '1200px',
|
||||||
|
margin: '0 auto',
|
||||||
|
paddingBottom: '80px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="top-bar" style={{ marginBottom: '25px' }}>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style={{
|
||||||
|
color: '#2d3436',
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: 300,
|
||||||
|
marginBottom: '30px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
}}>
|
||||||
|
<IconWrapper icon={FaNewspaper} style={{ color: '#1abc9c' }} />
|
||||||
|
Новости
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Фильтр по категориям */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '10px',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
marginBottom: '25px',
|
||||||
|
}}>
|
||||||
|
{categories.map(category => (
|
||||||
|
<motion.button
|
||||||
|
key={category}
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
onClick={() => setSelectedCategory(category)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 20px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '20px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: selectedCategory === category ? 600 : 400,
|
||||||
|
background: selectedCategory === category ? '#1abc9c' : '#f8f9fa',
|
||||||
|
color: selectedCategory === category ? 'white' : '#636e72',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{category}
|
||||||
|
</motion.button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Список новостей */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '20px',
|
||||||
|
}}>
|
||||||
|
{filteredNews.map((item, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={item.id}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: index * 0.1 }}
|
||||||
|
whileHover={{ y: -4 }}
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '25px',
|
||||||
|
boxShadow: '0 4px 20px rgba(0,0,0,0.06)',
|
||||||
|
border: '1px solid #f1f2f6',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '10px',
|
||||||
|
marginBottom: '12px',
|
||||||
|
}}>
|
||||||
|
<h3 style={{
|
||||||
|
margin: 0,
|
||||||
|
color: '#2d3436',
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}>
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
<span style={{
|
||||||
|
padding: '4px 12px',
|
||||||
|
background: '#f0f0f0',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#636e72',
|
||||||
|
}}>
|
||||||
|
<IconWrapper icon={FaTag} style={{ marginRight: '4px', fontSize: '10px' }} />
|
||||||
|
{item.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={{
|
||||||
|
color: '#636e72',
|
||||||
|
fontSize: '14px',
|
||||||
|
lineHeight: 1.8,
|
||||||
|
margin: '0 0 15px 0',
|
||||||
|
}}>
|
||||||
|
{item.content}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '20px',
|
||||||
|
fontSize: '13px',
|
||||||
|
color: '#b2bec3',
|
||||||
|
}}>
|
||||||
|
<span>
|
||||||
|
<IconWrapper icon={FaCalendarAlt} style={{ marginRight: '5px' }} />
|
||||||
|
{new Date(item.date).toLocaleDateString('ru-RU')}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<IconWrapper icon={FaUser} style={{ marginRight: '5px' }} />
|
||||||
|
{item.author}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredNews.length === 0 && (
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '40px',
|
||||||
|
color: '#636e72',
|
||||||
|
}}>
|
||||||
|
Новостей в этой категории пока нет
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default News;
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { FaTimes, FaPrint, FaSpinner, FaCheckCircle, FaExclamationCircle } from 'react-icons/fa';
|
||||||
|
|
||||||
|
interface PayslipFormPreviewProps {
|
||||||
|
tnom: string;
|
||||||
|
period: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PayslipFormPreview: React.FC<PayslipFormPreviewProps> = ({ tnom, period, onClose }) => {
|
||||||
|
const [htmlContent, setHtmlContent] = useState<string>('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isPrinting, setIsPrinting] = useState(false);
|
||||||
|
const [printStatus, setPrintStatus] = useState<'idle' | 'success' | 'error' | 'sending'>('idle');
|
||||||
|
const [printMessage, setPrintMessage] = useState('');
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
|
|
||||||
|
const BACKEND_URL = 'http://localhost:3001';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadForm = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
// ✅ Загружаем HTML-форму из бэкенда
|
||||||
|
const response = await fetch(`${BACKEND_URL}/api/payslip/form/${tnom}?period=${period}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.message || 'Ошибка загрузки формы');
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = await response.text();
|
||||||
|
setHtmlContent(html);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[PayslipFormPreview] Ошибка:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Неизвестная ошибка');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadForm();
|
||||||
|
}, [tnom, period]);
|
||||||
|
|
||||||
|
const handlePrint = async () => {
|
||||||
|
try {
|
||||||
|
setIsPrinting(true);
|
||||||
|
setPrintStatus('sending');
|
||||||
|
setPrintMessage('Подготовка документа к печати...');
|
||||||
|
|
||||||
|
if (!htmlContent || htmlContent.length < 10) {
|
||||||
|
throw new Error('HTML контент пустой или слишком короткий');
|
||||||
|
}
|
||||||
|
|
||||||
|
const electronAPI = (window as any).electronAPI;
|
||||||
|
|
||||||
|
if (electronAPI && electronAPI.printPayslipForm) {
|
||||||
|
console.log('[Print] Печать через Electron API');
|
||||||
|
console.log('[Print] Длина HTML:', htmlContent.length);
|
||||||
|
|
||||||
|
const result = await electronAPI.printPayslipForm({
|
||||||
|
formHtml: htmlContent,
|
||||||
|
printerName: 'GP-80250 Series'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[Print] Результат:', result);
|
||||||
|
|
||||||
|
if (result && result.success === false) {
|
||||||
|
throw new Error(result.error || 'Ошибка печати');
|
||||||
|
}
|
||||||
|
|
||||||
|
setPrintStatus('success');
|
||||||
|
setPrintMessage(`✅ Документ отправлен на печать`);
|
||||||
|
} else {
|
||||||
|
console.log('[Print] Electron API не доступен');
|
||||||
|
setPrintStatus('error');
|
||||||
|
setPrintMessage('❌ Electron API не доступен');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Print] Ошибка:', error);
|
||||||
|
setPrintStatus('error');
|
||||||
|
setPrintMessage('❌ Ошибка: ' + (error instanceof Error ? error.message : 'Неизвестная ошибка'));
|
||||||
|
} finally {
|
||||||
|
setIsPrinting(false);
|
||||||
|
setTimeout(() => {
|
||||||
|
setPrintStatus('idle');
|
||||||
|
setPrintMessage('');
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: 'rgba(0, 0, 0, 0.6)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
zIndex: 9999,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '20px',
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.9, y: 30 }}
|
||||||
|
animate={{ scale: 1, y: 0 }}
|
||||||
|
exit={{ scale: 0.9, y: 30 }}
|
||||||
|
transition={{ type: 'spring', damping: 25 }}
|
||||||
|
style={{
|
||||||
|
background: '#fff',
|
||||||
|
borderRadius: '16px',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '500px',
|
||||||
|
maxHeight: '90vh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Заголовок */}
|
||||||
|
<div style={{
|
||||||
|
padding: '16px 24px',
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
|
<span style={{ fontSize: '20px' }}>📄</span>
|
||||||
|
<span style={{ color: '#fff', fontSize: '16px', fontWeight: 500 }}>
|
||||||
|
Расчетный листок
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
color: 'rgba(255,255,255,0.5)',
|
||||||
|
fontSize: '12px',
|
||||||
|
background: 'rgba(255,255,255,0.15)',
|
||||||
|
padding: '2px 10px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
}}>
|
||||||
|
{tnom}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(255,255,255,0.2)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: '#fff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '4px 12px',
|
||||||
|
fontSize: '20px',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = 'rgba(255,255,255,0.3)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'rgba(255,255,255,0.2)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaTimes />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Контент */}
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'auto',
|
||||||
|
padding: '20px',
|
||||||
|
background: '#f5f6fa',
|
||||||
|
}}>
|
||||||
|
{loading ? (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '40px',
|
||||||
|
gap: '15px',
|
||||||
|
}}>
|
||||||
|
<FaSpinner style={{ animation: 'spin 1s linear infinite', fontSize: '32px', color: '#667eea' }} />
|
||||||
|
<span style={{ color: '#636e72' }}>Загрузка формы...</span>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '40px',
|
||||||
|
color: '#e74c3c',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '48px', marginBottom: '10px' }}>❌</div>
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
background: '#fff',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '16px',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
|
||||||
|
overflow: 'auto',
|
||||||
|
maxHeight: '60vh',
|
||||||
|
}}>
|
||||||
|
{/* ✅ Показываем HTML-форму как есть */}
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: htmlContent }} />
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
srcDoc={htmlContent}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
title="Print Frame"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Кнопка печати */}
|
||||||
|
<div style={{
|
||||||
|
padding: '16px 24px',
|
||||||
|
borderTop: '1px solid #e9ecef',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={handlePrint}
|
||||||
|
disabled={isPrinting || !!error || !htmlContent}
|
||||||
|
style={{
|
||||||
|
padding: '12px 40px',
|
||||||
|
background: isPrinting || error || !htmlContent ? '#b2bec3' : 'linear-gradient(135deg, #00b894, #00cec9)',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '10px',
|
||||||
|
cursor: isPrinting || error || !htmlContent ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 500,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
boxShadow: isPrinting || error || !htmlContent ? 'none' : '0 4px 15px rgba(0, 206, 201, 0.3)',
|
||||||
|
opacity: isPrinting || error || !htmlContent ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPrinting ? (
|
||||||
|
<>
|
||||||
|
<FaSpinner style={{ animation: 'spin 1s linear infinite' }} />
|
||||||
|
Печать...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FaPrint />
|
||||||
|
Печать
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Уведомление о статусе печати */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{printStatus !== 'idle' && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -50 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -50 }}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: '20px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: 99999,
|
||||||
|
padding: '16px 32px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
background: printStatus === 'success' ? '#10b981' :
|
||||||
|
printStatus === 'error' ? '#ef4444' :
|
||||||
|
'#f59e0b',
|
||||||
|
color: 'white',
|
||||||
|
boxShadow: '0 10px 40px rgba(0,0,0,0.2)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{printStatus === 'sending' && <FaSpinner style={{ animation: 'spin 1s linear infinite' }} />}
|
||||||
|
{printStatus === 'success' && <FaCheckCircle />}
|
||||||
|
{printStatus === 'error' && <FaExclamationCircle />}
|
||||||
|
{printMessage}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PayslipFormPreview;
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
interface PayslipFormPrintProps {
|
||||||
|
htmlContent: string;
|
||||||
|
onPrintComplete: () => void;
|
||||||
|
printerName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PayslipFormPrint: React.FC<PayslipFormPrintProps> = ({
|
||||||
|
htmlContent,
|
||||||
|
onPrintComplete,
|
||||||
|
printerName = 'GP-80250 Series'
|
||||||
|
}) => {
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const printIframe = async () => {
|
||||||
|
try {
|
||||||
|
// ✅ Используем (window as any) для обхода типов
|
||||||
|
const electronAPI = (window as any).electronAPI;
|
||||||
|
|
||||||
|
if (electronAPI && electronAPI.printPayslipForm) {
|
||||||
|
console.log('[Print] Печать через Electron API');
|
||||||
|
await electronAPI.printPayslipForm({
|
||||||
|
formHtml: htmlContent,
|
||||||
|
printerName: printerName
|
||||||
|
});
|
||||||
|
console.log('[Print] Форма отправлена на печать');
|
||||||
|
} else {
|
||||||
|
console.log('[Print] Electron API не доступен, используем fallback');
|
||||||
|
const iframe = iframeRef.current;
|
||||||
|
if (iframe) {
|
||||||
|
iframe.onload = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
iframe.contentWindow?.print();
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[PayslipFormPrint] Ошибка:', error);
|
||||||
|
} finally {
|
||||||
|
setTimeout(onPrintComplete, 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
printIframe();
|
||||||
|
}, [htmlContent, printerName, onPrintComplete]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 0.5 }}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: 'rgba(0,0,0,0.5)',
|
||||||
|
zIndex: 9999,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
background: '#fff',
|
||||||
|
padding: '40px',
|
||||||
|
borderRadius: '16px',
|
||||||
|
textAlign: 'center',
|
||||||
|
maxWidth: '400px',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🖨️</div>
|
||||||
|
<h3 style={{ margin: '0 0 8px 0', color: '#2d3436' }}>Печать...</h3>
|
||||||
|
<p style={{ margin: 0, color: '#636e72', fontSize: '14px' }}>
|
||||||
|
Документ отправляется на печать
|
||||||
|
</p>
|
||||||
|
<div style={{
|
||||||
|
width: '40px',
|
||||||
|
height: '40px',
|
||||||
|
margin: '20px auto 0',
|
||||||
|
border: '4px solid #f3f3f3',
|
||||||
|
borderTop: '4px solid #667eea',
|
||||||
|
borderRadius: '50%',
|
||||||
|
animation: 'spin 1s linear infinite',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
<style>{`
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
srcDoc={htmlContent}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
title="Print Frame"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PayslipFormPrint;
|
||||||
@@ -1,7 +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';
|
||||||
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs';
|
|
||||||
|
|
||||||
interface PayslipViewerProps {
|
interface PayslipViewerProps {
|
||||||
fileUrl: string;
|
fileUrl: string;
|
||||||
@@ -9,37 +8,229 @@ 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',
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '10px 20px',
|
||||||
|
background: '#6c757d',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaArrowLeft />
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h2 style={{
|
||||||
|
margin: 0,
|
||||||
|
color: '#2d3436',
|
||||||
|
fontSize: '22px',
|
||||||
|
fontWeight: 500,
|
||||||
}}>
|
}}>
|
||||||
<Document file={fileUrl} loading="Загрузка PDF...">
|
📄 Расчетный листок
|
||||||
<div style={{ margin: '0 auto' }}>
|
</h2>
|
||||||
<Page pageNumber={1} />
|
|
||||||
</div>
|
<button
|
||||||
</Document>
|
onClick={handlePrint}
|
||||||
</div>
|
disabled={isPrinting}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '10px 30px',
|
||||||
|
background: isPrinting ? '#b2bec3' : 'linear-gradient(135deg, #00b894, #00cec9)',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '10px',
|
||||||
|
cursor: isPrinting ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '15px',
|
||||||
|
fontWeight: 500,
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
boxShadow: isPrinting ? 'none' : '0 4px 15px rgba(0, 206, 201, 0.3)',
|
||||||
|
opacity: isPrinting ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPrinting ? <FaSpinner style={{ animation: 'spin 1s linear infinite' }} /> : <FaPrint />}
|
||||||
|
{isPrinting ? 'Отправка...' : 'Печать'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
background: '#f5f6fa',
|
||||||
|
borderRadius: '12px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
minHeight: 0,
|
||||||
|
border: 'none',
|
||||||
|
}}>
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
src={`${fullUrl}#toolbar=0&navpanes=0&scrollbar=1&view=FitH`}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
border: 'none',
|
||||||
|
display: 'block',
|
||||||
|
background: '#f5f6fa',
|
||||||
|
}}
|
||||||
|
title="PDF Viewer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<style>{`
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default PayslipViewer;
|
export default PayslipViewer;
|
||||||
@@ -0,0 +1,833 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
FaUserCircle, FaTrophy, FaVoteYea, FaCheckCircle, FaMedal,
|
||||||
|
FaCrown, FaChartBar, FaSearch, FaUsers, FaInfoCircle,
|
||||||
|
FaArrowLeft, FaSyncAlt, FaSpinner
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ТИПЫ
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
interface ProfileProps {
|
||||||
|
onBack: () => void;
|
||||||
|
/** Данные голосующего (ФИО + табельный) — приходит из внешнего компонента */
|
||||||
|
employee: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Candidate {
|
||||||
|
id: number;
|
||||||
|
tnom: string;
|
||||||
|
fullName: string;
|
||||||
|
position: string; // должность
|
||||||
|
department?: string; // подразделение
|
||||||
|
votes: number; // текущее кол-во голосов
|
||||||
|
photoUrl?: string; // опционально
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Profession {
|
||||||
|
id: number;
|
||||||
|
name: string; // "Инженер-технолог"
|
||||||
|
code?: string; // код профессии
|
||||||
|
totalVotes: number; // всего голосов по профессии
|
||||||
|
top: Candidate[]; // топ-3 (для верхней панели)
|
||||||
|
candidates?: Candidate[]; // полный список (подгружается при выборе)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ЗАГЛУШКА API — заменить на реальные вызовы backend
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const API_BASE = ''; // напр. 'http://localhost:3001'
|
||||||
|
|
||||||
|
async function apiGetProfessions(): Promise<Profession[]> {
|
||||||
|
// TODO: GET /api/voting/professions
|
||||||
|
// return (await fetch(`${API_BASE}/api/voting/professions`)).json();
|
||||||
|
|
||||||
|
// --- временные тестовые данные ---
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 1, name: 'Инженер-технолог', code: 'ТЕХ', totalVotes: 128,
|
||||||
|
top: [
|
||||||
|
{ id: 101, tnom: '001', fullName: 'Петров Пётр Петрович', position: 'Главный инженер', votes: 52 },
|
||||||
|
{ id: 102, tnom: '002', fullName: 'Иванов Иван Иванович', position: 'Инженер-технолог', votes: 41 },
|
||||||
|
{ id: 103, tnom: '003', fullName: 'Сидоров Сидор Сидорович', position: 'Инженер', votes: 35 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2, name: 'Бухгалтер', code: 'БУХ', totalVotes: 94,
|
||||||
|
top: [
|
||||||
|
{ id: 201, tnom: '004', fullName: 'Сидорова Анна Сергеевна', position: 'Гл. бухгалтер', votes: 44 },
|
||||||
|
{ id: 202, tnom: '005', fullName: 'Кузнецова Ольга Львовна', position: 'Бухгалтер', votes: 30 },
|
||||||
|
{ id: 203, tnom: '006', fullName: 'Морозова Ирина Петровна', position: 'Бухгалтер', votes: 20 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3, name: 'Электромонтёр', code: 'ЭЛК', totalVotes: 76,
|
||||||
|
top: [
|
||||||
|
{ id: 301, tnom: '007', fullName: 'Алексеев Алексей Алексеевич', position: 'Электромонтёр', votes: 38 },
|
||||||
|
{ id: 302, tnom: '008', fullName: 'Никитин Никита Никитич', position: 'Электромонтёр', votes: 22 },
|
||||||
|
{ id: 303, tnom: '009', fullName: 'Фёдоров Фёдор Фёдорович', position: 'Электромонтёр', votes: 16 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiGetCandidates(professionId: number): Promise<Candidate[]> {
|
||||||
|
// TODO: GET /api/voting/candidates?professionId=...
|
||||||
|
const all: Record<number, Candidate[]> = {
|
||||||
|
1: [
|
||||||
|
{ id: 101, tnom: '001', fullName: 'Петров Пётр Петрович', position: 'Главный инженер', department: 'Технический отдел', votes: 52 },
|
||||||
|
{ id: 102, tnom: '002', fullName: 'Иванов Иван Иванович', position: 'Инженер-технолог', department: 'Технический отдел', votes: 41 },
|
||||||
|
{ id: 103, tnom: '003', fullName: 'Сидоров Сидор Сидорович', position: 'Инженер', department: 'Технический отдел', votes: 35 },
|
||||||
|
],
|
||||||
|
2: [
|
||||||
|
{ id: 201, tnom: '004', fullName: 'Сидорова Анна Сергеевна', position: 'Гл. бухгалтер', department: 'Бухгалтерия', votes: 44 },
|
||||||
|
{ id: 202, tnom: '005', fullName: 'Кузнецова Ольга Львовна', position: 'Бухгалтер', department: 'Бухгалтерия', votes: 30 },
|
||||||
|
{ id: 203, tnom: '006', fullName: 'Морозова Ирина Петровна', position: 'Бухгалтер', department: 'Бухгалтерия', votes: 20 },
|
||||||
|
],
|
||||||
|
3: [
|
||||||
|
{ id: 301, tnom: '007', fullName: 'Алексеев Алексей Алексеевич', position: 'Электромонтёр', department: 'Энергоцех', votes: 38 },
|
||||||
|
{ id: 302, tnom: '008', fullName: 'Никитин Никита Никитич', position: 'Электромонтёр', department: 'Энергоцех', votes: 22 },
|
||||||
|
{ id: 303, tnom: '009', fullName: 'Фёдоров Фёдор Фёдорович', position: 'Электромонтёр', department: 'Энергоцех', votes: 16 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
return all[professionId] || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiVote(params: {
|
||||||
|
voterTnom: string;
|
||||||
|
professionId: number;
|
||||||
|
candidateId: number;
|
||||||
|
}): Promise<{ success: boolean; message?: string }> {
|
||||||
|
// TODO: POST /api/voting/vote
|
||||||
|
// const r = await fetch(`${API_BASE}/api/voting/vote`, {
|
||||||
|
// method: 'POST',
|
||||||
|
// headers: { 'Content-Type': 'application/json' },
|
||||||
|
// body: JSON.stringify(params),
|
||||||
|
// });
|
||||||
|
// return r.json();
|
||||||
|
console.log('[vote]', params);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiGetMyVotes(voterTnom: string): Promise<Record<number, number>> {
|
||||||
|
// TODO: GET /api/voting/my-votes?voterTnom=...
|
||||||
|
// возвращает { [professionId]: candidateId }
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ВСПОМОГАТЕЛЬНОЕ
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const getInitials = (fullName: string) =>
|
||||||
|
fullName
|
||||||
|
.split(' ')
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((s) => s[0]?.toUpperCase())
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const medalColor = (place: number) => {
|
||||||
|
if (place === 0) return '#f1c40f'; // золото
|
||||||
|
if (place === 1) return '#bdc3c7'; // серебро
|
||||||
|
if (place === 2) return '#cd7f32'; // бронза
|
||||||
|
return '#95a5a6';
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// КОМПОНЕНТ
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const Profile: React.FC<ProfileProps> = ({ onBack, employee }) => {
|
||||||
|
// --- голосующий (ФИО + таб. номер из старого кода) ---
|
||||||
|
const voterName =
|
||||||
|
employee && (employee.famaly || employee.ima)
|
||||||
|
? `${employee.famaly || ''} ${employee.ima || ''} ${employee.otch || ''}`.trim()
|
||||||
|
: 'Сотрудник';
|
||||||
|
const voterTnom = employee?.tnom || '—';
|
||||||
|
|
||||||
|
// --- состояние ---
|
||||||
|
const [professions, setProfessions] = useState<Profession[]>([]);
|
||||||
|
const [activeProfessionId, setActiveProfessionId] = useState<number | null>(null);
|
||||||
|
const [candidates, setCandidates] = useState<Candidate[]>([]);
|
||||||
|
const [myVotes, setMyVotes] = useState<Record<number, number>>({});
|
||||||
|
const [loadingProfessions, setLoadingProfessions] = useState(true);
|
||||||
|
const [loadingCandidates, setLoadingCandidates] = useState(false);
|
||||||
|
const [voting, setVoting] = useState<number | null>(null); // id кандидата, за которого сейчас идёт голосование
|
||||||
|
const [toast, setToast] = useState<{ text: string; type: 'ok' | 'err' } | null>(null);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
// --- загрузка профессий и «моих голосов» ---
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
setLoadingProfessions(true);
|
||||||
|
try {
|
||||||
|
const [profs, votes] = await Promise.all([
|
||||||
|
apiGetProfessions(),
|
||||||
|
apiGetMyVotes(voterTnom),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
setProfessions(profs);
|
||||||
|
setMyVotes(votes);
|
||||||
|
setActiveProfessionId(profs[0]?.id ?? null);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoadingProfessions(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [voterTnom]);
|
||||||
|
|
||||||
|
// --- загрузка кандидатов при смене профессии ---
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeProfessionId == null) return;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
setLoadingCandidates(true);
|
||||||
|
try {
|
||||||
|
const list = await apiGetCandidates(activeProfessionId);
|
||||||
|
if (!cancelled) setCandidates(list);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoadingCandidates(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [activeProfessionId]);
|
||||||
|
|
||||||
|
const activeProfession = useMemo(
|
||||||
|
() => professions.find((p) => p.id === activeProfessionId) || null,
|
||||||
|
[professions, activeProfessionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const myVotedCandidateId = activeProfessionId != null ? myVotes[activeProfessionId] : undefined;
|
||||||
|
const hasVotedHere = myVotedCandidateId != null;
|
||||||
|
|
||||||
|
const filteredCandidates = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
const sorted = [...candidates].sort((a, b) => b.votes - a.votes);
|
||||||
|
if (!q) return sorted;
|
||||||
|
return sorted.filter(
|
||||||
|
(c) =>
|
||||||
|
c.fullName.toLowerCase().includes(q) ||
|
||||||
|
c.position.toLowerCase().includes(q) ||
|
||||||
|
c.tnom.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}, [candidates, search]);
|
||||||
|
|
||||||
|
// --- обработка голоса ---
|
||||||
|
const handleVote = async (candidate: Candidate) => {
|
||||||
|
if (activeProfessionId == null) return;
|
||||||
|
if (hasVotedHere) {
|
||||||
|
showToast('Вы уже голосовали по этой профессии', 'err');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setVoting(candidate.id);
|
||||||
|
try {
|
||||||
|
const res = await apiVote({
|
||||||
|
voterTnom,
|
||||||
|
professionId: activeProfessionId,
|
||||||
|
candidateId: candidate.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.success) {
|
||||||
|
showToast(res.message || 'Не удалось отправить голос', 'err');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// локально обновляем
|
||||||
|
setMyVotes((prev) => ({ ...prev, [activeProfessionId]: candidate.id }));
|
||||||
|
setCandidates((prev) =>
|
||||||
|
prev.map((c) => (c.id === candidate.id ? { ...c, votes: c.votes + 1 } : c))
|
||||||
|
);
|
||||||
|
setProfessions((prev) =>
|
||||||
|
prev.map((p) => {
|
||||||
|
if (p.id !== activeProfessionId) return p;
|
||||||
|
const newTotal = p.totalVotes + 1;
|
||||||
|
const updated = p.top.map((t) =>
|
||||||
|
t.id === candidate.id ? { ...t, votes: t.votes + 1 } : t
|
||||||
|
);
|
||||||
|
return { ...p, totalVotes: newTotal, top: updated };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
showToast(`Голос за «${candidate.fullName}» учтён`, 'ok');
|
||||||
|
} finally {
|
||||||
|
setVoting(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showToast = (text: string, type: 'ok' | 'err') => {
|
||||||
|
setToast({ text, type });
|
||||||
|
setTimeout(() => setToast(null), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// РЕНДЕР
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
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',
|
||||||
|
height: 'calc(100vh - 120px)',
|
||||||
|
overflowY: 'auto',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* ---------- Верхняя строка: назад + голосующий ---------- */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '15px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
background: '#6c757d',
|
||||||
|
flex: '0 0 100px',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = '#5a6268')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = '#6c757d')}
|
||||||
|
>
|
||||||
|
<FaArrowLeft /> Назад
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Карточка голосующего */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
background: '#e8f0fe',
|
||||||
|
borderRadius: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaUserCircle style={{ fontSize: '28px', color: '#34495e' }} />
|
||||||
|
<div style={{ lineHeight: 1.25 }}>
|
||||||
|
<div style={{ fontSize: '12px', color: '#636e72' }}>Вы голосуете как</div>
|
||||||
|
<div style={{ fontSize: '14px', fontWeight: 600, color: '#2d3436' }}>
|
||||||
|
{voterName}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#636e72' }}>
|
||||||
|
Таб. № <b>{voterTnom}</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---------- Заголовок ---------- */}
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: '25px' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
fontSize: '26px',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#2d3436',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaTrophy style={{ color: '#f39c12' }} />
|
||||||
|
Лучший по профессии
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#636e72', marginTop: '6px' }}>
|
||||||
|
Выберите профессию и отдайте свой голос за лучшего коллегу
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---------- Топ профессий ---------- */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '16px',
|
||||||
|
boxShadow: '0 4px 20px rgba(0,0,0,0.06)',
|
||||||
|
padding: '18px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#34495e',
|
||||||
|
marginBottom: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaChartBar /> Текущие лидеры по профессиям
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingProfessions ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '20px', color: '#636e72' }}>
|
||||||
|
<FaSpinner className="spin" /> Загрузка…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||||
|
gap: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{professions.map((p) => {
|
||||||
|
const isActive = p.id === activeProfessionId;
|
||||||
|
const voted = myVotes[p.id] != null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => setActiveProfessionId(p.id)}
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '14px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: isActive
|
||||||
|
? '2px solid #1abc9c'
|
||||||
|
: '2px solid #f1f2f6',
|
||||||
|
background: isActive ? '#f0fdfa' : '#fafbfc',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isActive) e.currentTarget.style.background = '#f5f7fa';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!isActive) e.currentTarget.style.background = '#fafbfc';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600, color: '#2d3436', fontSize: '15px' }}>
|
||||||
|
{p.name}
|
||||||
|
</div>
|
||||||
|
{voted && (
|
||||||
|
<FaCheckCircle
|
||||||
|
style={{ color: '#2ecc71' }}
|
||||||
|
title="Вы уже голосовали"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||||
|
{p.top.slice(0, 3).map((c, idx) => (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
fontSize: '13px',
|
||||||
|
color: '#2d3436',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaMedal style={{ color: medalColor(idx) }} />
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.fullName}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: '#636e72', fontSize: '12px' }}>
|
||||||
|
{c.votes}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '10px',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#b2bec3',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaUsers /> Всего голосов: {p.totalVotes}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---------- Окно голосования ---------- */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '16px',
|
||||||
|
boxShadow: '0 4px 20px rgba(0,0,0,0.06)',
|
||||||
|
padding: '20px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Заголовок окна голосования */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '15px',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
marginBottom: '18px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
|
<FaVoteYea style={{ color: '#6c5ce7', fontSize: '20px' }} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '16px', fontWeight: 600, color: '#2d3436' }}>
|
||||||
|
Голосование: {activeProfession?.name || '—'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#636e72' }}>
|
||||||
|
Один голос на профессию
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Поиск */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '8px 12px',
|
||||||
|
background: '#f8f9fa',
|
||||||
|
borderRadius: '10px',
|
||||||
|
minWidth: '220px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaSearch style={{ color: '#b2bec3' }} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Поиск по ФИО, должности…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
outline: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
fontSize: '14px',
|
||||||
|
width: '100%',
|
||||||
|
color: '#2d3436',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Предупреждение, если уже голосовал */}
|
||||||
|
{hasVotedHere && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
padding: '10px 14px',
|
||||||
|
background: '#eafaf1',
|
||||||
|
color: '#1e8449',
|
||||||
|
borderRadius: '10px',
|
||||||
|
fontSize: '13px',
|
||||||
|
marginBottom: '15px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaInfoCircle />
|
||||||
|
Вы уже проголосовали по профессии «{activeProfession?.name}».
|
||||||
|
Ваш голос — за{' '}
|
||||||
|
<b>
|
||||||
|
{candidates.find((c) => c.id === myVotedCandidateId)?.fullName || '—'}
|
||||||
|
</b>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Список кандидатов */}
|
||||||
|
{loadingCandidates ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '30px', color: '#636e72' }}>
|
||||||
|
<FaSpinner className="spin" /> Загрузка кандидатов…
|
||||||
|
</div>
|
||||||
|
) : filteredCandidates.length === 0 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '30px',
|
||||||
|
color: '#b2bec3',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Кандидаты не найдены
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
|
{filteredCandidates.map((c, idx) => {
|
||||||
|
const isMyChoice = myVotedCandidateId === c.id;
|
||||||
|
const isVoting = voting === c.id;
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={c.id}
|
||||||
|
layout
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '15px',
|
||||||
|
padding: '14px 16px',
|
||||||
|
background: isMyChoice ? '#f0fdfa' : '#fafbfc',
|
||||||
|
border: isMyChoice
|
||||||
|
? '2px solid #1abc9c'
|
||||||
|
: '2px solid #f1f2f6',
|
||||||
|
borderRadius: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Место (по текущему топу) */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '36px',
|
||||||
|
height: '36px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: idx < 3 ? medalColor(idx) : '#ecf0f1',
|
||||||
|
color: idx < 3 ? '#fff' : '#636e72',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: '14px',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{idx + 1}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Аватар */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '48px',
|
||||||
|
height: '48px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: '#e8f0fe',
|
||||||
|
color: '#34495e',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 600,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getInitials(c.fullName)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Инфо */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '15px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#2d3436',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.fullName}
|
||||||
|
{isMyChoice && (
|
||||||
|
<FaCrown
|
||||||
|
style={{
|
||||||
|
color: '#f1c40f',
|
||||||
|
marginLeft: '8px',
|
||||||
|
}}
|
||||||
|
title="Ваш голос"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '13px',
|
||||||
|
color: '#636e72',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.position}
|
||||||
|
{c.department ? ` • ${c.department}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Голоса */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minWidth: '70px',
|
||||||
|
textAlign: 'right',
|
||||||
|
color: '#2d3436',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '15px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.votes}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#b2bec3',
|
||||||
|
fontWeight: 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
голосов
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Кнопка голосования */}
|
||||||
|
<button
|
||||||
|
disabled={hasVotedHere || isVoting}
|
||||||
|
onClick={() => handleVote(c)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 18px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
border: 'none',
|
||||||
|
background: isMyChoice
|
||||||
|
? '#1abc9c'
|
||||||
|
: hasVotedHere
|
||||||
|
? '#dfe6e9'
|
||||||
|
: '#6c5ce7',
|
||||||
|
color: hasVotedHere && !isMyChoice ? '#95a5a6' : '#fff',
|
||||||
|
cursor: hasVotedHere ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!hasVotedHere && !isVoting)
|
||||||
|
e.currentTarget.style.background = '#5b4bd6';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!hasVotedHere && !isVoting)
|
||||||
|
e.currentTarget.style.background = '#6c5ce7';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isVoting ? (
|
||||||
|
<>
|
||||||
|
<FaSpinner className="spin" /> Отправка…
|
||||||
|
</>
|
||||||
|
) : isMyChoice ? (
|
||||||
|
<>
|
||||||
|
<FaCheckCircle /> Ваш голос
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FaVoteYea /> Голосовать
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Кнопка обновления */}
|
||||||
|
<div style={{ marginTop: '18px', display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (activeProfessionId == null) return;
|
||||||
|
setLoadingCandidates(true);
|
||||||
|
try {
|
||||||
|
const [list, profs] = await Promise.all([
|
||||||
|
apiGetCandidates(activeProfessionId),
|
||||||
|
apiGetProfessions(),
|
||||||
|
]);
|
||||||
|
setCandidates(list);
|
||||||
|
setProfessions(profs);
|
||||||
|
} finally {
|
||||||
|
setLoadingCandidates(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid #dfe6e9',
|
||||||
|
borderRadius: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#636e72',
|
||||||
|
fontSize: '13px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaSyncAlt /> Обновить результаты
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---------- Toast ---------- */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{toast && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 30 }}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: '90px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
background: toast.type === 'ok' ? '#2ecc71' : '#e74c3c',
|
||||||
|
color: '#fff',
|
||||||
|
padding: '12px 22px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
fontSize: '14px',
|
||||||
|
boxShadow: '0 8px 24px rgba(0,0,0,0.15)',
|
||||||
|
zIndex: 10000,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{toast.text}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* маленький CSS для спиннера */}
|
||||||
|
<style>{`
|
||||||
|
.spin { animation: spin 1s linear infinite; }
|
||||||
|
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||||
|
`}</style>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Profile;
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { FaHandsHelping, FaCalendarCheck, FaMoneyBillWave, FaHome, FaTimes } from 'react-icons/fa';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
|
interface TradeUnionProps {
|
||||||
|
onBack: () => void;
|
||||||
|
employee: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Block {
|
||||||
|
id: number;
|
||||||
|
tab: string;
|
||||||
|
content: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
{ id: 'events', title: 'Мероприятия', description: 'Анонсы и запись на мероприятия', icon: FaCalendarCheck, color: '#e74c3c' },
|
||||||
|
{ id: 'help', title: 'Материальная помощь', description: 'Размеры и виды материальной помощи', icon: FaMoneyBillWave, color: '#f39c12' },
|
||||||
|
{ id: 'housing', title: 'Жилищные условия', description: 'Информация об очереди на улучшение жилищных условий', icon: FaHome, color: '#3498db' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TradeUnion: React.FC<TradeUnionProps> = ({ onBack, employee }) => {
|
||||||
|
const [selectedSection, setSelectedSection] = useState<string | null>(null);
|
||||||
|
const [blocksMap, setBlocksMap] = useState<Record<string, Block[]>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchAllSections = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const results: Record<string, Block[]> = {};
|
||||||
|
for (const sec of sections) {
|
||||||
|
const response = await fetch(`http://localhost:3005/moders/content/section/${sec.id}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
results[sec.id] = Array.isArray(data) ? data : [];
|
||||||
|
} else {
|
||||||
|
results[sec.id] = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setBlocksMap(results);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ошибка загрузки разделов профсоюза:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchAllSections();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openModal = (sectionId: string) => {
|
||||||
|
setSelectedSection(sectionId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setSelectedSection(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedData = selectedSection ? sections.find(s => s.id === selectedSection) : null;
|
||||||
|
const selectedBlocks = selectedSection ? blocksMap[selectedSection] || [] : [];
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="trade-union-section"
|
||||||
|
style={{
|
||||||
|
padding: '20px',
|
||||||
|
maxWidth: '1200px',
|
||||||
|
margin: '0 auto',
|
||||||
|
paddingBottom: '80px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="top-bar" style={{ marginBottom: '25px' }}>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style={{
|
||||||
|
color: '#2d3436',
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: 300,
|
||||||
|
marginBottom: '30px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
}}>
|
||||||
|
<FaHandsHelping style={{ color: '#e74c3c', fontSize: '24px' }} />
|
||||||
|
Профсоюз
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ padding: '40px', textAlign: 'center', color: '#636e72' }}>Загрузка...</div>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
|
||||||
|
gap: '20px',
|
||||||
|
}}>
|
||||||
|
{sections.map((section) => {
|
||||||
|
const Icon = section.icon;
|
||||||
|
const blocks = blocksMap[section.id] || [];
|
||||||
|
const hasContent = blocks.length > 0;
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={section.id}
|
||||||
|
whileHover={{ y: -5 }}
|
||||||
|
onClick={() => openModal(section.id)}
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '25px',
|
||||||
|
boxShadow: '0 4px 20px rgba(0,0,0,0.06)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
border: `1px solid ${hasContent ? section.color : '#f1f2f6'}`,
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasContent && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '12px',
|
||||||
|
right: '12px',
|
||||||
|
background: section.color,
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '10px',
|
||||||
|
padding: '2px 10px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}>
|
||||||
|
{blocks.length} блок(ов)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ fontSize: '32px', color: section.color, marginBottom: '15px' }}>
|
||||||
|
<Icon />
|
||||||
|
</div>
|
||||||
|
<h3 style={{ margin: '0 0 10px 0', color: '#2d3436', fontSize: '18px' }}>
|
||||||
|
{section.title}
|
||||||
|
</h3>
|
||||||
|
<p style={{ color: '#636e72', fontSize: '14px', lineHeight: 1.6, margin: 0 }}>
|
||||||
|
{section.description}
|
||||||
|
</p>
|
||||||
|
{!hasContent && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: '12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#b2bec3',
|
||||||
|
}}>
|
||||||
|
Информация отсутствует
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ===== МОДАЛКА ===== */}
|
||||||
|
{selectedSection && selectedData && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: 'rgba(0,0,0,0.6)',
|
||||||
|
backdropFilter: 'blur(6px)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1000,
|
||||||
|
padding: '20px',
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) closeModal();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '20px',
|
||||||
|
width: '70%',
|
||||||
|
height: '80%',
|
||||||
|
boxShadow: '0 30px 80px rgba(0,0,0,0.3)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Шапка (заголовок + крестик) — фиксирована */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '20px 30px 10px 30px',
|
||||||
|
borderBottom: '1px solid #eee',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
background: 'white',
|
||||||
|
zIndex: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{
|
||||||
|
color: selectedData.color,
|
||||||
|
fontSize: '22px',
|
||||||
|
margin: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
}}>
|
||||||
|
<selectedData.icon style={{ fontSize: '24px' }} />
|
||||||
|
{selectedData.title}
|
||||||
|
</h3>
|
||||||
|
<div style={{ flex: 1, display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
onClick={closeModal}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
fontSize: '26px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#999',
|
||||||
|
padding: '0 8px',
|
||||||
|
transition: 'color 0.2s',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'right',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.color = '#333'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.color = '#999'}
|
||||||
|
>
|
||||||
|
<FaTimes />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Тело с прокруткой */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: '20px 30px 30px 30px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedBlocks.length === 0 ? (
|
||||||
|
<div style={{
|
||||||
|
padding: '40px',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#b2bec3',
|
||||||
|
fontSize: '16px',
|
||||||
|
}}>
|
||||||
|
Информация отсутствует
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '16px',
|
||||||
|
}}>
|
||||||
|
{selectedBlocks.map((block) => (
|
||||||
|
<div
|
||||||
|
key={block.id}
|
||||||
|
style={{
|
||||||
|
border: '1px solid #e8e8e8',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '18px 20px',
|
||||||
|
background: '#fafafa',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '15px',
|
||||||
|
lineHeight: 1.7,
|
||||||
|
color: '#2d3436',
|
||||||
|
}}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: DOMPurify.sanitize(block.content)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{
|
||||||
|
marginTop: '14px',
|
||||||
|
paddingTop: '12px',
|
||||||
|
borderTop: '1px solid #eee',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#999',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}>
|
||||||
|
<span>🕒 {formatDate(block.created_at)}</span>
|
||||||
|
<span style={{ fontSize: '11px', color: '#bbb' }}>ID: {block.id}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Нижняя кнопка закрытия */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '12px 30px 20px 30px',
|
||||||
|
borderTop: '1px solid #eee',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
background: 'white',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={closeModal}
|
||||||
|
style={{
|
||||||
|
padding: '10px 40px',
|
||||||
|
background: '#6c757d',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
transition: 'background 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.background = '#5a6268'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.background = '#6c757d'}
|
||||||
|
>
|
||||||
|
Закрыть
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TradeUnion;
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { FaChevronLeft, FaChevronRight, FaIdCard, FaKeyboard } from 'react-icons/fa';
|
||||||
|
|
||||||
|
interface VideoBackgroundProps {
|
||||||
|
onComplete: () => void;
|
||||||
|
videoSrc?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Slide {
|
||||||
|
id: number;
|
||||||
|
image_url: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
link?: string;
|
||||||
|
order: number;
|
||||||
|
is_active: number;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADMIN_SERVER_URL = 'http://localhost:3005';
|
||||||
|
|
||||||
|
const VideoBackground: React.FC<VideoBackgroundProps> = ({
|
||||||
|
onComplete,
|
||||||
|
videoSrc = '/assets/videos/flag.mp4',
|
||||||
|
}) => {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const [slides, setSlides] = useState<Slide[]>([]);
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isAutoPlay, setIsAutoPlay] = useState(true);
|
||||||
|
const [showHint, setShowHint] = useState(false);
|
||||||
|
const autoPlayInterval = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// ---------- Загрузка слайдов ----------
|
||||||
|
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 sorted = Array.isArray(data)
|
||||||
|
? data
|
||||||
|
.filter((s: Slide) => s.is_active !== 0)
|
||||||
|
.sort((a: Slide, b: Slide) => (a.order || 0) - (b.order || 0))
|
||||||
|
: [];
|
||||||
|
setSlides(sorted.length ? sorted : getDefaultSlides());
|
||||||
|
} else {
|
||||||
|
setSlides(getDefaultSlides());
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setSlides(getDefaultSlides());
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchSlides();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getDefaultSlides = (): Slide[] => [
|
||||||
|
{ id: 1, image_url: 'https://picsum.photos/1920/1080?random=1', title: 'Добро пожаловать!', description: 'Инфокиоск для сотрудников', order: 1, is_active: 1 },
|
||||||
|
{ id: 2, image_url: 'https://picsum.photos/1920/1080?random=2', title: 'Новости предприятия', description: 'Будьте в курсе событий', order: 2, is_active: 1 },
|
||||||
|
{ id: 3, image_url: 'https://picsum.photos/1920/1080?random=3', title: 'С днём рождения!', description: 'Поздравляем именинников', order: 3, is_active: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------- Видео ----------
|
||||||
|
useEffect(() => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.play().catch(() => {
|
||||||
|
// если видео не проигрывается (нет файла) — ничего не делаем,
|
||||||
|
// пользователь сам нажмёт кнопку входа
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ---------- Навигация ----------
|
||||||
|
const goToNext = () => {
|
||||||
|
if (slides.length <= 1) return;
|
||||||
|
setCurrentIndex((p) => (p + 1) % slides.length);
|
||||||
|
};
|
||||||
|
const goToPrev = () => {
|
||||||
|
if (slides.length <= 1) return;
|
||||||
|
setCurrentIndex((p) => (p - 1 + slides.length) % slides.length);
|
||||||
|
};
|
||||||
|
const goToSlide = (index: number) => {
|
||||||
|
setCurrentIndex(index);
|
||||||
|
resetAutoPlay();
|
||||||
|
};
|
||||||
|
const resetAutoPlay = () => {
|
||||||
|
if (autoPlayInterval.current) clearInterval(autoPlayInterval.current);
|
||||||
|
if (slides.length > 1 && isAutoPlay) {
|
||||||
|
autoPlayInterval.current = setInterval(goToNext, 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Автопрокрутка ----------
|
||||||
|
useEffect(() => {
|
||||||
|
if (slides.length <= 1 || !isAutoPlay) return;
|
||||||
|
autoPlayInterval.current = setInterval(goToNext, 5000);
|
||||||
|
return () => {
|
||||||
|
if (autoPlayInterval.current) clearInterval(autoPlayInterval.current);
|
||||||
|
};
|
||||||
|
}, [slides.length, isAutoPlay, currentIndex]);
|
||||||
|
|
||||||
|
// ---------- Клавиатура ----------
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'ArrowLeft') { e.preventDefault(); goToPrev(); }
|
||||||
|
else if (e.key === 'ArrowRight') { e.preventDefault(); goToNext(); }
|
||||||
|
else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onComplete(); }
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [onComplete, slides.length]);
|
||||||
|
|
||||||
|
// ---------- Ховер по краям — пауза автопрокрутки ----------
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
setIsAutoPlay(false);
|
||||||
|
if (autoPlayInterval.current) clearInterval(autoPlayInterval.current);
|
||||||
|
};
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
setIsAutoPlay(true);
|
||||||
|
resetAutoPlay();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Переход к вводу ID ----------
|
||||||
|
const handleEnterClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onComplete();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Загрузка ----------
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', inset: 0, zIndex: 9999,
|
||||||
|
background: '#000', display: 'flex',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 60, height: 60,
|
||||||
|
border: '4px solid #2d2d2d',
|
||||||
|
borderTop: '4px solid #667eea',
|
||||||
|
borderRadius: '50%',
|
||||||
|
animation: 'spin 1s linear infinite',
|
||||||
|
}} />
|
||||||
|
<style>{`@keyframes spin { from{transform:rotate(0)} to{transform:rotate(360deg)} }`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSlides = slides.length > 0;
|
||||||
|
const current = hasSlides ? slides[currentIndex] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
style={{
|
||||||
|
position: 'fixed', inset: 0, zIndex: 9999,
|
||||||
|
background: '#000',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
{/* ============ ВИДЕО ФОН ============ */}
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={videoSrc}
|
||||||
|
autoPlay muted playsInline loop
|
||||||
|
style={{
|
||||||
|
position: 'absolute', width: '100%', height: '100%',
|
||||||
|
objectFit: 'cover', zIndex: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ============ ТЁМНЫЙ ОВЕРЛЕЙ ============ */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 0,
|
||||||
|
background: 'rgba(0, 0, 0, 0.45)', zIndex: 1,
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* ============ КАРУСЕЛЬ ============ */}
|
||||||
|
{hasSlides && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 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: '78%',
|
||||||
|
maxWidth: '900px',
|
||||||
|
maxHeight: '65vh',
|
||||||
|
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={
|
||||||
|
current!.image_url.startsWith('http')
|
||||||
|
? current!.image_url
|
||||||
|
: `${ADMIN_SERVER_URL}${current!.image_url}`
|
||||||
|
}
|
||||||
|
alt={current!.title || 'Слайд'}
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
maxHeight: '65vh',
|
||||||
|
objectFit: 'cover',
|
||||||
|
display: 'block',
|
||||||
|
}}
|
||||||
|
onError={(e) => {
|
||||||
|
(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';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(current!.title || current!.description) && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', left: 0, right: 0, bottom: 0,
|
||||||
|
padding: '30px 40px 40px',
|
||||||
|
background: 'linear-gradient(transparent, rgba(0,0,0,0.75))',
|
||||||
|
color: '#fff',
|
||||||
|
}}>
|
||||||
|
{current!.title && (
|
||||||
|
<h2 style={{
|
||||||
|
margin: 0, fontSize: '28px',
|
||||||
|
fontWeight: 600,
|
||||||
|
textShadow: '0 2px 10px rgba(0,0,0,0.3)',
|
||||||
|
}}>
|
||||||
|
{current!.title}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
{current!.description && (
|
||||||
|
<p style={{
|
||||||
|
margin: '8px 0 0', fontSize: '16px',
|
||||||
|
opacity: 0.85,
|
||||||
|
textShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
||||||
|
}}>
|
||||||
|
{current!.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ============ КНОПКИ НАВИГАЦИИ ============ */}
|
||||||
|
{slides.length > 1 && (
|
||||||
|
<>
|
||||||
|
<NavArrow direction="left" onClick={goToPrev} />
|
||||||
|
<NavArrow direction="right" onClick={goToNext} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ============ ИНДИКАТОРЫ ============ */}
|
||||||
|
{slides.length > 1 && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '120px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '10px',
|
||||||
|
zIndex: 10,
|
||||||
|
}}>
|
||||||
|
{slides.map((_, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={(e) => { e.stopPropagation(); goToSlide(index); }}
|
||||||
|
aria-label={`Слайд ${index + 1}`}
|
||||||
|
style={{
|
||||||
|
width: index === currentIndex ? '28px' : '10px',
|
||||||
|
height: '10px',
|
||||||
|
borderRadius: '5px',
|
||||||
|
background: index === currentIndex
|
||||||
|
? 'rgba(255,255,255,0.9)'
|
||||||
|
: 'rgba(255,255,255,0.35)',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ============ КНОПКА ВХОДА ============ */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '40px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: 20,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
}}>
|
||||||
|
<motion.button
|
||||||
|
onClick={handleEnterClick}
|
||||||
|
whileHover={{ scale: 1.04 }}
|
||||||
|
whileTap={{ scale: 0.97 }}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
padding: '16px 34px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '999px',
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '17px',
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
boxShadow: '0 10px 30px rgba(102, 126, 234, 0.45)',
|
||||||
|
letterSpacing: '0.3px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaIdCard style={{ fontSize: 20 }} />
|
||||||
|
Ввод табельного номера
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
{/* Подсказка «Enter» (появляется при наведении) */}
|
||||||
|
<div
|
||||||
|
onMouseEnter={() => setShowHint(true)}
|
||||||
|
onMouseLeave={() => setShowHint(false)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
color: 'rgba(255,255,255,0.5)',
|
||||||
|
fontSize: 12,
|
||||||
|
transition: 'opacity 0.2s',
|
||||||
|
opacity: showHint ? 1 : 0.55,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaKeyboard /> или нажмите <b style={{ margin: '0 4px' }}>Enter</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ============ СТИЛИ ============ */}
|
||||||
|
<style>{`
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Кнопка-стрелка (вынесена отдельно, чтобы не дублировать стили)
|
||||||
|
// ============================================================
|
||||||
|
const NavArrow: React.FC<{
|
||||||
|
direction: 'left' | 'right';
|
||||||
|
onClick: () => void;
|
||||||
|
}> = ({ direction, onClick }) => {
|
||||||
|
const isLeft = direction === 'left';
|
||||||
|
|
||||||
|
return (
|
||||||
|
// ── Позиционирование — на обычном div, БЕЗ framer-motion ──
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
[isLeft ? 'left' : 'right']: '25px',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* ── Анимация — только scale, без translate ── */}
|
||||||
|
<motion.button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onClick(); }}
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
|
||||||
|
aria-label={isLeft ? 'Предыдущий слайд' : 'Следующий слайд'}
|
||||||
|
style={{
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'rgba(255,255,255,0.12)',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
WebkitBackdropFilter: 'blur(10px)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.2)',
|
||||||
|
color: '#fff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: 22,
|
||||||
|
transition: 'background 0.2s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,0.25)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,0.12)')}
|
||||||
|
>
|
||||||
|
{isLeft ? <FaChevronLeft /> : <FaChevronRight />}
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VideoBackground;
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
interface WeatherDay {
|
||||||
|
date: string;
|
||||||
|
dayOfWeek: string;
|
||||||
|
tempMin: number;
|
||||||
|
tempMax: number;
|
||||||
|
icon: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WeatherWidgetProps {
|
||||||
|
onLogout?: () => void;
|
||||||
|
employee?: {
|
||||||
|
ima: string;
|
||||||
|
famaly: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const WeatherWidget: React.FC<WeatherWidgetProps> = ({ onLogout, employee }) => {
|
||||||
|
const [weatherData, setWeatherData] = useState<WeatherDay[]>([]);
|
||||||
|
const [currentWeather, setCurrentWeather] = useState<WeatherDay | null>(null);
|
||||||
|
const [isOnline, setIsOnline] = useState(navigator.onLine);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [currentTime, setCurrentTime] = useState(new Date());
|
||||||
|
|
||||||
|
// ⏰ ВРЕМЯ
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setCurrentTime(new Date());
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 🌐 СТАТУС ИНТЕРНЕТА
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOnline = () => setIsOnline(true);
|
||||||
|
const handleOffline = () => setIsOnline(false);
|
||||||
|
window.addEventListener('online', handleOnline);
|
||||||
|
window.addEventListener('offline', handleOffline);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', handleOnline);
|
||||||
|
window.removeEventListener('offline', handleOffline);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 🌤️ ЗАГРУЗКА ПОГОДЫ
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchWeather = async () => {
|
||||||
|
if (!isOnline) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const apiKey = '9edc3b8058098d110db0aa1894a08c97';
|
||||||
|
const url = `https://api.openweathermap.org/data/2.5/forecast?q=Grodno&units=metric&lang=ru&appid=${apiKey}`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
setCurrentWeather({
|
||||||
|
date: new Date().toISOString().split('T')[0],
|
||||||
|
dayOfWeek: 'Сегодня',
|
||||||
|
tempMin: 18,
|
||||||
|
tempMax: 24,
|
||||||
|
icon: '01d',
|
||||||
|
description: 'Ясно',
|
||||||
|
});
|
||||||
|
setWeatherData([
|
||||||
|
{ date: '2026-08-17', dayOfWeek: 'Пн', tempMin: 18, tempMax: 24, icon: '01d', description: 'Ясно' },
|
||||||
|
{ date: '2026-08-18', dayOfWeek: 'Вт', tempMin: 19, tempMax: 25, icon: '02d', description: 'Облачно' },
|
||||||
|
{ date: '2026-08-19', dayOfWeek: 'Ср', tempMin: 17, tempMax: 23, icon: '10d', description: 'Дождь' },
|
||||||
|
{ date: '2026-08-20', dayOfWeek: 'Чт', tempMin: 16, tempMax: 22, icon: '03d', description: 'Облачно' },
|
||||||
|
{ date: '2026-08-21', dayOfWeek: 'Пт', tempMin: 18, tempMax: 24, icon: '01d', description: 'Ясно' },
|
||||||
|
{ date: '2026-08-22', dayOfWeek: 'Сб', tempMin: 20, tempMax: 26, icon: '01d', description: 'Ясно' },
|
||||||
|
{ date: '2026-08-23', dayOfWeek: 'Вс', tempMin: 21, tempMax: 27, icon: '02d', description: 'Облачно' },
|
||||||
|
]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const current = data.list[0];
|
||||||
|
setCurrentWeather({
|
||||||
|
date: new Date(current.dt * 1000).toISOString().split('T')[0],
|
||||||
|
dayOfWeek: 'Сегодня',
|
||||||
|
tempMin: current.main.temp_min,
|
||||||
|
tempMax: current.main.temp_max,
|
||||||
|
icon: current.weather[0].icon,
|
||||||
|
description: current.weather[0].description,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dailyForecast: WeatherDay[] = [];
|
||||||
|
const daysMap = new Map();
|
||||||
|
|
||||||
|
data.list.forEach((item: any) => {
|
||||||
|
const date = new Date(item.dt * 1000);
|
||||||
|
const dateStr = date.toISOString().split('T')[0];
|
||||||
|
const dayOfWeek = date.toLocaleString('ru-RU', { weekday: 'short' });
|
||||||
|
|
||||||
|
if (!daysMap.has(dateStr)) {
|
||||||
|
daysMap.set(dateStr, {
|
||||||
|
date: dateStr,
|
||||||
|
dayOfWeek: dayOfWeek.charAt(0).toUpperCase() + dayOfWeek.slice(1),
|
||||||
|
tempMin: item.main.temp_min,
|
||||||
|
tempMax: item.main.temp_max,
|
||||||
|
icon: item.weather[0].icon,
|
||||||
|
description: item.weather[0].description,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const existing = daysMap.get(dateStr);
|
||||||
|
existing.tempMin = Math.min(existing.tempMin, item.main.temp_min);
|
||||||
|
existing.tempMax = Math.max(existing.tempMax, item.main.temp_max);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const sevenDays = Array.from(daysMap.values()).slice(0, 7);
|
||||||
|
setWeatherData(sevenDays);
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Ошибка загрузки погоды:', error);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchWeather();
|
||||||
|
const interval = setInterval(fetchWeather, 30 * 60 * 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [isOnline]);
|
||||||
|
|
||||||
|
const getWeatherIcon = (iconCode: string) => {
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
'01d': '☀️',
|
||||||
|
'01n': '🌙',
|
||||||
|
'02d': '⛅',
|
||||||
|
'02n': '☁️',
|
||||||
|
'03d': '☁️',
|
||||||
|
'03n': '☁️',
|
||||||
|
'04d': '☁️',
|
||||||
|
'04n': '☁️',
|
||||||
|
'09d': '🌧️',
|
||||||
|
'09n': '🌧️',
|
||||||
|
'10d': '🌦️',
|
||||||
|
'10n': '🌧️',
|
||||||
|
'11d': '⛈️',
|
||||||
|
'11n': '⛈️',
|
||||||
|
'13d': '❄️',
|
||||||
|
'13n': '❄️',
|
||||||
|
'50d': '🌫️',
|
||||||
|
'50n': '🌫️',
|
||||||
|
};
|
||||||
|
return icons[iconCode] || '🌡️';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
if (onLogout) {
|
||||||
|
onLogout();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const forecastDays = weatherData.slice(1, 7);
|
||||||
|
const doubledForecast = [...forecastDays, ...forecastDays, ...forecastDays];
|
||||||
|
|
||||||
|
const getGreeting = () => {
|
||||||
|
if (!employee) return '';
|
||||||
|
const hour = currentTime.getHours();
|
||||||
|
let greeting = '';
|
||||||
|
if (hour >= 5 && hour < 12) greeting = 'Доброе утро';
|
||||||
|
else if (hour >= 12 && hour < 17) greeting = 'Добрый день';
|
||||||
|
else if (hour >= 17 && hour < 22) greeting = 'Добрый вечер';
|
||||||
|
else greeting = 'Доброй ночи';
|
||||||
|
return `${greeting}, ${employee.ima}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: -100, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 9999,
|
||||||
|
background: 'linear-gradient(135deg, #1b718b 65%, #473f72 100%)',
|
||||||
|
backdropFilter: 'blur(16px)',
|
||||||
|
WebkitBackdropFilter: 'blur(16px)',
|
||||||
|
padding: '6px 20px',
|
||||||
|
borderBottom: '1px solid rgba(255,255,255,0.06)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
minHeight: '75px',
|
||||||
|
gap: '8px',
|
||||||
|
flexWrap: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* ===== ЛЕВАЯ ЧАСТЬ: ВРЕМЯ + ПОГОДА СЕГОДНЯ ===== */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '16px',
|
||||||
|
flexShrink: 0,
|
||||||
|
zIndex: 2,
|
||||||
|
}}>
|
||||||
|
{/* Время */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
minWidth: '90px',
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '38px',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#fff',
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
lineHeight: 1.1,
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
textShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||||
|
}}>
|
||||||
|
{currentTime.toLocaleString('ru-RU', {
|
||||||
|
timeZone: 'Europe/Minsk',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
color: 'rgba(255,255,255,0.4)',
|
||||||
|
letterSpacing: '0.3px',
|
||||||
|
fontWeight: 400,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}>
|
||||||
|
{currentTime.toLocaleString('ru-RU', {
|
||||||
|
timeZone: 'Europe/Minsk',
|
||||||
|
weekday: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Разделитель */}
|
||||||
|
<div style={{
|
||||||
|
width: '1px',
|
||||||
|
height: '32px',
|
||||||
|
background: 'rgba(255,255,255,0.06)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* Погода сегодня */}
|
||||||
|
{isOnline && !loading && currentWeather && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
background: 'rgba(255,255,255,0.05)',
|
||||||
|
padding: '4px 16px 4px 12px',
|
||||||
|
borderRadius: '30px',
|
||||||
|
border: '1px solid rgba(255,255,255,0.04)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '26px', lineHeight: 1 }}>
|
||||||
|
{getWeatherIcon(currentWeather.icon)}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', lineHeight: 1.2 }}>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#fff',
|
||||||
|
}}>
|
||||||
|
{Math.round(currentWeather.tempMax)}°
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
color: 'rgba(255,255,255,0.35)',
|
||||||
|
textTransform: 'capitalize',
|
||||||
|
}}>
|
||||||
|
{currentWeather.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== ЦЕНТРАЛЬНАЯ ЧАСТЬ: ПРИВЕТСТВИЕ ===== */}
|
||||||
|
<div style={{
|
||||||
|
flex: '1 1 0%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
height: '100%',
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
}}>
|
||||||
|
{employee && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -5 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.2 }}
|
||||||
|
style={{
|
||||||
|
fontSize: 'clamp(18px, 1.6vw, 28px)',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'rgba(255,255,255,0.92)',
|
||||||
|
textAlign: 'center',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
letterSpacing: '0.8px',
|
||||||
|
textShadow: '0 2px 12px rgba(0,0,0,0.15)',
|
||||||
|
background: 'rgba(255,255,255,0.04)',
|
||||||
|
padding: '4px 24px',
|
||||||
|
borderRadius: '30px',
|
||||||
|
border: '1px solid rgba(255,255,255,0.04)',
|
||||||
|
maxWidth: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getGreeting()}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== ПРАВАЯ ЧАСТЬ: ПРОГНОЗ С ПРОКРУТКОЙ ===== */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
flexShrink: 0,
|
||||||
|
maxWidth: '380px',
|
||||||
|
minWidth: '140px',
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
maskImage: 'linear-gradient(to right, transparent, black 3%, black 97%, transparent)',
|
||||||
|
WebkitMaskImage: 'linear-gradient(to right, transparent, black 3%, black 97%, transparent)',
|
||||||
|
}}>
|
||||||
|
<motion.div
|
||||||
|
animate={{
|
||||||
|
x: ['0%', '-33.33%'],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 20,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: 'linear',
|
||||||
|
repeatType: 'loop',
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '14px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
willChange: 'transform',
|
||||||
|
transform: 'translateZ(0)',
|
||||||
|
backfaceVisibility: 'hidden',
|
||||||
|
WebkitFontSmoothing: 'antialiased',
|
||||||
|
MozOsxFontSmoothing: 'grayscale',
|
||||||
|
textRendering: 'geometricPrecision',
|
||||||
|
paddingRight: '14px',
|
||||||
|
height: '100%',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{doubledForecast.map((day, index) => (
|
||||||
|
<div
|
||||||
|
key={`${day.date}-${index}`}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: '4px 12px',
|
||||||
|
borderRadius: '20px',
|
||||||
|
background: 'rgba(255,255,255,0.04)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.03)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
color: 'rgba(255,255,255,0.4)',
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: '0.3px',
|
||||||
|
minWidth: '20px',
|
||||||
|
}}>
|
||||||
|
{day.dayOfWeek}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: '18px', lineHeight: 1 }}>
|
||||||
|
{getWeatherIcon(day.icon)}
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'rgba(255,255,255,0.85)',
|
||||||
|
minWidth: '24px',
|
||||||
|
textAlign: 'right',
|
||||||
|
}}>
|
||||||
|
{Math.round(day.tempMax)}°
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== КНОПКА ВЫХОДА ===== */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
marginLeft: '4px',
|
||||||
|
zIndex: 2,
|
||||||
|
}}>
|
||||||
|
{onLogout && (
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
onClick={handleLogout}
|
||||||
|
style={{
|
||||||
|
padding: '6px 18px',
|
||||||
|
background: 'rgba(231, 76, 60, 0.5)',
|
||||||
|
color: 'white',
|
||||||
|
border: '1px solid rgba(255,255,255,0.06)',
|
||||||
|
borderRadius: '30px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 500,
|
||||||
|
transition: 'all 0.25s ease',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
minHeight: '32px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
boxShadow: '0 2px 12px rgba(231, 76, 60, 0.1)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = 'rgba(231, 76, 60, 0.8)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 4px 20px rgba(231, 76, 60, 0.25)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'rgba(231, 76, 60, 0.5)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 2px 12px rgba(231, 76, 60, 0.1)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ display: 'inline', padding: '5px 10px' }}>ВЫЙТИ</span>
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WeatherWidget;
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
interface UseActivityTimerProps {
|
||||||
|
timeout: number; // Время до предупреждения (10 сек)
|
||||||
|
warningTimeout: number; // Время до закрытия после предупреждения (5 сек)
|
||||||
|
onInactive: () => void; // Колбэк при закрытии
|
||||||
|
onWarning?: () => void; // Колбэк при показе предупреждения
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useActivityTimer = ({
|
||||||
|
timeout,
|
||||||
|
warningTimeout,
|
||||||
|
onInactive,
|
||||||
|
onWarning
|
||||||
|
}: UseActivityTimerProps) => {
|
||||||
|
// 📊 Состояния
|
||||||
|
const [isWarning, setIsWarning] = useState(false);
|
||||||
|
const [timeLeft, setTimeLeft] = useState(timeout);
|
||||||
|
|
||||||
|
// 📦 Рефы для таймеров
|
||||||
|
const mainTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const warningTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const lastActivityRef = useRef<number>(Date.now());
|
||||||
|
const onInactiveRef = useRef(onInactive);
|
||||||
|
const onWarningRef = useRef(onWarning);
|
||||||
|
|
||||||
|
// Обновляем ссылки на колбэки
|
||||||
|
useEffect(() => {
|
||||||
|
onInactiveRef.current = onInactive;
|
||||||
|
}, [onInactive]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onWarningRef.current = onWarning;
|
||||||
|
}, [onWarning]);
|
||||||
|
|
||||||
|
// 🧹 Очистка всех таймеров
|
||||||
|
const clearAllTimers = useCallback(() => {
|
||||||
|
if (mainTimerRef.current) {
|
||||||
|
clearTimeout(mainTimerRef.current);
|
||||||
|
mainTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (warningTimerRef.current) {
|
||||||
|
clearTimeout(warningTimerRef.current);
|
||||||
|
warningTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (countdownIntervalRef.current) {
|
||||||
|
clearInterval(countdownIntervalRef.current);
|
||||||
|
countdownIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 🔄 Сброс таймера (при активности)
|
||||||
|
const resetTimer = useCallback(() => {
|
||||||
|
// Обновляем время последней активности
|
||||||
|
lastActivityRef.current = Date.now();
|
||||||
|
|
||||||
|
// Сбрасываем состояние предупреждения
|
||||||
|
setIsWarning(false);
|
||||||
|
setTimeLeft(timeout);
|
||||||
|
|
||||||
|
// Очищаем все таймеры
|
||||||
|
clearAllTimers();
|
||||||
|
|
||||||
|
console.log(`[ACTIVITY TIMER] 🔄 Таймер сброшен (таймаут: ${timeout}ms)`);
|
||||||
|
|
||||||
|
// ✅ Основной таймер (через timeout мс показываем предупреждение)
|
||||||
|
mainTimerRef.current = setTimeout(() => {
|
||||||
|
console.log(`[ACTIVITY TIMER] ⚠️ Показываем предупреждение!`);
|
||||||
|
|
||||||
|
// Показываем предупреждение
|
||||||
|
setIsWarning(true);
|
||||||
|
setTimeLeft(warningTimeout);
|
||||||
|
|
||||||
|
// Вызываем колбэк предупреждения
|
||||||
|
if (onWarningRef.current) {
|
||||||
|
onWarningRef.current();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔄 Таймер обратного отсчета (обновляем каждую секунду)
|
||||||
|
countdownIntervalRef.current = setInterval(() => {
|
||||||
|
setTimeLeft(prev => {
|
||||||
|
const newTime = prev - 1000;
|
||||||
|
console.log(`[ACTIVITY TIMER] ⏰ Осталось: ${newTime/1000} сек`);
|
||||||
|
return Math.max(0, newTime);
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// ✅ Таймер закрытия (через warningTimeout мс)
|
||||||
|
warningTimerRef.current = setTimeout(() => {
|
||||||
|
console.log(`[ACTIVITY TIMER] 🛑 Время вышло! Вызываем onInactive`);
|
||||||
|
|
||||||
|
// Очищаем интервал
|
||||||
|
if (countdownIntervalRef.current) {
|
||||||
|
clearInterval(countdownIntervalRef.current);
|
||||||
|
countdownIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вызываем колбэк
|
||||||
|
onInactiveRef.current();
|
||||||
|
|
||||||
|
}, warningTimeout);
|
||||||
|
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
|
}, [timeout, warningTimeout, clearAllTimers]);
|
||||||
|
|
||||||
|
// 🖱️ Обработчик активности
|
||||||
|
useEffect(() => {
|
||||||
|
// События, которые считаются активностью
|
||||||
|
const events = [
|
||||||
|
'mousedown',
|
||||||
|
'mousemove',
|
||||||
|
'keydown',
|
||||||
|
'scroll',
|
||||||
|
'touchstart',
|
||||||
|
'click',
|
||||||
|
'wheel',
|
||||||
|
'touchmove',
|
||||||
|
'focus'
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleActivity = () => {
|
||||||
|
// Если было предупреждение — сбрасываем
|
||||||
|
if (isWarning) {
|
||||||
|
console.log('[ACTIVITY TIMER] 👆 Активность! Сбрасываем предупреждение');
|
||||||
|
}
|
||||||
|
resetTimer();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Добавляем обработчики
|
||||||
|
events.forEach(event => {
|
||||||
|
document.addEventListener(event, handleActivity, { passive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Запускаем таймер
|
||||||
|
resetTimer();
|
||||||
|
|
||||||
|
console.log(`[ACTIVITY TIMER] 🚀 Инициализирован (таймаут: ${timeout}ms, предупреждение: ${warningTimeout}ms)`);
|
||||||
|
|
||||||
|
// Очистка
|
||||||
|
return () => {
|
||||||
|
events.forEach(event => {
|
||||||
|
document.removeEventListener(event, handleActivity);
|
||||||
|
});
|
||||||
|
clearAllTimers();
|
||||||
|
console.log('[ACTIVITY TIMER] 🧹 Очищен');
|
||||||
|
};
|
||||||
|
}, [resetTimer, clearAllTimers]);
|
||||||
|
|
||||||
|
// ✅ Возвращаем состояние для отображения в UI
|
||||||
|
return {
|
||||||
|
isWarning, // ⚠️ Показывать ли предупреждение
|
||||||
|
timeLeft, // ⏰ Оставшееся время в мс
|
||||||
|
resetTimer // 🔄 Ручной сброс
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useActivityTimer;
|
||||||
@@ -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[]>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
/* ============================================
|
||||||
|
АНИМАЦИИ
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Плавное появление */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in {
|
||||||
|
animation: fadeIn 0.5s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Плавное появление с задержкой */
|
||||||
|
.fade-in-delay {
|
||||||
|
animation: fadeIn 0.5s ease-out forwards;
|
||||||
|
animation-delay: 0.3s;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Пульсация */
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse {
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Вращение (для спиннера) */
|
||||||
|
@keyframes spin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Скелетон загрузки */
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Скольжение снизу */
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-up {
|
||||||
|
animation: slideUp 0.6s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Скольжение сверху */
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-30px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-down {
|
||||||
|
animation: slideDown 0.6s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Масштабирование */
|
||||||
|
@keyframes scaleIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scale-in {
|
||||||
|
animation: scaleIn 0.5s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Бегущая строка */
|
||||||
|
@keyframes ticker {
|
||||||
|
0% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticker {
|
||||||
|
animation: ticker 20s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticker:hover {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Затемнение фона */
|
||||||
|
@keyframes overlayIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-in {
|
||||||
|
animation: overlayIn 0.3s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Всплывающая подсказка */
|
||||||
|
@keyframes tooltipIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px) scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-in {
|
||||||
|
animation: tooltipIn 0.2s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Затухание */
|
||||||
|
@keyframes fadeOut {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-out {
|
||||||
|
animation: fadeOut 0.3s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Пульсирующее предупреждение */
|
||||||
|
@keyframes pulseWarning {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.03);
|
||||||
|
box-shadow: 0 20px 80px rgba(255, 193, 7, 0.5);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Мигающий текст обратного отсчета */
|
||||||
|
@keyframes blinkText {
|
||||||
|
0% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Карта */
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Для плавного появления/исчезновения контролов */
|
||||||
|
@keyframes fadeSlideDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeSlideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
/* Общие стили */
|
/* Общие стили */
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -8,9 +14,12 @@ body {
|
|||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
background-color: #f5f5f5;
|
background-color: #f5f5f5;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Окно логина */
|
/* ============================================
|
||||||
|
ОКНО ЛОГИНА
|
||||||
|
============================================ */
|
||||||
.login-window {
|
.login-window {
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
margin: 12% auto;
|
margin: 12% auto;
|
||||||
@@ -18,7 +27,6 @@ body {
|
|||||||
background: white;
|
background: white;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-window h2 {
|
.login-window h2 {
|
||||||
@@ -83,7 +91,199 @@ button:disabled {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Информация о сотруднике */
|
/* ============================================
|
||||||
|
ГЛАВНОЕ МЕНЮ
|
||||||
|
============================================ */
|
||||||
|
.main-menu {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-menu h1 {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 200px));
|
||||||
|
gap: 25px;
|
||||||
|
max-width: 900px;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 25px 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border: none;
|
||||||
|
border-radius: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
|
||||||
|
min-height: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:hover {
|
||||||
|
transform: translateY(-5px) scale(1.05);
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item .icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item .title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #2d3436;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
ВИДЕО-ЗАСТАВКА
|
||||||
|
============================================ */
|
||||||
|
.video-splash {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
background: #000;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-splash video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-splash .hint {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 60px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
text-align: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.6;
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
НИЖНЯЯ ПАНЕЛЬ (КУРСЫ ВАЛЮТ)
|
||||||
|
============================================ */
|
||||||
|
.bottom-bar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: rgba(44, 62, 80, 0.95);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
color: #fff;
|
||||||
|
padding: 8px 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 1000;
|
||||||
|
height: 40px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-bar .status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-bar .status .online {
|
||||||
|
color: #2ecc71;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-bar .status .offline {
|
||||||
|
color: #e74c3c;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-bar .status .label {
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-bar .rates {
|
||||||
|
display: flex;
|
||||||
|
gap: 25px;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-bar .rates .item {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-bar .rates .item .currency {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-bar .rates .item .change-up {
|
||||||
|
color: #2ecc71;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-bar .rates .item .change-down {
|
||||||
|
color: #e74c3c;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-bar .rates .divider {
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-bar .time {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.4;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
ИНФОРМАЦИЯ О СОТРУДНИКЕ
|
||||||
|
============================================ */
|
||||||
.employee-info {
|
.employee-info {
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
margin: 20px auto;
|
margin: 20px auto;
|
||||||
@@ -128,7 +328,9 @@ button:disabled {
|
|||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Секция расчётных листов */
|
/* ============================================
|
||||||
|
РАСЧЁТНЫЙ ЛИСТ
|
||||||
|
============================================ */
|
||||||
.payslip-section {
|
.payslip-section {
|
||||||
margin-top: 30px;
|
margin-top: 30px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
@@ -170,33 +372,16 @@ button:disabled {
|
|||||||
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
|
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-buttons {
|
/* ============================================
|
||||||
display: flex;
|
ВЕРХНЯЯ ПАНЕЛЬ (КНОПКА НАЗАД)
|
||||||
gap: 10px;
|
============================================ */
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-buttons button {
|
|
||||||
background-color: #28a745;
|
|
||||||
color: white;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-buttons button:hover {
|
|
||||||
background-color: #218838;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-buttons button:first-child {
|
|
||||||
background-color: #007bff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-buttons button:first-child:hover {
|
|
||||||
background-color: #0056b3;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Верхняя панель с кнопкой назад */
|
|
||||||
.top-bar {
|
.top-bar {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-button {
|
.back-button {
|
||||||
@@ -210,20 +395,19 @@ button:disabled {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
transition: background-color 0.2s;
|
transition: all 0.2s ease;
|
||||||
}
|
|
||||||
|
|
||||||
.back-button:hover {
|
|
||||||
background-color: #545b62;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Делаем кнопку назад компактнее */
|
|
||||||
.back-button {
|
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Гостевой режим */
|
.back-button:hover {
|
||||||
|
background-color: #545b62;
|
||||||
|
transform: translateX(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
ГОСТЕВОЙ РЕЖИМ
|
||||||
|
============================================ */
|
||||||
.guest-mode {
|
.guest-mode {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 20px auto;
|
margin: 20px auto;
|
||||||
@@ -287,7 +471,6 @@ button:disabled {
|
|||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #666;
|
color: #666;
|
||||||
@@ -308,94 +491,262 @@ button:disabled {
|
|||||||
color: #555;
|
color: #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Handbook Content Styles --- */
|
/* ============================================
|
||||||
|
HANDBOOK СТИЛИ
|
||||||
|
============================================ */
|
||||||
.handbook-content {
|
.handbook-content {
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 24px 12px;
|
padding: 24px 12px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
}
|
}
|
||||||
.handbook-content h1 {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 2rem;
|
|
||||||
margin: 24px 0 16px 0;
|
|
||||||
color: #1a237e;
|
|
||||||
}
|
|
||||||
.handbook-content h2 {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
margin: 20px 0 12px 0;
|
|
||||||
color: #283593;
|
|
||||||
}
|
|
||||||
.handbook-content p {
|
|
||||||
margin: 10px 0;
|
|
||||||
font-size: 1.08rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
.handbook-content ul {
|
|
||||||
margin: 12px 0 12px 32px;
|
|
||||||
padding-left: 18px;
|
|
||||||
}
|
|
||||||
.handbook-content li {
|
|
||||||
margin: 6px 0;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
.handbook-content strong {
|
|
||||||
font-weight: bold;
|
|
||||||
color: #000000;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
|
.handbook-content h1 {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 2rem;
|
||||||
|
margin: 24px 0 16px 0;
|
||||||
|
color: #1a237e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handbook-content h2 {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin: 20px 0 12px 0;
|
||||||
|
color: #283593;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handbook-content p {
|
||||||
|
margin: 10px 0;
|
||||||
|
font-size: 1.08rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handbook-content ul {
|
||||||
|
margin: 12px 0 12px 32px;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handbook-content li {
|
||||||
|
margin: 6px 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handbook-content strong {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #000000;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.handbook-content h3 {
|
.handbook-content h3 {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #000000;
|
color: #000000;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.handbook-content em {
|
.handbook-content em {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: #555;
|
color: #555;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.handbook-content h4 {
|
.handbook-content h4 {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #000000;
|
color: #000000;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.handbook-table {
|
.handbook-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.handbook-table th,
|
.handbook-table th,
|
||||||
.handbook-table td {
|
.handbook-table td {
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #ccc;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.handbook-table th {
|
.handbook-table th {
|
||||||
background: #f5f5f5;
|
background: #f5f5f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.handbook-content table,
|
.handbook-content table,
|
||||||
.handbook-content th,
|
.handbook-content th,
|
||||||
.handbook-content td,
|
.handbook-content td,
|
||||||
.handbook-content tr {
|
.handbook-content tr {
|
||||||
border: 1px solid #bdbdbd;
|
border: 1px solid #bdbdbd;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
}
|
}
|
||||||
|
|
||||||
.handbook-content th,
|
.handbook-content th,
|
||||||
.handbook-content td {
|
.handbook-content td {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.handbook-content table {
|
.handbook-content table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
РАЗДЕЛЫ (Finance, Medicine, TradeUnion и т.д.)
|
||||||
|
============================================ */
|
||||||
|
.section-container {
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding-bottom: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
color: #2d3436;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 300;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 25px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border: 1px solid #f1f2f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-card .card-icon {
|
||||||
|
font-size: 32px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-card .card-title {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
color: #2d3436;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-card .card-description {
|
||||||
|
color: #636e72;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
ИНФО-БЛОК
|
||||||
|
============================================ */
|
||||||
|
.info-block {
|
||||||
|
margin-top: 30px;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #f1f2f6;
|
||||||
|
text-align: center;
|
||||||
|
color: #636e72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-block p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
АДАПТИВНОСТЬ
|
||||||
|
============================================ */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.login-window {
|
||||||
|
margin: 20% 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-photo {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-menu h1 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(120px, 150px));
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-navigation {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-bar {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
height: 34px;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-bar .rates {
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.login-window {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(100px, 120px));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
padding: 15px 10px;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item .icon {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item .title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// src/types/electron.d.ts
|
||||||
|
|
||||||
|
interface ElectronAPI {
|
||||||
|
getAppConfig: () => Promise<any>;
|
||||||
|
onCardDetected: (callback: (uid: string) => void) => void;
|
||||||
|
removeCardDetectedListeners: () => void;
|
||||||
|
printPdf: (options: { pdfUrl: string; deviceName?: string }) => Promise<void>;
|
||||||
|
getPrinters: () => Promise<Array<{ name: string; displayName: string; isDefault: boolean; status: string }>>;
|
||||||
|
printPayslipForm: (data: { formHtml: string; printerName?: string }) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
electronAPI?: ElectronAPI;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// ============================================
|
||||||
|
// 📄 ВСПОМОГАТЕЛЬ ДЛЯ ПЕЧАТИ
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
import { ipcRenderer } from 'electron';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Печать формы расчетного листка через основной процесс
|
||||||
|
*/
|
||||||
|
export async function printPayslipForm(html: string, printerName?: string) {
|
||||||
|
try {
|
||||||
|
// ✅ ПЫТАЕМСЯ НАПРЯМУЮ ПЕЧАТЬ HTML
|
||||||
|
try {
|
||||||
|
const result = await ipcRenderer.invoke('print-html-direct', {
|
||||||
|
html,
|
||||||
|
printerName: printerName || 'GP-80250 Series',
|
||||||
|
customCss: `
|
||||||
|
.header { font-size: 10pt !important; }
|
||||||
|
.item-row { font-size: 7pt !important; }
|
||||||
|
.total-row { font-size: 9pt !important; }
|
||||||
|
.info-row { font-size: 7pt !important; }
|
||||||
|
.footer { font-size: 6pt !important; }
|
||||||
|
`
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
console.log('[PRINT] Успешно напечатано через прямой HTML');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[PRINT] Прямая печать не удалась, пробуем через PDF:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ ЕСЛИ НЕ ПОЛУЧИЛОСЬ - ПЕЧАТАЕМ ЧЕРЕЗ PDF
|
||||||
|
const result = await ipcRenderer.invoke('print-payslip-form', {
|
||||||
|
formHtml: html,
|
||||||
|
printerName: printerName || 'GP-80250 Series'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[PRINT] Успешно напечатано через PDF');
|
||||||
|
return result;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[PRINT] Ошибка печати:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение списка принтеров
|
||||||
|
*/
|
||||||
|
export async function getPrinters() {
|
||||||
|
try {
|
||||||
|
return await ipcRenderer.invoke('get-printers');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[PRINT] Ошибка получения принтеров:', error);
|
||||||
|
return [{ name: 'GP-80250 Series', displayName: 'GP-80250 Series' }];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +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/weatherwidget.tsx","./src/components/useactivitytimer.ts","./vite.config.js"],"version":"5.9.3"}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
{
|
||||||
|
"name": "electron",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"dependencies": {
|
||||||
|
"pdfmake": "^0.3.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@noble/ciphers": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.21.3 || >=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@noble/hashes": {
|
||||||
|
"version": "1.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||||
|
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.21.3 || >=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/helpers": {
|
||||||
|
"version": "0.5.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
||||||
|
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/base64-js": {
|
||||||
|
"version": "0.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
|
||||||
|
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/brotli": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.1.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/brotli/node_modules/base64-js": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/browserify-zlib": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "~1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/clone": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dfa": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/fast-deep-equal": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/fontkit": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/helpers": "^0.5.12",
|
||||||
|
"brotli": "^1.3.2",
|
||||||
|
"clone": "^2.1.2",
|
||||||
|
"dfa": "^1.2.0",
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"restructure": "^3.0.0",
|
||||||
|
"tiny-inflate": "^1.0.3",
|
||||||
|
"unicode-properties": "^1.4.0",
|
||||||
|
"unicode-trie": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/js-md5": {
|
||||||
|
"version": "0.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
|
||||||
|
"integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/linebreak": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "0.0.8",
|
||||||
|
"unicode-trie": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
|
"node_modules/pdfkit": {
|
||||||
|
"version": "0.19.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz",
|
||||||
|
"integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/ciphers": "^1.0.0",
|
||||||
|
"@noble/hashes": "^1.6.0",
|
||||||
|
"fontkit": "^2.0.4",
|
||||||
|
"js-md5": "^0.8.3",
|
||||||
|
"linebreak": "^1.1.0",
|
||||||
|
"png-js": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pdfmake": {
|
||||||
|
"version": "0.3.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdfmake/-/pdfmake-0.3.11.tgz",
|
||||||
|
"integrity": "sha512-Uc49J9hUMyuqJk+U+PxlpBpPr96A4HOOfesGx609EPr2ue82+5/Smq/KTAkEqh0/jUGSi1fumvqZ5yAWijJTJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"linebreak": "^1.1.0",
|
||||||
|
"pdfkit": "^0.19.1",
|
||||||
|
"xmldoc": "^2.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/png-js": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==",
|
||||||
|
"dependencies": {
|
||||||
|
"browserify-zlib": "^0.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/restructure": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/sax": {
|
||||||
|
"version": "1.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
|
||||||
|
"integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tiny-inflate": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
|
"node_modules/unicode-properties": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.0",
|
||||||
|
"unicode-trie": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unicode-properties/node_modules/base64-js": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/unicode-trie": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^0.2.5",
|
||||||
|
"tiny-inflate": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unicode-trie/node_modules/pako": {
|
||||||
|
"version": "0.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||||
|
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/xmldoc": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmldoc/-/xmldoc-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-6gRk4NY/Jvg67xn7OzJuxLRsGgiXBaPUQplVJ/9l99uIugxh4FTOewYz5ic8WScj7Xx/2WvhENiQKwkK9RpE4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"sax": "^1.4.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"pdfmake": "^0.3.11"
|
||||||
|
}
|
||||||
|
}
|
||||||