diff --git a/.gitignore b/.gitignore index 1586303..7490641 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ /admin-server/node_modules /backend/logs /admin-server1/node_modules -/admin-frontend/node_modules \ No newline at end of file +/admin-frontend/node_modules +/node_modules \ No newline at end of file diff --git a/admin-frontend/.gitignore b/admin-frontend/.gitignore index 4d29575..46124d0 100644 --- a/admin-frontend/.gitignore +++ b/admin-frontend/.gitignore @@ -21,3 +21,4 @@ npm-debug.log* yarn-debug.log* yarn-error.log* +/node_modules \ No newline at end of file diff --git a/admin-frontend/src/components/Editor.jsx b/admin-frontend/src/components/Editor.jsx index 9abb3ff..fcb961f 100644 --- a/admin-frontend/src/components/Editor.jsx +++ b/admin-frontend/src/components/Editor.jsx @@ -3,6 +3,7 @@ import 'quill/dist/quill.snow.css'; import '../App.css'; import DOMPurify from 'dompurify'; import SlidesManager from './SlidesManager'; +import FoodManager from './FoodManager'; function Editor() { const [isLoggedIn, setIsLoggedIn] = useState(false); @@ -94,7 +95,7 @@ function Editor() { const handleTabChange = (tab) => { setActiveTab(tab); - if (isLoggedIn && tab !== 'slides') { + if (isLoggedIn && tab !== 'slides' && tab !== 'food') { loadBlocks(tab); } }; @@ -262,11 +263,16 @@ function Editor() { + {/* Рендеринг контента вкладок */} {activeTab === 'slides' ? ( + ) : activeTab === 'food' ? ( + ) : ( <>
diff --git a/admin-frontend/src/components/FoodManager.jsx b/admin-frontend/src/components/FoodManager.jsx new file mode 100644 index 0000000..779409b --- /dev/null +++ b/admin-frontend/src/components/FoodManager.jsx @@ -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 ( +
+ {/* Уведомления */} + {notify.show && ( +
+ {notify.message} +
+ )} + + {/* Суб-вкладки */} +
+ + +
+ + {/* ================= ЗАКАЗЫ ================= */} + {activeSubTab === 'orders' && ( +
+
+
+ + +
+ +
+ + {loadingOrders ? ( +

Загрузка заказов...

+ ) : orders.length === 0 ? ( +

Заказов нет.

+ ) : ( + + + + + + + + + + + + + + + {orders.map(o => ( + + + + + + + + + + + ))} + +
№ДатаСотрудникТабельныйСоставСуммаСтатусДействия
{o.id}{new Date(o.createdAt).toLocaleString('ru-RU')}{o.employee}{o.tnom} +
    + {o.items.map((it, i) => ( +
  • {it.name} x{it.quantity}
  • + ))} +
+
{o.totalAmount.toFixed(2)} + + {o.status} + + + {o.status === 'cancelled' ? ( + + Отменён + + ) : ( + + )} +
+ )} +
+ )} + + {/* ================= МЕНЮ ================= */} + {activeSubTab === 'menu' && ( +
+
+

Блюда в меню

+ +
+ + {loadingMenu ?

Загрузка меню...

: menuData.categories.map(cat => ( +
+

{cat.emoji} {cat.name}

+
+ {cat.items.map(item => ( +
+
+ {item.imageUrl ? {item.name} : '🍽️'} +
+
{item.name}
+
{item.description}
+
+ {item.price} BYN +
+ + +
+
+
+ ))} +
+
+ ))} +
+ )} + + {/* МОДАЛКА РЕДАКТИРОВАНИЯ БЛЮДА */} + {showItemModal && ( +
+
+

{editingItem ? 'Редактировать блюдо' : 'Добавить блюдо'}

+
+
+ + +
+
+ + setItemForm({...itemForm, name: e.target.value})} required style={{ width: '100%', padding: '8px' }} /> +
+
+ + setItemForm({...itemForm, description: e.target.value})} style={{ width: '100%', padding: '8px' }} /> +
+
+
+ + setItemForm({...itemForm, price: e.target.value})} required style={{ width: '100%', padding: '8px' }} /> +
+
+ + setItemForm({...itemForm, weight_grams: e.target.value})} style={{ width: '100%', padding: '8px' }} /> +
+
+
+ + setItemForm({...itemForm, imageFile: e.target.files[0]})} accept="image/*" style={{ width: '100%', padding: '8px' }} /> +
+
+ + +
+
+
+
+ )} +
+ ); +} + +export default FoodManager; \ No newline at end of file diff --git a/admin-server1/logs/admin.log b/admin-server1/logs/admin.log index 35107ba..a2231fd 100644 --- a/admin-server1/logs/admin.log +++ b/admin-server1/logs/admin.log @@ -2336,3 +2336,188 @@ [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 diff --git a/admin-server1/moders/routes/foodOrders.js b/admin-server1/moders/routes/foodOrders.js new file mode 100644 index 0000000..216c663 --- /dev/null +++ b/admin-server1/moders/routes/foodOrders.js @@ -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; \ No newline at end of file diff --git a/admin-server1/package-lock.json b/admin-server1/package-lock.json index ff30023..8f70009 100644 --- a/admin-server1/package-lock.json +++ b/admin-server1/package-lock.json @@ -18,6 +18,7 @@ "express-mysql-session": "^3.0.3", "express-rate-limit": "^8.0.1", "express-session": "^1.18.1", + "form-data": "^4.0.6", "helmet": "^8.3.0", "morgan": "^1.10.0", "multer": "^2.0.1", diff --git a/admin-server1/package.json b/admin-server1/package.json index d8e06b1..f7b51c1 100644 --- a/admin-server1/package.json +++ b/admin-server1/package.json @@ -22,6 +22,7 @@ "express-mysql-session": "^3.0.3", "express-rate-limit": "^8.0.1", "express-session": "^1.18.1", + "form-data": "^4.0.6", "helmet": "^8.3.0", "morgan": "^1.10.0", "multer": "^2.0.1", diff --git a/admin-server1/server.js b/admin-server1/server.js index ef0f56b..1a47496 100644 --- a/admin-server1/server.js +++ b/admin-server1/server.js @@ -19,6 +19,7 @@ const healthRouter = require('./routes/health'); const monitoringRouter = require('./routes/monitoring'); const setupWebSocket = require('./services/websocket'); const cardController = require('./controllers/cardController'); +const foodOrdersRouter = require('./moders/routes/foodOrders'); const app = express(); const server = require('http').createServer(app); @@ -27,17 +28,29 @@ const corsConfig = require('./config/cors'); const sessionConfig = require('./config/session'); // Middleware -app.use(helmet()); // Добавление helmet для защиты +app.use(helmet({ + contentSecurityPolicy: false, // Разрешаем скрипты на странице питания +})); app.use(cors(corsConfig)); app.use(morgan('dev')); app.use(express.json({ limit: '100mb' })); app.use(session(sessionConfig(session))); + // Роуты app.use('/api/auth', authRouter); // Статические файлы app.use(express.static(path.join(__dirname, '..','admin-frontend', 'build'))); 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/logs', logsRouter); @@ -47,10 +60,12 @@ app.use('/api/health', healthRouter); app.use('/api/monitoring', monitoringRouter); app.use('/moders', moderAuthRouter); app.use('/moders/content', moderContentRouter); + app.use((req, res, next) => { console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`); next(); }); + // Initialize card logs (async () => { try { @@ -74,9 +89,10 @@ app.get(/.*/, (req, res, next) => { // Обработчик ошибок (должен быть последним!) app.use(require('./middleware/errorHandler')); + app.get('/api/status', async (req, res) => { try { - res.json({ status: 'online' }); // или другая логика проверки + res.json({ status: 'online' }); } catch (err) { res.status(500).json({ status: 'error', message: err.message }); } @@ -96,19 +112,17 @@ app.get('/api/config', async (req, res) => { res.status(500).json({ error: 'Ошибка загрузки конфигурации' }); } }); -// Добавьте рядом с существующим GET обработчиком + app.post('/api/config', async (req, res) => { try { const backendConfigPath = path.join(__dirname, '../backend/config.json'); const content = await fs.readFile(backendConfigPath, 'utf8'); const currentConfig = JSON.parse(content); - // Обновляем только разрешенные поля if (req.body.payslipsPath) { currentConfig.payslipsPath = req.body.payslipsPath; } - // Сохраняем обновленный конфиг await fs.writeFile(backendConfigPath, JSON.stringify(currentConfig, null, 4)); res.json({ @@ -123,6 +137,7 @@ app.post('/api/config', async (req, res) => { }); } }); + server.listen(process.env.PORT || 3005, () => { logger.info(`Server running on ${process.env.ADMIN_URL}`); }); \ No newline at end of file diff --git a/admin-server1/uploads/tmp/04e2c6857a1d0a005c6ec36aa14e4a15 b/admin-server1/uploads/tmp/04e2c6857a1d0a005c6ec36aa14e4a15 new file mode 100644 index 0000000..57d3140 Binary files /dev/null and b/admin-server1/uploads/tmp/04e2c6857a1d0a005c6ec36aa14e4a15 differ diff --git a/admin-server1/uploads/tmp/4e1f31280cd55dd47a656bd79a4ceaa3 b/admin-server1/uploads/tmp/4e1f31280cd55dd47a656bd79a4ceaa3 new file mode 100644 index 0000000..57d3140 Binary files /dev/null and b/admin-server1/uploads/tmp/4e1f31280cd55dd47a656bd79a4ceaa3 differ diff --git a/admin-server1/uploads/tmp/5fcaa99f00db8c3fb7c0eb557dbf1a57 b/admin-server1/uploads/tmp/5fcaa99f00db8c3fb7c0eb557dbf1a57 new file mode 100644 index 0000000..510928c Binary files /dev/null and b/admin-server1/uploads/tmp/5fcaa99f00db8c3fb7c0eb557dbf1a57 differ diff --git a/admin-server1/uploads/tmp/d40a53cd217859f9fc5ba91076bfc281 b/admin-server1/uploads/tmp/d40a53cd217859f9fc5ba91076bfc281 new file mode 100644 index 0000000..57d3140 Binary files /dev/null and b/admin-server1/uploads/tmp/d40a53cd217859f9fc5ba91076bfc281 differ diff --git a/admin-server1/views/food-orders.html b/admin-server1/views/food-orders.html new file mode 100644 index 0000000..8532e1f --- /dev/null +++ b/admin-server1/views/food-orders.html @@ -0,0 +1,460 @@ + + + + + + Заказы питания — панель модератора + + + +
+
+ ← В панель модератора +

🍔 Заказы питания

+
+
+ + + + +
+
+ +
+
+
+ Автообновление каждые 10 сек… +
+
+
Загрузка...
+
+
+ +
+ + + + \ No newline at end of file diff --git a/backend/backend.rar b/backend/backend.rar new file mode 100644 index 0000000..2c3fc55 Binary files /dev/null and b/backend/backend.rar differ diff --git a/backend/config.env b/backend/config.env index 0eed498..3f45d26 100644 --- a/backend/config.env +++ b/backend/config.env @@ -9,4 +9,11 @@ LOG_FILE_CARDS=cards.log LOG_DIR=../../logs/ LOG_FILE=app.log -DOCX_PATH=../../../frontend/public/handbook.docx \ No newline at end of file +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 \ No newline at end of file diff --git a/backend/config.json b/backend/config.json index be7bd5c..85b8c1d 100644 --- a/backend/config.json +++ b/backend/config.json @@ -12,6 +12,34 @@ "testMode": true, "mockData": { "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, "cardNumber": "1001", @@ -19,7 +47,7 @@ "famaly": "Петров", "ima": "Петр", "otch": "Петрович", - "profession": "Главный инженер", + "profession": "электромонтер", "category": "Высшая", "departmentName": "Технический отдел", "departmentCode": "101", @@ -47,7 +75,7 @@ "famaly": "Иванов", "ima": "Иван", "otch": "Иванович", - "profession": "Разработчик", + "profession": "Инженер-программист", "category": "2", "departmentName": "IT-отдел", "departmentCode": "303", @@ -56,6 +84,8 @@ } ], "payslips": [ + "001.pdf", + "001.pdf", "001.pdf", "002.pdf", "003.pdf" diff --git a/backend/migrations/003_food.sql b/backend/migrations/003_food.sql new file mode 100644 index 0000000..db7c9fa --- /dev/null +++ b/backend/migrations/003_food.sql @@ -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; \ No newline at end of file diff --git a/backend/migrations/004_food_seed.sql b/backend/migrations/004_food_seed.sql new file mode 100644 index 0000000..4b5abd9 --- /dev/null +++ b/backend/migrations/004_food_seed.sql @@ -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); \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index d1ce38a..4a5298b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -15,6 +15,7 @@ "express-rate-limit": "^8.6.2", "helmet": "^8.3.0", "mammoth": "^1.9.1", + "multer": "^2.5.0", "mysql2": "^3.14.2", "node-cron": "^4.2.1", "odata-client": "^0.8.0", @@ -152,6 +153,12 @@ "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": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -288,6 +295,17 @@ "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": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1446,6 +1464,67 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "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": { "version": "3.23.2", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.23.2.tgz", @@ -2054,6 +2133,14 @@ "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": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", diff --git a/backend/package.json b/backend/package.json index 6f0a299..9c90bd5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,6 +18,7 @@ "express-rate-limit": "^8.6.2", "helmet": "^8.3.0", "mammoth": "^1.9.1", + "multer": "^2.5.0", "mysql2": "^3.14.2", "node-cron": "^4.2.1", "odata-client": "^0.8.0", diff --git a/backend/rates.xlsx b/backend/rates.xlsx index b804e1b..5130846 100644 Binary files a/backend/rates.xlsx and b/backend/rates.xlsx differ diff --git a/backend/src/config/db_food.js b/backend/src/config/db_food.js new file mode 100644 index 0000000..6a10bba --- /dev/null +++ b/backend/src/config/db_food.js @@ -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 }; \ No newline at end of file diff --git a/backend/src/routes/food.js b/backend/src/routes/food.js new file mode 100644 index 0000000..3893a80 --- /dev/null +++ b/backend/src/routes/food.js @@ -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; \ No newline at end of file diff --git a/backend/src/routes/payslipForm.js b/backend/src/routes/payslipForm.js index 73ff135..34f1fbb 100644 --- a/backend/src/routes/payslipForm.js +++ b/backend/src/routes/payslipForm.js @@ -1,6 +1,6 @@ const express = require('express'); const path = require('path'); -const fs = require('fs').promises; +const fs = require('fs'); const { logger } = require('../config/logger'); const { parsePayslipExcel } = require('../services/excelParser'); const { generatePayslipForm } = require('../services/formGenerator'); @@ -9,7 +9,7 @@ const router = express.Router(); /** * GET /api/payslip/form/:tnom - * Получить HTML-форму расчетного листка + * Получить HTML-форму расчетного листка (для предпросмотра) */ router.get('/form/:tnom', async (req, res) => { try { @@ -18,9 +18,8 @@ router.get('/form/:tnom', async (req, res) => { logger.info(`[PayslipForm] Запрос формы для ${tnom}, период: ${period || 'не указан'}`); - // 1. Находим Excel-файл const configPath = path.join(__dirname, '../../config.json'); - const config = JSON.parse(await fs.readFile(configPath, 'utf8')); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); let payslipBasePath = config.payslipsPath || './payslips'; if (!path.isAbsolute(payslipBasePath)) { @@ -36,10 +35,7 @@ router.get('/form/:tnom', async (req, res) => { logger.info(`[PayslipForm] Путь к Excel: ${excelPath}`); - // 2. Проверяем существование файла - try { - await fs.access(excelPath); - } catch (err) { + if (!fs.existsSync(excelPath)) { logger.warn(`[PayslipForm] Excel не найден: ${excelPath}`); return res.status(404).json({ message: 'Excel-файл расчетного листка не найден', @@ -47,7 +43,6 @@ router.get('/form/:tnom', async (req, res) => { }); } - // 3. Парсим Excel const data = parsePayslipExcel(excelPath); logger.info(`[PayslipForm] Данные извлечены:`, { fullName: data.fullName, @@ -56,10 +51,7 @@ router.get('/form/:tnom', async (req, res) => { totalPaid: data.totalPaid }); - // 4. Генерируем HTML-форму const html = generatePayslipForm(data); - - // 5. Отправляем HTML res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.send(html); @@ -72,6 +64,154 @@ router.get('/form/:tnom', async (req, res) => { } }); +/** + * 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 (для отладки) @@ -81,9 +221,8 @@ router.get('/form-data/:tnom', async (req, res) => { const { tnom } = req.params; const { period } = req.query; - // Находим Excel-файл const configPath = path.join(__dirname, '../../config.json'); - const config = JSON.parse(await fs.readFile(configPath, 'utf8')); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); let payslipBasePath = config.payslipsPath || './payslips'; if (!path.isAbsolute(payslipBasePath)) { @@ -97,14 +236,10 @@ router.get('/form-data/:tnom', async (req, res) => { excelPath = path.join(payslipBasePath, `${tnom}.xlsx`); } - // Проверяем существование - try { - await fs.access(excelPath); - } catch (err) { + if (!fs.existsSync(excelPath)) { return res.status(404).json({ message: 'Excel не найден' }); } - // Парсим и возвращаем JSON const data = parsePayslipExcel(excelPath); res.json(data); diff --git a/backend/src/server.js b/backend/src/server.js index b4a3662..bd3d57d 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -7,6 +7,7 @@ const { connectDB } = require('./config/db'); const { logger } = require('./config/logger'); const handbookRoutes = require('./routes/handbook'); const employeeRoutes = require('./routes/employee'); +const foodRoutes = require('./routes/food'); // ← НОВАЯ const helmet = require('helmet'); const rateLimit = require('express-rate-limit'); const XLSX = require('xlsx'); @@ -72,11 +73,16 @@ async function connectToNetworkShare(config) { const app = express(); app.use(cors()); +// ✅ Раздаем статические файлы (картинки питания, слайды и т.д.) +app.use('/uploads', express.static(path.join(__dirname, '../uploads'))); 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' }); } @@ -96,8 +102,8 @@ app.use(helmet({ // Другие необходимые директивы scriptSrc: ["'self'", "'unsafe-inline'"], styleSrc: ["'self'", "'unsafe-inline'"], - imgSrc: ["'self'", "data:", "blob:"], - connectSrc: ["'self'"], + imgSrc: ["'self'", "data:", "blob:"], + connectSrc: ["'self'"], fontSrc: ["'self'"], objectSrc: ["'self'", "blob:"], mediaSrc: ["'self'"], @@ -117,6 +123,7 @@ app.use('/api/', limiter); app.use('/api/handbook', handbookRoutes); app.use('/api/employee', employeeRoutes); +app.use('/api/food', foodRoutes); // ← НОВАЯ // ============================================ // 🧪 ТЕСТОВЫЙ ЭНДПОИНТ @@ -401,7 +408,7 @@ async function getPayslipFilePath(tnom, period = null) { app.get('/api/payslip/check/:tnom', async function(req, res) { try { var tnom = req.params.tnom; - var period = req.query.period || null; // ✅ Берем period из query-параметров + var period = req.query.period || null; console.log('=========================================='); console.log(`📁 [PAYSLIP CHECK] Табельный номер: ${tnom}, Период: ${period || 'не указан'}`); @@ -430,7 +437,7 @@ app.get('/api/payslip/check/:tnom', async function(req, res) { app.get('/api/payslip/url/:tnom', async function(req, res) { try { var tnom = req.params.tnom; - var period = req.query.period || null; // ✅ Берем period из query-параметров + var period = req.query.period || null; console.log('=========================================='); console.log(`📁 [PAYSLIP URL] Табельный номер: ${tnom}, Период: ${period || 'не указан'}`); @@ -443,10 +450,9 @@ app.get('/api/payslip/url/:tnom', async function(req, res) { console.log('=========================================='); if (exists) { - // ✅ Формируем URL для получения файла let fileUrl = `/api/payslip/tnom/${tnom}`; if (period) { - fileUrl += `?period=${period}`; // ✅ Добавляем period в query + fileUrl += `?period=${period}`; } logger.info(`[PAYSLIP URL] Файл найден для ${tnom} (период: ${period || 'корень'})`); res.json({ @@ -476,7 +482,7 @@ app.get('/api/payslip/url/:tnom', async function(req, res) { app.get('/api/payslip/tnom/:tnom', async function(req, res) { try { var tnom = req.params.tnom; - var period = req.query.period || null; // ✅ Берем period из query-параметров + var period = req.query.period || null; console.log('=========================================='); console.log(`📁 [PAYSLIP] Запрос к /api/payslip/tnom/${tnom}, Период: ${period || 'не указан'}`); @@ -484,7 +490,6 @@ app.get('/api/payslip/tnom/:tnom', async function(req, res) { const filePath = await getPayslipFilePath(tnom, period); console.log(`📁 Полный путь к файлу: ${filePath}`); - // ✅ ЯВНО проверяем существование файла try { await fsPromises.access(filePath); console.log('✅ Файл найден, начинаем отправку...'); @@ -499,11 +504,9 @@ app.get('/api/payslip/tnom/:tnom', async function(req, res) { }); } - // ✅ Добавляем правильные заголовки для PDF res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', 'inline; filename="' + tnom + '.pdf"'); - // ✅ Отправляем файл с обработкой ошибок res.sendFile(filePath, function(err) { if (err) { console.error('❌ Ошибка при отправке файла:', err); @@ -524,6 +527,13 @@ app.get('/api/payslip/tnom/:tnom', async function(req, res) { } }); +// ============================================ +// 📄 РАСЧЕТНЫЙ ЛИСТОК (ФОРМА ДЛЯ ПЕЧАТИ) - НОВЫЙ МАРШРУТ +// ============================================ + +const payslipFormRoutes = require('./routes/payslipForm'); +app.use('/api/payslip', payslipFormRoutes); + // ============================================ // 🗄️ СТАРЫЕ ЭНДПОИНТЫ РАСЧЕТНЫХ ЛИСТКОВ (для обратной совместимости) // ============================================ @@ -769,13 +779,6 @@ app.get('/api/time', function(req, res) { }); }); -// ============================================ -// 📄 РАСЧЕТНЫЙ ЛИСТОК (ФОРМА ДЛЯ ПЕЧАТИ) -// ============================================ - -const payslipFormRoutes = require('./routes/payslipForm'); -app.use('/api/payslip', payslipFormRoutes); - // ============================================ // 🚀 ЗАПУСК СЕРВЕРА // ============================================ diff --git a/backend/src/services/formGenerator.js b/backend/src/services/formGenerator.js index 14274be..b5a7fe4 100644 --- a/backend/src/services/formGenerator.js +++ b/backend/src/services/formGenerator.js @@ -5,6 +5,10 @@ const { logger } = require('../config/logger'); * @param {Object} data - данные из Excel * @returns {string} HTML-код формы */ +// ============================================ +// 📄 ГЕНЕРАЦИЯ HTML-ФОРМЫ ДЛЯ ТЕРМОПРИНТЕРА +// ============================================ + function generatePayslipForm(data) { try { logger.info('[FormGenerator] Генерация HTML-формы'); @@ -18,111 +22,187 @@ function generatePayslipForm(data) { const monthNum = monthNames[data.month] || '00'; const period = `${monthNum}${data.year || '2026'}`; - // Формируем HTML для термопринтера (80мм ширина) + // ✅ Формируем HTML с улучшенной печатью let html = ` + Расчетный листок @@ -145,11 +225,11 @@ function generatePayslipForm(data) {
Подразделение: - ${data.department || ''} + ${data.department || ''}
Должность: - ${data.position || ''} + ${data.position || ''}
Отработано: @@ -189,7 +269,7 @@ function generatePayslipForm(data) {
-
+
💰 К ВЫПЛАТЕ: ${data.totalPaid.toFixed(2)}
@@ -216,9 +296,9 @@ function generatePayslipForm(data) { ${data.accountNumber ? `
-
+
Лицевой счет: - ${data.accountNumber} + ${data.accountNumber}
` : ''} diff --git a/backend/uploads/food/1793224182340 b/backend/uploads/food/1793224182340 new file mode 100644 index 0000000..57d3140 Binary files /dev/null and b/backend/uploads/food/1793224182340 differ diff --git a/backend/uploads/food/1793224213479 b/backend/uploads/food/1793224213479 new file mode 100644 index 0000000..510928c Binary files /dev/null and b/backend/uploads/food/1793224213479 differ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b84a8d0..98e8352 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,14 +18,15 @@ "dompurify": "^3.4.13", "dotenv": "^17.4.2", "electron-is-dev": "^2.0.0", - "framer-motion": "^13.1.0", + "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-dom": "^19.2.0", - "react-icons": "^4.11.0", + "react-icons": "^4.12.0", "react-pdf": "^8.0.0", "react-zoom-pan-pinch": "^3.3.0", "sharp": "^0.35.4", @@ -3060,6 +3061,22 @@ "dequal": "^2.0.3" } }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/asn1js": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", @@ -3118,6 +3135,21 @@ "node": ">= 4.0.0" } }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/aws4": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", @@ -3230,6 +3262,15 @@ "base64-js": "^1.1.2" } }, + "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/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -3646,6 +3687,24 @@ "node": ">=8" } }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -3659,6 +3718,22 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/canvas": { "version": "2.11.2", "resolved": "https://registry.npmjs.org/canvas/-/canvas-2.11.2.tgz", @@ -4040,6 +4115,13 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "deprecated": "Active development of CryptoJS has been discontinued. This library is no longer maintained.", + "license": "MIT" + }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -4124,6 +4206,44 @@ "node": ">=6" } }, + "node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/deep-equal/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, "node_modules/defaults": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", @@ -4151,9 +4271,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", @@ -4170,9 +4288,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", @@ -5016,6 +5132,32 @@ "node": ">= 0.4" } }, + "node_modules/es-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-get-iterator/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -5285,6 +5427,21 @@ "node": ">=0.8" } }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/form-data": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", @@ -5311,12 +5468,12 @@ } }, "node_modules/framer-motion": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.1.0.tgz", - "integrity": "sha512-QSZrF0Id3QGuHJ+OL+9PSY9pk86C8ERFalwAGSchzTm65+ZoGH/RM26lmEARLljcHj2lqhv0jZOOks+EI3COOw==", + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.1.1.tgz", + "integrity": "sha512-B/xn2TPS4f61cEBLFjiYlQFnBZUW1YVj/LM+C+N4OP8Rs95VLEI2ot/RlfBg111la/EiyECFaJJi/A3FWA8MUA==", "license": "MIT", "dependencies": { - "motion-dom": "^13.0.0", + "motion-dom": "^13.1.1", "motion-utils": "^13.0.0", "tslib": "^2.4.0" }, @@ -5405,6 +5562,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gauge": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", @@ -5638,6 +5804,18 @@ "dev": true, "license": "ISC" }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -5652,9 +5830,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "es-define-property": "^1.0.0" }, @@ -5796,7 +5972,6 @@ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "license": "MIT", - "optional": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -5870,6 +6045,20 @@ "devOptional": true, "license": "ISC" }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ip-address": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", @@ -5880,6 +6069,98 @@ "node": ">= 12" } }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -5907,6 +6188,18 @@ "dev": true, "license": "MIT" }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -5917,6 +6210,100 @@ "node": ">=0.12.0" } }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -5930,6 +6317,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -7123,9 +7538,9 @@ } }, "node_modules/motion-dom": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-13.0.0.tgz", - "integrity": "sha512-Xk+SJas70uMAUIApg+m3lZDShxI3LBFHq7mFGbBKoRXc2PVPDyAKmzN64Bbzt4CZdP/CItTiJxWtn4TA0v53Ng==", + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-13.1.1.tgz", + "integrity": "sha512-XSf8VYWSB6G/0IY3rWVbyLcxWXtAVHkN1PQE2agTaCv3u8RGvbwu56TyyR/MNzBqqNavEBTZzErcxI1TxBrjcA==", "license": "MIT", "dependencies": { "motion-utils": "^13.0.0" @@ -7395,17 +7810,63 @@ "node": ">=0.10.0" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">= 0.4" } }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -7614,6 +8075,83 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/pdfmake": { + "version": "0.1.72", + "resolved": "https://registry.npmjs.org/pdfmake/-/pdfmake-0.1.72.tgz", + "integrity": "sha512-xZrPS+Safjf1I8ZYtMoXX83E6C6Pd1zFwa168yNTeeJWHclqf1z9DoYajjlY2uviN7gGyxwVZeou39uSk1oh1g==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.2", + "linebreak": "^1.0.2", + "pdfkit": "^0.12.0", + "svg-to-pdfkit": "^0.1.8", + "xmldoc": "^1.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pdfmake/node_modules/@swc/helpers": { + "version": "0.3.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.3.17.tgz", + "integrity": "sha512-tb7Iu+oZ+zWJZ3HJqwx8oNwSDIU440hmVMDPhpACWQWnrZHK99Bxs70gT1L2dnr5Hg50ZRWEFkQCAnOVVV0z1Q==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/pdfmake/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/pdfmake/node_modules/fontkit": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-1.9.0.tgz", + "integrity": "sha512-HkW/8Lrk8jl18kzQHvAw9aTHe1cqsyx5sDnxncx652+CIfhawokEPkeM3BoIC+z/Xv7a0yMr0f3pRRwhGH455g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.3.13", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "deep-equal": "^2.0.5", + "dfa": "^1.2.0", + "restructure": "^2.0.1", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.3.1", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/pdfmake/node_modules/pdfkit": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.12.3.tgz", + "integrity": "sha512-+qDLgm2yq6WOKcxTb43lDeo3EtMIDQs0CK1RNqhHC9iT6u0KOmgwAClkYh9xFw2ATbmUZzt4f7KMwDCOfPDluA==", + "license": "MIT", + "dependencies": { + "crypto-js": "^4.0.0", + "fontkit": "^1.8.1", + "linebreak": "^1.0.2", + "png-js": "^1.0.0" + } + }, + "node_modules/pdfmake/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/pdfmake/node_modules/restructure": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-2.0.1.tgz", + "integrity": "sha512-e0dOpjm5DseomnXx2M5lpdZ5zoHqF1+bqdMJUohoYVVQa7cBdnk7fdmeI6byNWP/kiME72EeTiSypTCVnpLiDg==", + "license": "MIT" + }, "node_modules/pe-library": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", @@ -7709,6 +8247,15 @@ "fflate": "^0.8.2" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", @@ -8030,6 +8577,26 @@ "node": ">=8" } }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -8262,12 +8829,28 @@ ], "license": "MIT" }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/sanitize-filename": { "version": "1.6.4", @@ -8283,7 +8866,6 @@ "version": "1.6.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" @@ -8337,6 +8919,38 @@ "devOptional": true, "license": "ISC" }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/sharp": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", @@ -8434,6 +9048,78 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -8727,6 +9413,19 @@ "dev": true, "license": "MIT" }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -8806,6 +9505,15 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/svg-to-pdfkit": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/svg-to-pdfkit/-/svg-to-pdfkit-0.1.8.tgz", + "integrity": "sha512-QItiGZBy5TstGy+q8mjQTMGRlDDOARXLxH+sgVm1n/LYeo0zFcQlcCh8m4zi8QxctrxB9Kue/lStc/RD5iLadQ==", + "license": "MIT", + "dependencies": { + "pdfkit": ">=0.8.1" + } + }, "node_modules/tar": { "version": "7.5.22", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", @@ -10549,6 +11257,64 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -10650,6 +11416,15 @@ "node": ">=8.0" } }, + "node_modules/xmldoc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/xmldoc/-/xmldoc-1.3.0.tgz", + "integrity": "sha512-y7IRWW6PvEnYQZNZFMRLNJw+p3pezM4nKYPfr15g4OOW9i8VpeydycFuipE2297OvZnh3jSb2pxOt9QpkZUVng==", + "license": "MIT", + "dependencies": { + "sax": "^1.2.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/frontend/package.json b/frontend/package.json index e5e961e..005e56a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,14 +14,15 @@ "dompurify": "^3.4.13", "dotenv": "^17.4.2", "electron-is-dev": "^2.0.0", - "framer-motion": "^13.1.0", + "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-dom": "^19.2.0", - "react-icons": "^4.11.0", + "react-icons": "^4.12.0", "react-pdf": "^8.0.0", "react-zoom-pan-pinch": "^3.3.0", "sharp": "^0.35.4", diff --git a/frontend/public/electron.js b/frontend/public/electron.js index d55deee..1b755a5 100644 --- a/frontend/public/electron.js +++ b/frontend/public/electron.js @@ -7,6 +7,7 @@ const axios = require('axios'); const { logger } = require('../../backend/src/config/logger'); const config = require('../src/config.json') const { print } = require('pdf-to-printer'); +const { generatePayslipPDF } = require('./pdfGenerator'); protocol.registerSchemesAsPrivileged([ { @@ -51,6 +52,532 @@ try { 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 = ` +
+ ТЕСТ ПЕЧАТИ +
+
Дата: ${new Date().toLocaleString()}
+
Табельный номер: TEST
+
+ `; + } + + const fullHtml = ` + + + + Расчетный листок + + + + ${htmlContent} +
+ Сгенерировано: ${new Date().toLocaleString()} +
+ +`; + + 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 = '
ОШИБКА: Контент не загрузился!
'; + } + `); + } + + 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() { const preloadPath = path.join(__dirname, 'preload.js'); try { @@ -90,94 +617,6 @@ function createWindow() { }); } -// ============================================ -// 📄 ПЕЧАТЬ PDF ЧЕРЕЗ pdf-to-printer -// ============================================ - -ipcMain.handle('print-pdf', async (event, data = {}) => { - let tempFile = null; - - try { - if (!data.pdfUrl) { - throw new Error('URL файла PDF не передан'); - } - - const cleanUrl = data.pdfUrl.split('#')[0]; - logger.info('[PRINT] Загрузка PDF:', cleanUrl); - - // ✅ Скачиваем PDF - const response = await axios.get(cleanUrl, { - responseType: 'arraybuffer', - timeout: 30000 - }); - - const pdfBuffer = Buffer.from(response.data); - logger.info('[PRINT] Размер PDF:', pdfBuffer.length, 'байт'); - - if (pdfBuffer.length < 100) { - throw new Error('PDF слишком маленький или пустой'); - } - - // ✅ Сохраняем во временный файл - const tempDir = app.getPath('temp'); - tempFile = path.join(tempDir, `print_${Date.now()}.pdf`); - fs.writeFileSync(tempFile, pdfBuffer); - logger.info('[PRINT] Временный файл создан:', tempFile); - - // ✅ ПЕЧАТАЕМ ЧЕРЕЗ pdf-to-printer - const printerName = 'GP-80250 Series'; - logger.info('[PRINT] Отправляем на печать через pdf-to-printer:', printerName); - - // ✅ Опции печати - const options = { - printer: printerName, - // scale: 'fit', // Масштабирование - // paperSize: 'A4', - // landscape: false, - // copies: 1, - // silent: true // Тихая печать - }; - - const result = await print(tempFile, options); - logger.info('[PRINT] Результат печати:', result); - - // ✅ Удаляем временный файл - try { - if (tempFile && fs.existsSync(tempFile)) { - fs.unlinkSync(tempFile); - logger.info('[PRINT] Временный файл удален'); - } - } catch (e) {} - - logger.info('[PRINT] ✅ Документ отправлен на печать'); - return true; - - } catch (error) { - logger.error('[PRINT] ❌ Ошибка:', error.message); - try { - if (tempFile && fs.existsSync(tempFile)) { - fs.unlinkSync(tempFile); - } - } catch (e) {} - throw error; - } -}); - -// ============================================ -// 📄 ПОЛУЧЕНИЕ СПИСКА ПРИНТЕРОВ -// ============================================ - -ipcMain.handle('get-printers', async () => { - return [ - { - name: 'GP-80250 Series', - displayName: 'GP-80250 Series', - isDefault: true, - status: 'ready' - } - ]; -}); - // ============================================ // 🚀 ЗАПУСК // ============================================ @@ -226,8 +665,4 @@ app.on('activate', () => { app.on('will-quit', () => { globalShortcut.unregisterAll(); -}); - -ipcMain.handle('get-app-config', async (event) => { - return appConfig; }); \ No newline at end of file diff --git a/frontend/public/payslips/00001_20260902.pdf b/frontend/public/payslips/00001_20260902.pdf new file mode 100644 index 0000000..36c7765 Binary files /dev/null and b/frontend/public/payslips/00001_20260902.pdf differ diff --git a/frontend/public/payslips/001.pdf b/frontend/public/payslips/001.pdf new file mode 100644 index 0000000..9157731 Binary files /dev/null and b/frontend/public/payslips/001.pdf differ diff --git a/frontend/public/payslips/001_20260902.pdf b/frontend/public/payslips/001_20260902.pdf new file mode 100644 index 0000000..474ea37 Binary files /dev/null and b/frontend/public/payslips/001_20260902.pdf differ diff --git a/frontend/public/pdfGenerator.js b/frontend/public/pdfGenerator.js new file mode 100644 index 0000000..1f754dd --- /dev/null +++ b/frontend/public/pdfGenerator.js @@ -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 }; \ No newline at end of file diff --git a/frontend/public/preload.js b/frontend/public/preload.js index 223cfbb..85ffef6 100644 --- a/frontend/public/preload.js +++ b/frontend/public/preload.js @@ -19,6 +19,8 @@ contextBridge.exposeInMainWorld( }, printPdf: (options) => ipcRenderer.invoke('print-pdf', options), getPrinters: () => ipcRenderer.invoke('get-printers'), + // ✅ НОВАЯ ФУНКЦИЯ: печать формы расчетного листка + printPayslipForm: (data) => ipcRenderer.invoke('print-payslip-form', data), } ); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2815b8c..40e1777 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,6 +20,7 @@ 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 { id: string; @@ -303,6 +304,8 @@ function App() { return ; case 'profile': return ; + case 'food': + return setCurrentSection('main')} employee={employee} />; default: return ; } diff --git a/frontend/src/components/BottomBar.tsx b/frontend/src/components/BottomBar.tsx index 046d23a..322f128 100644 --- a/frontend/src/components/BottomBar.tsx +++ b/frontend/src/components/BottomBar.tsx @@ -37,7 +37,7 @@ const BottomBar: React.FC = () => { const BACKEND_URL = 'http://localhost:3001'; - // ============================================ + // ============================================ // 📊 ЗАГРУЗКА КУРСОВ // ============================================ useEffect(() => { @@ -45,57 +45,29 @@ const BottomBar: React.FC = () => { try { setLoading(true); - // ✅ Сначала пробуем MySQL - try { - const mysqlResponse = await fetch(`${BACKEND_URL}/api/rates/mysql`); - if (mysqlResponse.ok) { - const data = await mysqlResponse.json(); - if (data.currency && data.currency.length > 0) { - setCurrencyRates(data.currency); - setMetalRates(data.metals || []); - setDataSource('mysql'); - setLastUpdate(new Date().toLocaleString('ru-RU')); - // Сохраняем в localStorage - localStorage.setItem('ratesData', JSON.stringify({ - currency: data.currency, - metals: data.metals, - source: 'mysql', - lastUpdate: new Date().toISOString() - })); - setLoading(false); - return; - } + // ✅ Запрашиваем единый эндпоинт, который уже есть на бэкенде + 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; } - } catch (mysqlError) { - console.warn('MySQL недоступен, пробуем Excel:', mysqlError); } - - // ✅ Если MySQL не работает — пробуем Excel - try { - const excelResponse = await fetch(`${BACKEND_URL}/api/rates/excel`); - if (excelResponse.ok) { - const data = await excelResponse.json(); - if (data.currency && data.currency.length > 0) { - setCurrencyRates(data.currency); - setMetalRates(data.metals || []); - setDataSource('excel'); - setLastUpdate(new Date().toLocaleString('ru-RU')); - // Сохраняем в localStorage - localStorage.setItem('ratesData', JSON.stringify({ - currency: data.currency, - metals: data.metals, - source: 'excel', - lastUpdate: new Date().toISOString() - })); - setLoading(false); - return; - } - } - } catch (excelError) { - console.warn('Excel недоступен:', excelError); - } - - // ✅ Если ничего не работает — берём из localStorage + + // Если не удалось — берём из localStorage const stored = localStorage.getItem('ratesData'); if (stored) { try { @@ -114,7 +86,6 @@ const BottomBar: React.FC = () => { } setLoading(false); - } catch (error) { console.warn('Ошибка загрузки курсов:', error); setLoading(false); diff --git a/frontend/src/components/EmployeeInfo.tsx b/frontend/src/components/EmployeeInfo.tsx index f2bfb03..5ad0d6d 100644 --- a/frontend/src/components/EmployeeInfo.tsx +++ b/frontend/src/components/EmployeeInfo.tsx @@ -66,11 +66,7 @@ const EmployeeInfo: React.FC = ({ const periods = generatePeriods(); const monthNames = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', - 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь']; - - const getFullUrl = (url: string) => { - return url.startsWith('http') ? url : `http://localhost:3001${url}`; - }; + 'Июль', 'Август', 'Сентябрь', 'Окторябрь', 'Ноябрь', 'Декабрь']; const checkPayslip = async () => { if (!employee?.tnom) return; @@ -100,6 +96,7 @@ const EmployeeInfo: React.FC = ({ checkPayslip(); }, [selectedPeriod, employee]); + // ✅ ФУНКЦИЯ: формируем PDF в памяти и открываем предпросмотр const handlePreviewPdf = async () => { if (!employee?.tnom) return; @@ -109,6 +106,50 @@ const EmployeeInfo: React.FC = ({ 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}`); @@ -127,8 +168,8 @@ const EmployeeInfo: React.FC = ({ setPdfLoading(false); } } catch (error) { - console.error('Ошибка загрузки PDF:', error); - setPdfError('Не удалось загрузить расчетный листок'); + console.error('❌ [PREVIEW] Ошибка формирования PDF:', error); + setPdfError(error instanceof Error ? error.message : 'Не удалось сформировать расчетный листок'); setPdfLoading(false); } }; @@ -138,63 +179,47 @@ const EmployeeInfo: React.FC = ({ setPdfLoading(false); setPdfError(null); setIsFullscreen(false); + + // Очищаем URL, чтобы не было утечек памяти + if (payslipUrl && payslipUrl.startsWith('blob:')) { + URL.revokeObjectURL(payslipUrl); + } + setPayslipUrl(null); }; - const handlePrint = async () => { + // ✅ ФУНКЦИЯ ПЕЧАТИ (отправляет base64 напрямую в print-pdf) + const handlePrint = async () => { try { setIsPrinting(true); setPrintStatus('sending'); setPrintMessage('Отправка документа на печать...'); - const urlToPrint = payslipUrl ; // payslipUrl для Employee/Finance, fileUrl для Viewer - if (!urlToPrint) throw new Error('URL документа отсутствует'); + const electronAPI = (window as any).electronAPI; - 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 сам плюнет на дефолтный принтер + // ✅ Получаем 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' }); - 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); - } - }; + if (result && result.success) { + setPrintStatus('success'); + setPrintMessage('✅ Отправлено на принтер'); + } else { + throw new Error(result?.error || 'Ошибка печати'); + } + } else { + throw new Error('Electron API не доступен'); } } catch (error) { @@ -400,23 +425,24 @@ const EmployeeInfo: React.FC = ({
) : null} + {/* ✅ ТОЛЬКО ОДНА КНОПКА - ПРЕДПРОСМОТР */}
)} @@ -658,20 +673,26 @@ const EmployeeInfo: React.FC = ({ {!pdfLoading && payslipUrl && !pdfError && (