food
This commit is contained in:
+2
-1
@@ -5,4 +5,5 @@
|
||||
/admin-server/node_modules
|
||||
/backend/logs
|
||||
/admin-server1/node_modules
|
||||
/admin-frontend/node_modules
|
||||
/admin-frontend/node_modules
|
||||
/node_modules
|
||||
@@ -21,3 +21,4 @@
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
/node_modules
|
||||
@@ -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() {
|
||||
<button className={`tab-btn ${activeTab === 'slides' ? 'active' : ''}`} onClick={() => setActiveTab('slides')} data-tab="slides">
|
||||
📸 Слайды
|
||||
</button>
|
||||
<button className={`tab-btn ${activeTab === 'food' ? 'active' : ''}`} onClick={() => setActiveTab('food')} data-tab="food">
|
||||
🍔 Питание
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Рендеринг контента вкладок */}
|
||||
{activeTab === 'slides' ? (
|
||||
<SlidesManager />
|
||||
) : activeTab === 'food' ? (
|
||||
<FoodManager />
|
||||
) : (
|
||||
<>
|
||||
<div style={{ margin: '18px 0' }}>
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
function FoodManager() {
|
||||
const [activeSubTab, setActiveSubTab] = useState('orders');
|
||||
|
||||
// --- ЗАКАЗЫ ---
|
||||
const [orders, setOrders] = useState([]);
|
||||
const [loadingOrders, setLoadingOrders] = useState(false);
|
||||
const [orderStatus, setOrderStatus] = useState('all'); // all, new, accepted, done, cancelled
|
||||
|
||||
// --- МЕНЮ ---
|
||||
const [menuData, setMenuData] = useState({ company: '', categories: [] });
|
||||
const [loadingMenu, setLoadingMenu] = useState(false);
|
||||
const [showItemModal, setShowItemModal] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
|
||||
const [itemForm, setItemForm] = useState({
|
||||
category_id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
price: '',
|
||||
weight_grams: '',
|
||||
image_url: ''
|
||||
});
|
||||
|
||||
const [notify, setNotify] = useState({ message: '', type: '', show: false });
|
||||
const showNotify = useCallback((message, type = 'success') => {
|
||||
setNotify({ message, type, show: true });
|
||||
setTimeout(() => setNotify({ message: '', type: '', show: false }), 3000);
|
||||
}, []);
|
||||
|
||||
// ============================================
|
||||
// ЗАКАЗЫ
|
||||
// ============================================
|
||||
const fetchOrders = useCallback(async () => {
|
||||
setLoadingOrders(true);
|
||||
try {
|
||||
const url = orderStatus === 'all'
|
||||
? '/moders/food/orders'
|
||||
: `/moders/food/orders?status=${orderStatus}`;
|
||||
const res = await fetch(url, { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setOrders(Array.isArray(data) ? data : []);
|
||||
}
|
||||
} catch (e) {
|
||||
showNotify('Ошибка загрузки заказов', 'error');
|
||||
} finally {
|
||||
setLoadingOrders(false);
|
||||
}
|
||||
}, [orderStatus, showNotify]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSubTab === 'orders') fetchOrders();
|
||||
}, [activeSubTab, fetchOrders]);
|
||||
|
||||
const updateOrderStatus = async (id, status) => {
|
||||
// Если это отмена — спрашиваем подтверждение
|
||||
if (status === 'cancelled') {
|
||||
if (!window.confirm('Вы уверены? Заказ будет отменён, деньги вернутся на баланс сотрудника. Это действие нельзя отменить.')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/moders/food/orders/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ status })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
if (data.refunded) {
|
||||
showNotify(`Заказ отменён. Возвращено: ${data.refund.toFixed(2)} BYN`, 'success');
|
||||
} else {
|
||||
showNotify('Статус обновлён', 'success');
|
||||
}
|
||||
fetchOrders();
|
||||
} else {
|
||||
showNotify(data.error || 'Ошибка обновления', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showNotify('Ошибка обновления', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Экспорт в Excel
|
||||
const exportToExcel = () => {
|
||||
if (orders.length === 0) {
|
||||
showNotify('Нет заказов для экспорта', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Формируем плоскую таблицу
|
||||
const rows = [];
|
||||
orders.forEach(order => {
|
||||
if (!order.items || order.items.length === 0) {
|
||||
rows.push({
|
||||
'№ Заказа': order.id,
|
||||
'Дата': new Date(order.createdAt).toLocaleString('ru-RU'),
|
||||
'Сотрудник': order.employee || 'Неизвестно',
|
||||
'Табельный': order.tnom,
|
||||
'Статус': order.status,
|
||||
'Блюдо': '(пусто)',
|
||||
'Кол-во': '',
|
||||
'Цена за шт.': '',
|
||||
'Сумма позиции': '',
|
||||
'Итого по заказу': order.totalAmount
|
||||
});
|
||||
} else {
|
||||
order.items.forEach((item, idx) => {
|
||||
rows.push({
|
||||
'№ Заказа': idx === 0 ? order.id : '', // Пишем ID только в первой строке
|
||||
'Дата': idx === 0 ? new Date(order.createdAt).toLocaleString('ru-RU') : '',
|
||||
'Сотрудник': idx === 0 ? (order.employee || 'Неизвестно') : '',
|
||||
'Табельный': idx === 0 ? order.tnom : '',
|
||||
'Статус': idx === 0 ? order.status : '',
|
||||
'Блюдо': item.name,
|
||||
'Кол-во': item.quantity,
|
||||
'Цена за шт.': item.unitPrice,
|
||||
'Сумма позиции': item.subtotal,
|
||||
'Итого по заказу': idx === 0 ? order.totalAmount : ''
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const ws = XLSX.utils.json_to_sheet(rows);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Заказы');
|
||||
XLSX.writeFile(wb, `Заказы_питания_${new Date().toLocaleDateString('ru-RU')}.xlsx`);
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// МЕНЮ (БЛЮДА)
|
||||
// ============================================
|
||||
const fetchMenu = useCallback(async () => {
|
||||
setLoadingMenu(true);
|
||||
try {
|
||||
const res = await fetch('/moders/food/items', { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setMenuData({
|
||||
company: data.company,
|
||||
categories: data.categories || []
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
showNotify('Ошибка загрузки меню', 'error');
|
||||
} finally {
|
||||
setLoadingMenu(false);
|
||||
}
|
||||
}, [showNotify]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSubTab === 'menu') fetchMenu();
|
||||
}, [activeSubTab, fetchMenu]);
|
||||
|
||||
const openAddModal = () => {
|
||||
setEditingItem(null);
|
||||
setItemForm({
|
||||
category_id: menuData.categories[0]?.id || '',
|
||||
name: '', description: '', price: '', weight_grams: '', image_url: ''
|
||||
});
|
||||
setShowItemModal(true);
|
||||
};
|
||||
|
||||
const openEditModal = (item, catId) => {
|
||||
setEditingItem({ ...item, category_id: catId });
|
||||
setItemForm({
|
||||
category_id: catId,
|
||||
name: item.name,
|
||||
description: item.description || '',
|
||||
price: item.price,
|
||||
weight_grams: item.weightGrams || '',
|
||||
image_url: item.imageUrl || ''
|
||||
});
|
||||
setShowItemModal(true);
|
||||
};
|
||||
|
||||
const handleSaveItem = async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData();
|
||||
formData.append('category_id', itemForm.category_id);
|
||||
formData.append('name', itemForm.name);
|
||||
formData.append('description', itemForm.description);
|
||||
formData.append('price', itemForm.price);
|
||||
formData.append('weight_grams', itemForm.weight_grams);
|
||||
|
||||
if (itemForm.imageFile) {
|
||||
formData.append('image', itemForm.imageFile);
|
||||
} else if (itemForm.image_url) {
|
||||
formData.append('current_image_url', itemForm.image_url);
|
||||
}
|
||||
|
||||
const url = editingItem
|
||||
? `/moders/food/items/${editingItem.id}`
|
||||
: '/moders/food/items';
|
||||
const method = editingItem ? 'PUT' : 'POST';
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
body: formData,
|
||||
credentials: 'include'
|
||||
});
|
||||
if (res.ok) {
|
||||
showNotify(editingItem ? 'Блюдо обновлено' : 'Блюдо добавлено', 'success');
|
||||
setShowItemModal(false);
|
||||
fetchMenu();
|
||||
} else {
|
||||
showNotify('Ошибка сохранения', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showNotify('Ошибка сервера', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteItem = async (id) => {
|
||||
if (!window.confirm('Удалить это блюдо?')) return;
|
||||
try {
|
||||
const res = await fetch(`/moders/food/items/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
if (res.ok) {
|
||||
showNotify('Блюдо удалено', 'success');
|
||||
fetchMenu();
|
||||
} else {
|
||||
showNotify('Ошибка удаления', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showNotify('Ошибка удаления', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// РЕНДЕР
|
||||
// ============================================
|
||||
return (
|
||||
<div style={{ paddingBottom: '40px' }}>
|
||||
{/* Уведомления */}
|
||||
{notify.show && (
|
||||
<div style={{
|
||||
padding: '12px 16px', marginBottom: '15px', borderRadius: '8px',
|
||||
background: notify.type === 'success' ? '#d4edda' : '#f8d7da',
|
||||
color: notify.type === 'success' ? '#155724' : '#721c24',
|
||||
border: `1px solid ${notify.type === 'success' ? '#c3e6cb' : '#f5c6cb'}`
|
||||
}}>
|
||||
{notify.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Суб-вкладки */}
|
||||
<div style={{ display: 'flex', gap: '10px', marginBottom: '20px', borderBottom: '2px solid #eee', paddingBottom: '10px' }}>
|
||||
<button
|
||||
onClick={() => setActiveSubTab('orders')}
|
||||
style={{
|
||||
padding: '8px 20px', border: 'none', borderRadius: '8px', cursor: 'pointer',
|
||||
background: activeSubTab === 'orders' ? '#0056b3' : '#f8f9fa',
|
||||
color: activeSubTab === 'orders' ? 'white' : '#333',
|
||||
fontWeight: activeSubTab === 'orders' ? 'bold' : 'normal'
|
||||
}}
|
||||
>
|
||||
📦 Заказы
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveSubTab('menu')}
|
||||
style={{
|
||||
padding: '8px 20px', border: 'none', borderRadius: '8px', cursor: 'pointer',
|
||||
background: activeSubTab === 'menu' ? '#0056b3' : '#f8f9fa',
|
||||
color: activeSubTab === 'menu' ? 'white' : '#333',
|
||||
fontWeight: activeSubTab === 'menu' ? 'bold' : 'normal'
|
||||
}}
|
||||
>
|
||||
🍔 Управление меню
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ================= ЗАКАЗЫ ================= */}
|
||||
{activeSubTab === 'orders' && (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '15px' }}>
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<select
|
||||
value={orderStatus}
|
||||
onChange={(e) => setOrderStatus(e.target.value)}
|
||||
style={{ padding: '8px', borderRadius: '6px', border: '1px solid #ccc' }}
|
||||
>
|
||||
<option value="all">Все статусы</option>
|
||||
<option value="new">Новые</option>
|
||||
<option value="accepted">Принятые</option>
|
||||
<option value="done">Выполненные</option>
|
||||
<option value="cancelled">Отменённые</option>
|
||||
</select>
|
||||
<button onClick={fetchOrders} className="btn-small">Обновить</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={exportToExcel}
|
||||
style={{ padding: '8px 16px', background: '#28a745', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 'bold' }}
|
||||
>
|
||||
📥 Экспорт в Excel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadingOrders ? (
|
||||
<p>Загрузка заказов...</p>
|
||||
) : orders.length === 0 ? (
|
||||
<p>Заказов нет.</p>
|
||||
) : (
|
||||
<table className="moders-table" style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>№</th>
|
||||
<th>Дата</th>
|
||||
<th>Сотрудник</th>
|
||||
<th>Табельный</th>
|
||||
<th>Состав</th>
|
||||
<th>Сумма</th>
|
||||
<th>Статус</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orders.map(o => (
|
||||
<tr key={o.id}>
|
||||
<td>{o.id}</td>
|
||||
<td>{new Date(o.createdAt).toLocaleString('ru-RU')}</td>
|
||||
<td>{o.employee}</td>
|
||||
<td>{o.tnom}</td>
|
||||
<td>
|
||||
<ul style={{ margin: 0, paddingLeft: '15px', fontSize: '12px' }}>
|
||||
{o.items.map((it, i) => (
|
||||
<li key={i}>{it.name} x{it.quantity}</li>
|
||||
))}
|
||||
</ul>
|
||||
</td>
|
||||
<td>{o.totalAmount.toFixed(2)}</td>
|
||||
<td>
|
||||
<span style={{
|
||||
padding: '4px 8px', borderRadius: '4px', fontSize: '12px', fontWeight: 'bold',
|
||||
background: o.status === 'new' ? '#ffc107' : o.status === 'done' ? '#28a745' : '#17a2b8',
|
||||
color: 'white'
|
||||
}}>
|
||||
{o.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{o.status === 'cancelled' ? (
|
||||
<span style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 'bold',
|
||||
background: '#fdecea',
|
||||
color: '#c0392b',
|
||||
border: '1px solid #f5c6cb'
|
||||
}}>
|
||||
Отменён
|
||||
</span>
|
||||
) : (
|
||||
<select
|
||||
value={o.status}
|
||||
onChange={(e) => updateOrderStatus(o.id, e.target.value)}
|
||||
style={{ padding: '6px', borderRadius: '4px', border: '1px solid #ccc', fontSize: '13px' }}
|
||||
>
|
||||
<option value="new">Новый</option>
|
||||
<option value="accepted">Принят</option>
|
||||
<option value="done">Выполнен</option>
|
||||
<option value="cancelled">Отменить (вернуть деньги)</option>
|
||||
</select>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ================= МЕНЮ ================= */}
|
||||
{activeSubTab === 'menu' && (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '15px' }}>
|
||||
<h3 style={{ margin: 0 }}>Блюда в меню</h3>
|
||||
<button
|
||||
onClick={openAddModal}
|
||||
style={{ padding: '8px 16px', background: '#007bff', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 'bold' }}
|
||||
>
|
||||
+ Добавить блюдо
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadingMenu ? <p>Загрузка меню...</p> : menuData.categories.map(cat => (
|
||||
<div key={cat.id} style={{ marginBottom: '30px' }}>
|
||||
<h4 style={{ borderBottom: '1px solid #ccc', paddingBottom: '5px' }}>{cat.emoji} {cat.name}</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: '15px' }}>
|
||||
{cat.items.map(item => (
|
||||
<div key={item.id} style={{ background: '#f9f9f9', borderRadius: '8px', padding: '10px', border: '1px solid #ddd', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ height: '80px', background: '#eee', borderRadius: '4px', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '32px' }}>
|
||||
{item.imageUrl ? <img src={`http://localhost:3001${item.imageUrl}`} alt={item.name} style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '4px' }} /> : '🍽️'}
|
||||
</div>
|
||||
<div style={{ fontWeight: 'bold', marginTop: '8px' }}>{item.name}</div>
|
||||
<div style={{ fontSize: '12px', color: '#666', flex: 1 }}>{item.description}</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '10px' }}>
|
||||
<strong style={{ color: '#007bff' }}>{item.price} BYN</strong>
|
||||
<div>
|
||||
<button onClick={() => openEditModal(item, cat.id)} className="btn-small" style={{ marginRight: '5px' }}>✏️</button>
|
||||
<button onClick={() => handleDeleteItem(item.id)} className="btn-small" style={{ background: '#dc3545', color: 'white' }}>🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* МОДАЛКА РЕДАКТИРОВАНИЯ БЛЮДА */}
|
||||
{showItemModal && (
|
||||
<div className="modal" style={{ display: 'block', position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', zIndex: 2000, padding: '20px', overflowY: 'auto' }}>
|
||||
<div className="modal-content" style={{ maxWidth: '500px', margin: '40px auto', background: '#fff', borderRadius: '12px', padding: '20px' }}>
|
||||
<h3>{editingItem ? 'Редактировать блюдо' : 'Добавить блюдо'}</h3>
|
||||
<form onSubmit={handleSaveItem}>
|
||||
<div className="form-group">
|
||||
<label>Категория:</label>
|
||||
<select
|
||||
value={itemForm.category_id}
|
||||
onChange={(e) => setItemForm({...itemForm, category_id: e.target.value})}
|
||||
required
|
||||
style={{ width: '100%', padding: '8px', borderRadius: '6px', border: '1px solid #ccc' }}
|
||||
>
|
||||
{menuData.categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Название:</label>
|
||||
<input type="text" value={itemForm.name} onChange={(e) => setItemForm({...itemForm, name: e.target.value})} required style={{ width: '100%', padding: '8px' }} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Описание:</label>
|
||||
<input type="text" value={itemForm.description} onChange={(e) => setItemForm({...itemForm, description: e.target.value})} style={{ width: '100%', padding: '8px' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label>Цена (BYN):</label>
|
||||
<input type="number" step="0.01" value={itemForm.price} onChange={(e) => setItemForm({...itemForm, price: e.target.value})} required style={{ width: '100%', padding: '8px' }} />
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label>Вес (гр):</label>
|
||||
<input type="number" value={itemForm.weight_grams} onChange={(e) => setItemForm({...itemForm, weight_grams: e.target.value})} style={{ width: '100%', padding: '8px' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Картинка (оставьте пустым, чтобы не менять):</label>
|
||||
<input type="file" onChange={(e) => setItemForm({...itemForm, imageFile: e.target.files[0]})} accept="image/*" style={{ width: '100%', padding: '8px' }} />
|
||||
</div>
|
||||
<div style={{ marginTop: '20px', display: 'flex', gap: '10px' }}>
|
||||
<button type="submit" style={{ padding: '10px 20px', background: '#28a745', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer' }}>Сохранить</button>
|
||||
<button type="button" onClick={() => setShowItemModal(false)} style={{ padding: '10px 20px', background: '#6c757d', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer' }}>Отмена</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FoodManager;
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
Generated
+1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+20
-5
@@ -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}`);
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
@@ -0,0 +1,460 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Заказы питания — панель модератора</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
background: #f4f7fb;
|
||||
color: #2d3436;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
color: #2b6cb0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.nav-link {
|
||||
text-decoration: none;
|
||||
color: #2b6cb0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-right: 15px;
|
||||
}
|
||||
.nav-link:hover { text-decoration: underline; }
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filters button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: #eef2f7;
|
||||
color: #4a5568;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.filters button:hover { background: #dde5ef; }
|
||||
.filters button.active {
|
||||
background: #2b6cb0;
|
||||
color: #fff;
|
||||
}
|
||||
.filters button .badge {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
background: rgba(255,255,255,0.3);
|
||||
padding: 0 7px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
}
|
||||
main {
|
||||
max-width: 1100px;
|
||||
margin: 24px auto;
|
||||
padding: 0 16px 60px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
color: #4a5568;
|
||||
font-size: 14px;
|
||||
}
|
||||
.toolbar .dot {
|
||||
width: 10px; height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #2ecc71;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
.orders {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.order {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 18px 20px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.06);
|
||||
border-left: 5px solid #bdc3c7;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.order:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.order.status-new { border-left-color: #e74c3c; }
|
||||
.order.status-accepted { border-left-color: #f39c12; }
|
||||
.order.status-done { border-left-color: #2ecc71; }
|
||||
.order.status-cancelled { border-left-color: #95a5a6; opacity: 0.7; }
|
||||
|
||||
.order-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.order-head h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.order-id {
|
||||
background: #eef2f7;
|
||||
color: #2b6cb0;
|
||||
padding: 2px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.order-time {
|
||||
color: #95a5a6;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.employee {
|
||||
font-size: 15px;
|
||||
color: #4a5568;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.employee b { color: #2d3436; }
|
||||
.items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 12px 0;
|
||||
border-top: 1px dashed #eef2f7;
|
||||
border-bottom: 1px dashed #eef2f7;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.item-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
color: #2d3436;
|
||||
}
|
||||
.item-row .qty { color: #95a5a6; margin-left: 6px; font-weight: 500; }
|
||||
.order-foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.total {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1abc9c;
|
||||
}
|
||||
.total small { font-size: 13px; color: #95a5a6; font-weight: 500; }
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.actions button {
|
||||
padding: 8px 18px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
transition: transform 0.1s, opacity 0.2s;
|
||||
}
|
||||
.actions button:active { transform: scale(0.96); }
|
||||
.actions button:hover { opacity: 0.85; }
|
||||
.btn-accept { background: #f39c12; }
|
||||
.btn-done { background: #2ecc71; }
|
||||
.btn-cancel { background: #e74c3c; }
|
||||
.actions button:disabled {
|
||||
background: #bdc3c7;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.empty, .loading {
|
||||
text-align: center;
|
||||
color: #95a5a6;
|
||||
padding: 60px 20px;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
font-size: 16px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.04);
|
||||
}
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 30px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #2d3436;
|
||||
color: #fff;
|
||||
padding: 12px 24px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.25);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
.toast.show { opacity: 1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div style="display: flex; align-items: center;">
|
||||
<a href="/moders" class="nav-link">← В панель модератора</a>
|
||||
<h1>🍔 Заказы питания</h1>
|
||||
</div>
|
||||
<div class="filters" id="filters">
|
||||
<button data-status="new" class="active">Новые <span class="badge" id="b-new">0</span></button>
|
||||
<button data-status="accepted">Принятые <span class="badge" id="b-accepted">0</span></button>
|
||||
<button data-status="done">Выполненные <span class="badge" id="b-done">0</span></button>
|
||||
<button data-status="cancelled">Отменённые <span class="badge" id="b-cancelled">0</span></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="toolbar">
|
||||
<div class="dot"></div>
|
||||
<span id="auto-status">Автообновление каждые 10 сек…</span>
|
||||
</div>
|
||||
<div id="container" class="orders">
|
||||
<div class="loading">Загрузка...</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
// ============================================================
|
||||
// Конфиг
|
||||
// ============================================================
|
||||
const API = '/moders/food/orders';
|
||||
const API_STATUS = '/moders/food/orders/';
|
||||
const REFRESH_MS = 10000;
|
||||
|
||||
let currentStatus = 'new';
|
||||
let allOrders = [];
|
||||
|
||||
const container = document.getElementById('container');
|
||||
const toastEl = document.getElementById('toast');
|
||||
|
||||
// ============================================================
|
||||
// Загрузка данных
|
||||
// ============================================================
|
||||
async function loadOrders() {
|
||||
try {
|
||||
const r = await fetch(`${API}?status=${currentStatus}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const data = await r.json();
|
||||
allOrders = data;
|
||||
render(data);
|
||||
} catch (e) {
|
||||
console.error('Ошибка загрузки:', e);
|
||||
if (container.innerHTML.includes('loading')) {
|
||||
container.innerHTML = `<div class="empty">Ошибка загрузки: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Бейджи (кол-во по каждому статусу)
|
||||
// ============================================================
|
||||
async function updateBadges() {
|
||||
try {
|
||||
const statuses = ['new', 'accepted', 'done', 'cancelled'];
|
||||
const results = await Promise.all(
|
||||
statuses.map((s) =>
|
||||
fetch(`${API}?status=${s}`, { credentials: 'include' })
|
||||
.then((r) => r.ok ? r.json() : [])
|
||||
.then((arr) => arr.length)
|
||||
.catch(() => 0)
|
||||
)
|
||||
);
|
||||
statuses.forEach((s, i) => {
|
||||
const el = document.getElementById('b-' + s);
|
||||
if (el) el.textContent = results[i];
|
||||
});
|
||||
} catch (err) {
|
||||
// Игнорируем ошибки бейджей
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Рендер карточек
|
||||
// ============================================================
|
||||
function render(orders) {
|
||||
if (!orders || orders.length === 0) {
|
||||
container.innerHTML = `<div class="empty">Заказов нет</div>`;
|
||||
return;
|
||||
}
|
||||
container.innerHTML = orders.map(renderOrder).join('');
|
||||
|
||||
// Вешаем обработчики на кнопки
|
||||
container.querySelectorAll('[data-action]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const id = btn.dataset.id;
|
||||
const status = btn.dataset.action;
|
||||
changeStatus(id, status, btn);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderOrder(o) {
|
||||
const itemsHtml = (o.items || [])
|
||||
.map(
|
||||
(it) => `
|
||||
<div class="item-row">
|
||||
<span>${escapeHtml(it.name)}<span class="qty">× ${it.quantity}</span></span>
|
||||
<span>${Number(it.subtotal).toFixed(2)} BYN</span>
|
||||
</div>`
|
||||
)
|
||||
.join('');
|
||||
|
||||
const actions = [];
|
||||
|
||||
if (o.status === 'new') {
|
||||
actions.push(`<button class="btn-accept" data-action="accepted" data-id="${o.id}">Принять</button>`);
|
||||
actions.push(`<button class="btn-cancel" data-action="cancelled" data-id="${o.id}">Отменить</button>`);
|
||||
} else if (o.status === 'accepted') {
|
||||
actions.push(`<button class="btn-done" data-action="done" data-id="${o.id}">Выполнен</button>`);
|
||||
actions.push(`<button class="btn-cancel" data-action="cancelled" data-id="${o.id}">Отменить</button>`);
|
||||
} else if (o.status === 'cancelled') {
|
||||
actions.push(`<button class="btn-accept" data-action="accepted" data-id="${o.id}">Восстановить</button>`);
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="order status-${o.status}">
|
||||
<div class="order-head">
|
||||
<h2><span class="order-id">Заказ #${o.id}</span></h2>
|
||||
<span class="order-time">${formatDate(o.createdAt)}</span>
|
||||
</div>
|
||||
<div class="employee">
|
||||
${escapeHtml(o.employee || 'Сотрудник')} · таб. № <b>${escapeHtml(o.tnom)}</b>
|
||||
</div>
|
||||
<div class="items">${itemsHtml}</div>
|
||||
<div class="order-foot">
|
||||
<div class="total">${Number(o.totalAmount).toFixed(2)} <small>BYN</small></div>
|
||||
<div class="actions">${actions.join('')}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Смена статуса
|
||||
// ============================================================
|
||||
async function changeStatus(id, status, btn) {
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '...';
|
||||
}
|
||||
try {
|
||||
const r = await fetch(`${API_STATUS}${id}/status`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
|
||||
showToast(`Заказ #${id} → ${statusLabel(status)}`);
|
||||
await loadOrders();
|
||||
await updateBadges();
|
||||
} catch (e) {
|
||||
showToast(`Ошибка: ${e.message}`);
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
// Кнопка вернет свой текст при перерисовке
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(s) {
|
||||
return { new: 'Новый', accepted: 'Принят', done: 'Выполнен', cancelled: 'Отменён' }[s] || s;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Утилиты
|
||||
// ============================================================
|
||||
function escapeHtml(str) {
|
||||
return String(str || '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
}[c]));
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString('ru-RU', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
let toastTimer = null;
|
||||
function showToast(text) {
|
||||
toastEl.textContent = text;
|
||||
toastEl.classList.add('show');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => toastEl.classList.remove('show'), 2500);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Фильтры
|
||||
// ============================================================
|
||||
document.getElementById('filters').addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('button[data-status]');
|
||||
if (!btn) return;
|
||||
document.querySelectorAll('#filters button').forEach((b) => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
currentStatus = btn.dataset.status;
|
||||
container.innerHTML = `<div class="loading">Загрузка...</div>`;
|
||||
loadOrders();
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Старт и Автообновление
|
||||
// ============================================================
|
||||
loadOrders();
|
||||
updateBadges();
|
||||
|
||||
setInterval(() => {
|
||||
loadOrders();
|
||||
updateBadges();
|
||||
}, REFRESH_MS);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
+8
-1
@@ -9,4 +9,11 @@ LOG_FILE_CARDS=cards.log
|
||||
|
||||
LOG_DIR=../../logs/
|
||||
LOG_FILE=app.log
|
||||
DOCX_PATH=../../../frontend/public/handbook.docx
|
||||
DOCX_PATH=../../../frontend/public/handbook.docx
|
||||
|
||||
# Питание (rasch_content — локальная БД с users/content/food)
|
||||
FOOD_DB_HOST=localhost
|
||||
FOOD_DB_PORT=3306
|
||||
FOOD_DB_USER=root
|
||||
FOOD_DB_PASSWORD=Vova-2003.
|
||||
FOOD_DB_NAME=rasch_content
|
||||
+32
-2
@@ -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"
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
-- ============================================================
|
||||
-- Модуль питания: категории, блюда, счета, заказы
|
||||
-- ============================================================
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
-- ---------- Компания-оператор питания ----------
|
||||
CREATE TABLE IF NOT EXISTS food_companies (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ---------- Категории (Горячее, Напитки, Закуски, Суп...) ----------
|
||||
CREATE TABLE IF NOT EXISTS food_categories (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
emoji VARCHAR(16) DEFAULT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ---------- Блюда ----------
|
||||
CREATE TABLE IF NOT EXISTS food_items (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
category_id INT NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description VARCHAR(500) DEFAULT NULL,
|
||||
price DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
image_url VARCHAR(500) DEFAULT NULL,
|
||||
weight_grams INT DEFAULT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
KEY idx_food_items_cat (category_id),
|
||||
CONSTRAINT fk_item_cat FOREIGN KEY (category_id)
|
||||
REFERENCES food_categories(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ---------- Счета питания сотрудников (по табельному) ----------
|
||||
CREATE TABLE IF NOT EXISTS food_accounts (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
tnom VARCHAR(20) NOT NULL UNIQUE,
|
||||
balance DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ---------- Заказы ----------
|
||||
CREATE TABLE IF NOT EXISTS food_orders (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
tnom VARCHAR(20) NOT NULL,
|
||||
total_amount DECIMAL(10,2) NOT NULL,
|
||||
status ENUM('new','accepted','done','cancelled')
|
||||
NOT NULL DEFAULT 'new',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_food_orders_status (status),
|
||||
KEY idx_food_orders_tnom (tnom),
|
||||
KEY idx_food_orders_date (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ---------- Позиции заказа ----------
|
||||
CREATE TABLE IF NOT EXISTS food_order_items (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
order_id INT NOT NULL,
|
||||
item_id INT NOT NULL,
|
||||
item_name VARCHAR(255) NOT NULL, -- фиксируем имя на момент заказа
|
||||
unit_price DECIMAL(10,2) NOT NULL, -- и цену
|
||||
quantity INT NOT NULL,
|
||||
subtotal DECIMAL(10,2) NOT NULL,
|
||||
KEY idx_order_items_order (order_id),
|
||||
KEY idx_order_items_item (item_id),
|
||||
CONSTRAINT fk_oi_order FOREIGN KEY (order_id)
|
||||
REFERENCES food_orders(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,27 @@
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
INSERT IGNORE INTO food_companies (id, name) VALUES (1, 'АзотСервис');
|
||||
|
||||
INSERT IGNORE INTO food_categories (id, name, emoji, sort_order) VALUES
|
||||
(1, 'Горячее', '🍲', 10),
|
||||
(2, 'Супы', '🥣', 20),
|
||||
(3, 'Закуски', '🥪', 30),
|
||||
(4, 'Напитки', '🥤', 40),
|
||||
(5, 'Десерты', '🍰', 50);
|
||||
|
||||
INSERT IGNORE INTO food_items (id, category_id, name, description, price, image_url, weight_grams, sort_order) VALUES
|
||||
(1, 1, 'Котлета', 'Куриная котлета с маслом', 6.50, '/uploads/food/kiev.jpg', 250, 10),
|
||||
(2, 1, 'Плов с говядиной', 'Рассольный плов', 7.20, '/uploads/food/plov.jpg', 300, 20),
|
||||
(3, 2, 'Борщ', 'С говядиной и сметаной', 4.50, '/uploads/food/borsch.jpg', 350, 10),
|
||||
(4, 2, 'Куриный суп', 'С домашней лапшой', 3.80, '/uploads/food/soup.jpg', 350, 20),
|
||||
(5, 3, 'Бутерброд с сыром', 'Тост, сыр, ветчина', 2.90, '/uploads/food/sandwich.jpg',150, 10),
|
||||
(6, 4, 'Чай чёрный', 'С сахаром', 1.20, '/uploads/food/tea.jpg', 250, 10),
|
||||
(7, 4, 'Кофе американо', 'Эспрессо с водой', 2.00, '/uploads/food/coffee.jpg', 200, 20),
|
||||
(8, 4, 'Сок апельсиновый', 'Свежевыжатый', 2.50, '/uploads/food/juice.jpg', 250, 30),
|
||||
(9, 5, 'Пирожок с вишней', 'Выпечка', 1.80, '/uploads/food/pie.jpg', 100, 10);
|
||||
|
||||
-- Тестовые счета (можно удалить на проде)
|
||||
INSERT IGNORE INTO food_accounts (tnom, balance) VALUES
|
||||
('001', 50.00),
|
||||
('002', 32.50),
|
||||
('003', 100.00);
|
||||
Generated
+87
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Binary file not shown.
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
|
||||
+20
-17
@@ -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);
|
||||
|
||||
// ============================================
|
||||
// 🚀 ЗАПУСК СЕРВЕРА
|
||||
// ============================================
|
||||
|
||||
@@ -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 = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Расчетный листок</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
/* ✅ ГЛОБАЛЬНЫЕ ПРАВИЛА ДЛЯ ПЕЧАТИ */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 80mm;
|
||||
margin: 0 auto;
|
||||
padding: 8px 4px;
|
||||
padding: 2mm 2mm;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 11px;
|
||||
font-size: 9pt;
|
||||
color: #000;
|
||||
background: #fff;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* ✅ ПРАВИЛА ДЛЯ ПЕЧАТИ - ПРИНУДИТЕЛЬНОЕ ОТОБРАЖЕНИЕ */
|
||||
@media print {
|
||||
body {
|
||||
width: 80mm;
|
||||
margin: 0;
|
||||
padding: 1mm 1.5mm;
|
||||
font-size: 8.5pt;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/* ✅ ЗАПРЕЩАЕМ РАЗРЫВЫ ВНУТРИ БЛОКОВ */
|
||||
.header, .sub-header, .section-title, .total-row, .footer {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
/* ✅ ПРИНУДИТЕЛЬНО РАЗМЕРЫ */
|
||||
.item-row {
|
||||
font-size: 7.5pt !important;
|
||||
padding: 0.5px 0 !important;
|
||||
}
|
||||
|
||||
.total-row {
|
||||
font-size: 9.5pt !important;
|
||||
padding: 2px 0 !important;
|
||||
}
|
||||
|
||||
.header {
|
||||
font-size: 11pt !important;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 9pt !important;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
font-size: 7.5pt !important;
|
||||
}
|
||||
|
||||
.footer {
|
||||
font-size: 6.5pt !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ✅ СТИЛИ ДЛЯ ЭКРАНА (ПРЕДПРОСМОТР) */
|
||||
.header {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
font-size: 11pt;
|
||||
font-weight: bold;
|
||||
padding-bottom: 4px;
|
||||
padding-bottom: 3px;
|
||||
border-bottom: 1px dashed #000;
|
||||
margin-bottom: 4px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.sub-header {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
padding-bottom: 4px;
|
||||
font-size: 9pt;
|
||||
padding-bottom: 3px;
|
||||
border-bottom: 1px dashed #000;
|
||||
margin-bottom: 4px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 2px 0;
|
||||
font-size: 11px;
|
||||
padding: 1px 0;
|
||||
font-size: 8.5pt;
|
||||
}
|
||||
|
||||
.row-label {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.row-value {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.divider {
|
||||
border-top: 1px dashed #000;
|
||||
margin: 4px 0;
|
||||
padding-top: 4px;
|
||||
margin: 3px 0;
|
||||
padding-top: 3px;
|
||||
}
|
||||
|
||||
.total-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 3px 0;
|
||||
padding: 2px 0;
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
font-size: 9.5pt;
|
||||
border-top: 1px solid #000;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
padding: 4px 0 2px 0;
|
||||
font-size: 9pt;
|
||||
padding: 3px 0 2px 0;
|
||||
border-bottom: 1px dotted #000;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.item-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 1px 0;
|
||||
font-size: 10px;
|
||||
padding-left: 4px;
|
||||
padding: 0.5px 0;
|
||||
font-size: 7.5pt;
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
.item-desc {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.item-amount {
|
||||
text-align: right;
|
||||
min-width: 50px;
|
||||
min-width: 40px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
font-size: 9px;
|
||||
font-size: 6.5pt;
|
||||
color: #666;
|
||||
border-top: 1px dashed #000;
|
||||
margin-top: 6px;
|
||||
padding-top: 4px;
|
||||
margin-top: 4px;
|
||||
padding-top: 3px;
|
||||
}
|
||||
|
||||
.bank-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 1px 0;
|
||||
font-size: 10px;
|
||||
padding: 0.5px 0;
|
||||
font-size: 7.5pt;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 1px 0;
|
||||
font-size: 10px;
|
||||
padding: 0.5px 0;
|
||||
font-size: 7.5pt;
|
||||
}
|
||||
@media print {
|
||||
body { width: 100%; }
|
||||
|
||||
/* ✅ ДЛЯ ПРЕДПРОСМОТРА В ПРИЛОЖЕНИИ */
|
||||
@media screen {
|
||||
body {
|
||||
max-width: 80mm;
|
||||
padding: 3mm;
|
||||
border: 1px solid #ddd;
|
||||
margin: 10px auto;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -145,11 +225,11 @@ function generatePayslipForm(data) {
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span>Подразделение:</span>
|
||||
<span style="font-size:9px;max-width:120px;overflow:hidden;">${data.department || ''}</span>
|
||||
<span style="font-size:7pt;max-width:100px;overflow:hidden;">${data.department || ''}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span>Должность:</span>
|
||||
<span style="font-size:9px;max-width:120px;overflow:hidden;">${data.position || ''}</span>
|
||||
<span style="font-size:7pt;max-width:100px;overflow:hidden;">${data.position || ''}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span>Отработано:</span>
|
||||
@@ -189,7 +269,7 @@ function generatePayslipForm(data) {
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Итоги -->
|
||||
<div class="total-row" style="border-top:2px solid #000;font-size:14px;">
|
||||
<div class="total-row" style="border-top:2px solid #000;font-size:12pt;padding:3px 0;">
|
||||
<span>💰 К ВЫПЛАТЕ:</span>
|
||||
<span>${data.totalPaid.toFixed(2)}</span>
|
||||
</div>
|
||||
@@ -216,9 +296,9 @@ function generatePayslipForm(data) {
|
||||
<!-- Лицевой счет -->
|
||||
${data.accountNumber ? `
|
||||
<div class="divider"></div>
|
||||
<div class="info-row" style="font-size:9px;color:#666;">
|
||||
<div class="info-row" style="font-size:7pt;color:#666;">
|
||||
<span>Лицевой счет:</span>
|
||||
<span style="font-size:8px;">${data.accountNumber}</span>
|
||||
<span style="font-size:6pt;">${data.accountNumber}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Generated
+796
-21
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
+527
-92
@@ -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 = `
|
||||
<div style="text-align:center;padding:20px;font-size:16pt;border:2px solid #000;">
|
||||
<b>ТЕСТ ПЕЧАТИ</b>
|
||||
<hr>
|
||||
<div>Дата: ${new Date().toLocaleString()}</div>
|
||||
<div>Табельный номер: TEST</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const fullHtml = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Расчетный листок</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Courier New', monospace;
|
||||
padding: 20px;
|
||||
background: white;
|
||||
color: black;
|
||||
font-size: 12pt;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
font-size: 16pt;
|
||||
border-bottom: 2px solid #000;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.total {
|
||||
font-weight: bold;
|
||||
border-top: 2px solid #000;
|
||||
padding-top: 10px;
|
||||
margin-top: 10px;
|
||||
font-size: 14pt;
|
||||
}
|
||||
.section-title {
|
||||
font-weight: bold;
|
||||
border-bottom: 1px dotted #000;
|
||||
margin: 10px 0;
|
||||
padding: 5px 0;
|
||||
font-size: 14pt;
|
||||
}
|
||||
.item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 3px 0 3px 15px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
font-size: 10pt;
|
||||
color: #666;
|
||||
border-top: 1px dashed #000;
|
||||
margin-top: 20px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
.debug-info {
|
||||
color: #999;
|
||||
font-size: 8pt;
|
||||
margin-top: 10px;
|
||||
padding: 5px;
|
||||
border: 1px dashed #ccc;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${htmlContent}
|
||||
<div class="debug-info">
|
||||
Сгенерировано: ${new Date().toLocaleString()}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
fs.writeFileSync(tempHtml, fullHtml, 'utf8');
|
||||
logger.info('[PRINT-HTML] HTML файл создан:', tempHtml);
|
||||
logger.info('[PRINT-HTML] Размер HTML:', fs.statSync(tempHtml).size, 'байт');
|
||||
|
||||
printWindow = new BrowserWindow({
|
||||
width: 600,
|
||||
height: 800,
|
||||
show: true,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
webSecurity: false,
|
||||
allowRunningInsecureContent: true,
|
||||
}
|
||||
});
|
||||
|
||||
printWindow.webContents.openDevTools();
|
||||
|
||||
logger.info('[PRINT-HTML] Загрузка HTML в окно...');
|
||||
await printWindow.loadFile(tempHtml);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
printWindow.webContents.once('did-finish-load', () => {
|
||||
logger.info('[PRINT-HTML] Событие did-finish-load');
|
||||
resolve();
|
||||
});
|
||||
setTimeout(() => {
|
||||
logger.info('[PRINT-HTML] Таймаут загрузки');
|
||||
resolve();
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
const contentCheck = await printWindow.webContents.executeJavaScript(`
|
||||
(function() {
|
||||
const body = document.body;
|
||||
if (!body) return { hasBody: false, contentLength: 0 };
|
||||
const text = body.textContent || '';
|
||||
const html = body.innerHTML || '';
|
||||
return {
|
||||
hasBody: true,
|
||||
contentLength: text.length,
|
||||
htmlLength: html.length,
|
||||
preview: text.substring(0, 200)
|
||||
};
|
||||
})()
|
||||
`);
|
||||
|
||||
logger.info('[PRINT-HTML] Проверка контента:', contentCheck);
|
||||
|
||||
if (contentCheck.contentLength < 10) {
|
||||
logger.warn('[PRINT-HTML] ⚠️ В окне нет контента!');
|
||||
await printWindow.webContents.executeJavaScript(`
|
||||
if (!document.body || document.body.textContent.length < 10) {
|
||||
document.body.innerHTML = '<div style="padding:20px;font-size:20pt;color:red;">ОШИБКА: Контент не загрузился!</div>';
|
||||
}
|
||||
`);
|
||||
}
|
||||
|
||||
await printWindow.webContents.executeJavaScript(`
|
||||
document.body.style.display = 'block';
|
||||
document.body.style.visibility = 'visible';
|
||||
document.body.style.opacity = '1';
|
||||
window.scrollTo(0, 0);
|
||||
document.querySelectorAll('*').forEach(el => {
|
||||
el.style.display = 'block';
|
||||
el.style.visibility = 'visible';
|
||||
el.style.opacity = '1';
|
||||
});
|
||||
console.log('Контент в окне:', document.body.textContent.length, 'символов');
|
||||
console.log('HTML:', document.body.innerHTML.substring(0, 500));
|
||||
`);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
const finalCheck = await printWindow.webContents.executeJavaScript(`
|
||||
document.body ? document.body.textContent.length : 0
|
||||
`);
|
||||
logger.info('[PRINT-HTML] Финальная проверка контента:', finalCheck, 'символов');
|
||||
|
||||
logger.info('[PRINT-HTML] Конвертация в PDF...');
|
||||
const pdfData = await printWindow.webContents.printToPDF({
|
||||
pageSize: {
|
||||
width: 226800,
|
||||
height: 500000
|
||||
},
|
||||
margins: {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0
|
||||
},
|
||||
printBackground: true,
|
||||
landscape: false
|
||||
});
|
||||
|
||||
fs.writeFileSync(tempPdf, pdfData);
|
||||
logger.info('[PRINT-HTML] PDF создан, размер:', pdfData.length, 'байт');
|
||||
|
||||
const pdfBuffer = fs.readFileSync(tempPdf);
|
||||
const isPdf = pdfBuffer.slice(0, 4).toString() === '%PDF';
|
||||
logger.info('[PRINT-HTML] Это PDF?', isPdf);
|
||||
|
||||
if (!isPdf) {
|
||||
logger.error('[PRINT-HTML] ⚠️ Файл НЕ является PDF!');
|
||||
throw new Error('Созданный файл не является PDF');
|
||||
}
|
||||
|
||||
if (printWindow) {
|
||||
printWindow.close();
|
||||
printWindow = null;
|
||||
}
|
||||
|
||||
if (pdfData.length < 500) {
|
||||
logger.warn('[PRINT-HTML] ⚠️ PDF слишком маленький:', pdfData.length, 'байт');
|
||||
logger.info('[PRINT-HTML] Пробуем еще раз с большим ожиданием...');
|
||||
|
||||
const retryWindow = new BrowserWindow({
|
||||
width: 600,
|
||||
height: 800,
|
||||
show: false,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
webSecurity: false,
|
||||
allowRunningInsecureContent: true,
|
||||
}
|
||||
});
|
||||
|
||||
await retryWindow.loadFile(tempHtml);
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
|
||||
const retryPdf = await retryWindow.webContents.printToPDF({
|
||||
pageSize: { width: 226800, height: 500000 },
|
||||
margins: { top: 0, bottom: 0, left: 0, right: 0 },
|
||||
printBackground: true,
|
||||
landscape: false
|
||||
});
|
||||
|
||||
fs.writeFileSync(tempPdf, retryPdf);
|
||||
logger.info('[PRINT-HTML] Повторный PDF создан, размер:', retryPdf.length, 'байт');
|
||||
retryWindow.close();
|
||||
}
|
||||
|
||||
fs.copyFileSync(tempPdf, savedPdfPath);
|
||||
logger.info('[PRINT-HTML] ✅ PDF сохранен в:', savedPdfPath);
|
||||
logger.info('[PRINT-HTML] Размер сохраненного PDF:', fs.statSync(savedPdfPath).size, 'байт');
|
||||
|
||||
const printerName = data.printerName || 'GP-80250 Series';
|
||||
logger.info('[PRINT-HTML] Отправка PDF на печать, принтер:', printerName);
|
||||
|
||||
let printResult = null;
|
||||
let printSuccess = false;
|
||||
|
||||
try {
|
||||
logger.info('[PRINT-HTML] Попытка печати...');
|
||||
printResult = await print(tempPdf, {
|
||||
printer: printerName,
|
||||
scale: 'fit',
|
||||
paperSize: 'A4'
|
||||
});
|
||||
printSuccess = true;
|
||||
logger.info('[PRINT-HTML] Печать успешна:', printResult);
|
||||
} catch (err) {
|
||||
logger.warn('[PRINT-HTML] Печать не удалась:', err.message);
|
||||
try {
|
||||
printResult = await print(tempPdf);
|
||||
printSuccess = true;
|
||||
logger.info('[PRINT-HTML] Печать успешна (без параметров)');
|
||||
} catch (err2) {
|
||||
logger.error('[PRINT-HTML] Все попытки печати не удались:', err2.message);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (tempHtml && fs.existsSync(tempHtml)) fs.unlinkSync(tempHtml);
|
||||
if (tempPdf && fs.existsSync(tempPdf)) fs.unlinkSync(tempPdf);
|
||||
} catch (e) {}
|
||||
|
||||
logger.info('[PRINT-HTML] ========== ЗАВЕРШЕНО ==========');
|
||||
logger.info('[PRINT-HTML] ✅ PDF сохранен в:', savedPdfPath);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
pdfPath: savedPdfPath,
|
||||
printSuccess: printSuccess,
|
||||
message: `PDF сохранен в: ${savedPdfPath}`
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[PRINT-HTML] ❌ Ошибка:', error.message);
|
||||
logger.error('[PRINT-HTML] Стек:', error.stack);
|
||||
|
||||
try {
|
||||
if (printWindow) printWindow.close();
|
||||
if (tempHtml && fs.existsSync(tempHtml)) fs.unlinkSync(tempHtml);
|
||||
if (tempPdf && fs.existsSync(tempPdf)) fs.unlinkSync(tempPdf);
|
||||
} catch (e) {}
|
||||
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 3. Обработчик print-html-direct (для совместимости)
|
||||
ipcMain.handle('print-html-direct', async (event, data) => {
|
||||
return await printHtmlDirectLogic(data);
|
||||
});
|
||||
|
||||
// ✅ 4. ОСНОВНОЙ ОБРАБОТЧИК - ГЕНЕРАЦИЯ PDF В ПАМЯТИ (БЕЗ СОХРАНЕНИЯ НА ДИСК)
|
||||
ipcMain.handle('print-payslip-form', async (event, data = {}) => {
|
||||
try {
|
||||
logger.info('[PRINT-PAYSLIP] ========== ГЕНЕРАЦИЯ PDF В ПАМЯТИ ==========');
|
||||
logger.info('[PRINT-PAYSLIP] tnom:', data.tnom);
|
||||
logger.info('[PRINT-PAYSLIP] period:', data.period);
|
||||
|
||||
// --- 1. Получаем данные из бэкенда ---
|
||||
let payslipData = null;
|
||||
|
||||
try {
|
||||
const backendUrl = appConfig.backendUrl || 'http://localhost:3001';
|
||||
const url = `${backendUrl}/api/payslip/data/${data.tnom}?period=${data.period}`;
|
||||
logger.info('[PRINT-PAYSLIP] Запрос к бэкенду:', url);
|
||||
|
||||
const response = await axios.get(url, { timeout: 10000 });
|
||||
payslipData = response.data;
|
||||
logger.info('[PRINT-PAYSLIP] Данные получены');
|
||||
} catch (error) {
|
||||
logger.warn('[PRINT-PAYSLIP] Не удалось получить данные из бэкенда:', error.message);
|
||||
// Заглушка на случай, если бэкенд не отвечает
|
||||
payslipData = {
|
||||
fullName: 'Иванов Иван Иванович',
|
||||
tabNumber: data.tnom || '00001',
|
||||
department: 'Отдел разработки',
|
||||
position: 'Инженер-программист',
|
||||
daysWorked: '22',
|
||||
month: 'Ноябрь',
|
||||
year: '2025',
|
||||
period: data.period || '112025',
|
||||
accrued: [
|
||||
{ code: '01', description: 'Оклад', amount: 2891.87 },
|
||||
{ code: '02', description: 'Премия', amount: 0 }
|
||||
],
|
||||
totalAccrued: 2891.87,
|
||||
deductions: [
|
||||
{ code: '101', description: 'НДФЛ', amount: 376.36 },
|
||||
{ code: '102', description: 'Профсоюз', amount: 85.50 }
|
||||
],
|
||||
totalDeductions: 461.86,
|
||||
totalPaid: 2430.01,
|
||||
bankTransfers: [
|
||||
{ bank: 'Сбербанк', amount: 2430.01 }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
// --- 2. Генерируем PDF в памяти (Buffer) ---
|
||||
const pdfBuffer = await generatePayslipPDF(payslipData);
|
||||
logger.info('[PRINT-PAYSLIP] PDF сгенерирован, размер:', pdfBuffer.length, 'байт');
|
||||
|
||||
if (pdfBuffer.length < 100) {
|
||||
throw new Error('Сгенерированный PDF слишком маленький');
|
||||
}
|
||||
|
||||
// --- 3. Преобразуем Buffer в base64 для передачи через IPC ---
|
||||
const pdfBase64 = pdfBuffer.toString('base64');
|
||||
|
||||
logger.info('[PRINT-PAYSLIP] PDF сконвертирован в base64, длина:', pdfBase64.length);
|
||||
|
||||
// --- 4. Возвращаем base64 PDF ---
|
||||
return {
|
||||
success: true,
|
||||
pdfBase64: pdfBase64,
|
||||
fileName: `${data.tnom || 'payslip'}.pdf`
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[PRINT-PAYSLIP] ❌ Ошибка:', error.message);
|
||||
logger.error('[PRINT-PAYSLIP] Стек:', error.stack);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// ✅ 5. Обработчик получения списка принтеров
|
||||
ipcMain.handle('get-printers', async () => {
|
||||
return [
|
||||
{
|
||||
name: 'GP-80250 Series',
|
||||
displayName: 'GP-80250 Series',
|
||||
isDefault: true,
|
||||
status: 'ready'
|
||||
}
|
||||
];
|
||||
});
|
||||
|
||||
// ✅ 6. Обработчик получения конфига
|
||||
ipcMain.handle('get-app-config', async (event) => {
|
||||
return appConfig;
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 🚀 СОЗДАНИЕ ГЛАВНОГО ОКНА
|
||||
// ============================================
|
||||
|
||||
function createWindow() {
|
||||
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;
|
||||
});
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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 };
|
||||
@@ -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),
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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 <News onBack={handleBackToMenu} />;
|
||||
case 'profile':
|
||||
return <Profile onBack={handleBackToMenu} employee={employee!} />;
|
||||
case 'food':
|
||||
return <FoodMenu onBack={() => setCurrentSection('main')} employee={employee} />;
|
||||
default:
|
||||
return <MainMenu onSectionSelect={handleSectionSelect} employee={employee!} />;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -66,11 +66,7 @@ const EmployeeInfo: React.FC<EmployeeInfoProps> = ({
|
||||
|
||||
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<EmployeeInfoProps> = ({
|
||||
checkPayslip();
|
||||
}, [selectedPeriod, employee]);
|
||||
|
||||
// ✅ ФУНКЦИЯ: формируем PDF в памяти и открываем предпросмотр
|
||||
const handlePreviewPdf = async () => {
|
||||
if (!employee?.tnom) return;
|
||||
|
||||
@@ -109,6 +106,50 @@ const EmployeeInfo: React.FC<EmployeeInfoProps> = ({
|
||||
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<EmployeeInfoProps> = ({
|
||||
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<EmployeeInfoProps> = ({
|
||||
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<EmployeeInfoProps> = ({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* ✅ ТОЛЬКО ОДНА КНОПКА - ПРЕДПРОСМОТР */}
|
||||
<button
|
||||
onClick={handlePreviewPdf}
|
||||
disabled={!hasPayslip}
|
||||
style={{
|
||||
padding: '10px 25px',
|
||||
padding: '12px 35px',
|
||||
background: hasPayslip ? 'linear-gradient(135deg, #00b894, #00cec9)' : '#dfe6e9',
|
||||
color: hasPayslip ? 'white' : '#b2bec3',
|
||||
border: 'none',
|
||||
borderRadius: '10px',
|
||||
fontSize: '14px',
|
||||
fontSize: '15px',
|
||||
fontWeight: 500,
|
||||
cursor: hasPayslip ? 'pointer' : 'not-allowed',
|
||||
transition: 'all 0.3s ease',
|
||||
boxShadow: hasPayslip ? '0 4px 15px rgba(0, 206, 201, 0.3)' : 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
gap: '10px',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (hasPayslip) {
|
||||
@@ -480,6 +506,7 @@ const EmployeeInfo: React.FC<EmployeeInfoProps> = ({
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* ✅ МОДАЛКА ПРЕДПРОСМОТРА PDF */}
|
||||
{showPdfPreview && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
@@ -550,32 +577,22 @@ const EmployeeInfo: React.FC<EmployeeInfoProps> = ({
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<button
|
||||
onClick={handlePrint}
|
||||
disabled={isPrinting}
|
||||
disabled={isPrinting || pdfLoading || !payslipUrl}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '8px 16px',
|
||||
background: isPrinting ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.2)',
|
||||
background: isPrinting || pdfLoading || !payslipUrl ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.2)',
|
||||
color: 'white',
|
||||
border: '1px solid rgba(255,255,255,0.3)',
|
||||
borderRadius: '10px',
|
||||
cursor: isPrinting ? 'not-allowed' : 'pointer',
|
||||
cursor: isPrinting || pdfLoading || !payslipUrl ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
transition: 'all 0.3s ease',
|
||||
backdropFilter: 'blur(4px)',
|
||||
opacity: isPrinting ? 0.5 : 1,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isPrinting) {
|
||||
e.currentTarget.style.background = 'rgba(255,255,255,0.3)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isPrinting) {
|
||||
e.currentTarget.style.background = 'rgba(255,255,255,0.2)';
|
||||
}
|
||||
opacity: isPrinting || pdfLoading || !payslipUrl ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{isPrinting ? <FaSpinner style={{ animation: 'spin 1s linear infinite' }} /> : <FaPrint size={16} />}
|
||||
@@ -595,7 +612,6 @@ const EmployeeInfo: React.FC<EmployeeInfoProps> = ({
|
||||
border: '1px solid rgba(255,255,255,0.3)',
|
||||
borderRadius: '10px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
>
|
||||
{isFullscreen ? <FaCompress size={16} /> : <FaExpand size={16} />}
|
||||
@@ -614,7 +630,6 @@ const EmployeeInfo: React.FC<EmployeeInfoProps> = ({
|
||||
border: '1px solid rgba(255,255,255,0.3)',
|
||||
borderRadius: '10px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
>
|
||||
<FaTimes size={18} />
|
||||
@@ -650,7 +665,7 @@ const EmployeeInfo: React.FC<EmployeeInfoProps> = ({
|
||||
animation: 'spin 1s linear infinite',
|
||||
}} />
|
||||
<span style={{ color: '#888', fontSize: '16px' }}>
|
||||
Загрузка документа...
|
||||
Формирование документа...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -658,20 +673,26 @@ const EmployeeInfo: React.FC<EmployeeInfoProps> = ({
|
||||
{!pdfLoading && payslipUrl && !pdfError && (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={`${payslipUrl}#toolbar=0&navpanes=0&scrollbar=1&view=FitH`}
|
||||
src={payslipUrl}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
minHeight: '600px',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
background: '#0a0a0a',
|
||||
background: '#ffffff',
|
||||
display: 'block',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
title="PDF Preview"
|
||||
onError={() => {
|
||||
console.error('❌ [IFRAME] Ошибка загрузки PDF:', payslipUrl);
|
||||
setPdfError('Не удалось загрузить PDF. Возможно, файл повреждён или путь неверный.');
|
||||
setPdfLoading(false);
|
||||
}}
|
||||
onLoad={() => {
|
||||
console.log('✅ [IFRAME] PDF загружен успешно');
|
||||
setPdfLoading(false);
|
||||
setPdfError('Не удалось загрузить PDF');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -690,12 +711,11 @@ const EmployeeInfo: React.FC<EmployeeInfoProps> = ({
|
||||
<div style={{ fontSize: '18px', color: '#e74c3c', fontWeight: 500 }}>
|
||||
{pdfError}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#888', maxWidth: '400px', wordBreak: 'break-all' }}>
|
||||
URL: {payslipUrl || 'не указан'}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setPdfError(null);
|
||||
setPdfLoading(true);
|
||||
handlePreviewPdf();
|
||||
}}
|
||||
onClick={handlePreviewPdf}
|
||||
style={{
|
||||
padding: '10px 30px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
@@ -705,7 +725,6 @@ const EmployeeInfo: React.FC<EmployeeInfoProps> = ({
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
>
|
||||
🔄 Попробовать снова
|
||||
|
||||
@@ -199,6 +199,7 @@ const Finance: React.FC<FinanceProps> = ({ onBack, onViewPayslip, employee }) =>
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
background: '#6c757d',
|
||||
flex: '0 0 100px',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
|
||||
@@ -0,0 +1,853 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
FaArrowLeft, FaShoppingCart, FaPlus, FaMinus, FaTrashAlt,
|
||||
FaWallet, FaPaperPlane, FaCheckCircle, FaSpinner, FaUtensils, FaTimes
|
||||
} from 'react-icons/fa';
|
||||
|
||||
const API_BASE = 'http://localhost:3001';
|
||||
const BOTTOM_BAR_HEIGHT = 70;
|
||||
|
||||
// ============================================================
|
||||
// ТИПЫ
|
||||
// ============================================================
|
||||
interface FoodItem {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
price: number;
|
||||
imageUrl?: string;
|
||||
weightGrams?: number;
|
||||
}
|
||||
interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
emoji?: string;
|
||||
items: FoodItem[];
|
||||
}
|
||||
|
||||
interface FoodMenuProps {
|
||||
onBack: () => void;
|
||||
employee: any;
|
||||
}
|
||||
|
||||
type View = 'menu' | 'success';
|
||||
|
||||
const CATEGORY_EMOJI_FALLBACK: Record<number, string> = {
|
||||
1: '🍲', 2: '🥣', 3: '🥪', 4: '🥤', 5: '🍰',
|
||||
};
|
||||
|
||||
const getInitials = (fullName: string) =>
|
||||
fullName.split(' ').filter(Boolean).slice(0, 2)
|
||||
.map((s) => s[0]?.toUpperCase()).join('');
|
||||
|
||||
// ============================================================
|
||||
// ГЛАВНЫЙ КОМПОНЕНТ
|
||||
// ============================================================
|
||||
const FoodMenu: React.FC<FoodMenuProps> = ({ onBack, employee }) => {
|
||||
const tnom = employee?.tnom || '';
|
||||
const fullName = employee
|
||||
? `${employee.famaly || ''} ${employee.ima || ''} ${employee.otch || ''}`.trim()
|
||||
: 'Сотрудник';
|
||||
|
||||
const [company, setCompany] = useState('АзотСервис');
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [activeCat, setActiveCat] = useState<number | null>(null);
|
||||
const [balance, setBalance] = useState<number>(0);
|
||||
|
||||
const [cart, setCart] = useState<Record<number, number>>({});
|
||||
const [expandedItem, setExpandedItem] = useState<number | null>(null);
|
||||
const [imageErrors, setImageErrors] = useState<Record<number, boolean>>({});
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [view, setView] = useState<View>('menu');
|
||||
const [isCartOpen, setIsCartOpen] = useState(false);
|
||||
const [successOrder, setSuccessOrder] = useState<{ id: number; total: number } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Состояния для "Мои заказы"
|
||||
const [showMyOrders, setShowMyOrders] = useState(false);
|
||||
const [myOrders, setMyOrders] = useState<any[]>([]);
|
||||
const [loadingOrders, setLoadingOrders] = useState(false);
|
||||
const [cancelConfirm, setCancelConfirm] = useState<{ id: number; total: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [menuRes, balRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/api/food/menu`).then((r) => r.json()),
|
||||
tnom ? fetch(`${API_BASE}/api/food/balance/${tnom}`).then((r) => r.json())
|
||||
: Promise.resolve({ balance: 0 }),
|
||||
]);
|
||||
setCompany(menuRes.company || 'АзотСервис');
|
||||
setCategories(menuRes.categories || []);
|
||||
setActiveCat(menuRes.categories?.[0]?.id ?? null);
|
||||
setBalance(Number(balRes.balance) || 0);
|
||||
} catch (e) {
|
||||
setError('Не удалось загрузить меню');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [tnom]);
|
||||
|
||||
const allItems = useMemo(() => {
|
||||
const map: Record<number, FoodItem> = {};
|
||||
categories.forEach((c) => c.items.forEach((i) => (map[i.id] = i)));
|
||||
return map;
|
||||
}, [categories]);
|
||||
|
||||
const cartCount = useMemo(
|
||||
() => Object.values(cart).reduce((s, q) => s + q, 0), [cart]
|
||||
);
|
||||
const cartTotal = useMemo(
|
||||
() => +Object.entries(cart).reduce((s, [id, qty]) => {
|
||||
const it = allItems[Number(id)];
|
||||
return it ? s + it.price * qty : s;
|
||||
}, 0).toFixed(2),
|
||||
[cart, allItems]
|
||||
);
|
||||
|
||||
const changeQty = (itemId: number, delta: number) => {
|
||||
setCart((prev) => {
|
||||
const next = { ...prev };
|
||||
const qty = (next[itemId] || 0) + delta;
|
||||
if (qty <= 0) delete next[itemId];
|
||||
else next[itemId] = qty;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const removeItem = (itemId: number) => {
|
||||
setCart((prev) => {
|
||||
const n = { ...prev };
|
||||
delete n[itemId];
|
||||
return n;
|
||||
});
|
||||
};
|
||||
|
||||
const activeItems = categories.find((c) => c.id === activeCat)?.items || [];
|
||||
|
||||
const submitOrder = async () => {
|
||||
if (cartCount === 0) return;
|
||||
setSending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const items = Object.entries(cart).map(([id, qty]) => ({
|
||||
itemId: Number(id), quantity: qty,
|
||||
}));
|
||||
const res = await fetch(`${API_BASE}/api/food/orders`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tnom, items }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Ошибка отправки заказа');
|
||||
setSending(false);
|
||||
return;
|
||||
}
|
||||
setSuccessOrder({ id: data.orderId, total: data.total });
|
||||
setBalance((b) => +(b - data.total).toFixed(2));
|
||||
setCart({});
|
||||
setIsCartOpen(false);
|
||||
setView('success');
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Ошибка сети');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDone = () => {
|
||||
setSuccessOrder(null);
|
||||
setView('menu');
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// МОИ ЗАКАЗЫ
|
||||
// ============================================
|
||||
const fetchMyOrders = async () => {
|
||||
if (!tnom) return;
|
||||
setLoadingOrders(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/food/my-orders/${tnom}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setMyOrders(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoadingOrders(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenMyOrders = () => {
|
||||
setShowMyOrders(true);
|
||||
fetchMyOrders();
|
||||
};
|
||||
|
||||
const handleCancelClick = (orderId: number, totalAmount: number) => {
|
||||
setCancelConfirm({ id: orderId, total: totalAmount });
|
||||
};
|
||||
|
||||
const confirmCancelOrder = async () => {
|
||||
if (!cancelConfirm) return;
|
||||
const { id, total } = cancelConfirm;
|
||||
setCancelConfirm(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/food/my-orders/${id}/cancel`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tnom }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setBalance((b) => +(b + data.refund).toFixed(2));
|
||||
fetchMyOrders();
|
||||
} else {
|
||||
alert(data.error || 'Ошибка отмены заказа');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Ошибка сети при отмене заказа');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={loadingStyle}>
|
||||
<div style={spinnerStyle} />
|
||||
<style>{keyframes}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (view === 'success' && successOrder) {
|
||||
return (
|
||||
<div style={successWrapperStyle}>
|
||||
<motion.div
|
||||
initial={{ scale: 0.85, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
style={successCardStyle}
|
||||
>
|
||||
<div style={{ background: '#E8F5E9', borderRadius: '50%', padding: 20, display: 'inline-block', marginBottom: 20 }}>
|
||||
<FaCheckCircle style={{ fontSize: 80, color: '#4CAF50' }} />
|
||||
</div>
|
||||
<h1 style={{ margin: '0 0 12px', color: '#2E3B32', fontSize: 36, fontWeight: 800 }}>
|
||||
Заказ оформлен!
|
||||
</h1>
|
||||
<p style={{ color: '#607D8B', fontSize: 20, margin: '8px 0' }}>
|
||||
Номер заказа: <b style={{ color: '#2E3B32' }}>#{successOrder.id}</b>
|
||||
</p>
|
||||
<p style={{ color: '#607D8B', fontSize: 20, margin: '8px 0 32px' }}>
|
||||
Сумма: <b style={{ color: '#2E3B32' }}>{successOrder.total.toFixed(2)} BYN</b>
|
||||
</p>
|
||||
<button onClick={handleDone} style={successBtnStyle}>
|
||||
Готово
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const entries = Object.entries(cart);
|
||||
const notEnough = cartTotal > balance;
|
||||
|
||||
return (
|
||||
<div style={menuRootStyle}>
|
||||
{/* ============ ШАПКА ============ */}
|
||||
<div style={headerZoneStyle}>
|
||||
{/* 1-я строка: Кнопка Назад (10%) + Название столовой + Мои заказы */}
|
||||
<div style={headerRow1Style}>
|
||||
<button onClick={onBack} style={btnRectBackStyle}>
|
||||
<FaArrowLeft size={20} />
|
||||
</button>
|
||||
<div style={companyTitleStyle}>
|
||||
<FaUtensils size={32} style={{ color: '#4CAF50' }} />
|
||||
<span>{company}</span>
|
||||
</div>
|
||||
|
||||
{/* Кнопка Мои заказы */}
|
||||
<button
|
||||
onClick={handleOpenMyOrders}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
background: '#FFFFFF', color: '#4CAF50', border: '2px solid #4CAF50',
|
||||
borderRadius: 12, padding: '10px 20px', fontSize: 16, fontWeight: 700,
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.05)'
|
||||
}}
|
||||
>
|
||||
<FaShoppingCart size={18} /> Мои заказы
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 2-я строка: Сотрудник + Баланс */}
|
||||
<div style={headerRow2Style}>
|
||||
<div style={headerCardStyle}>
|
||||
<div style={avatarCircleStyle}>{getInitials(fullName) || '👤'}</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={headerLabelStyle}>Сотрудник</div>
|
||||
<div style={headerValueStyle} title={fullName}>{fullName}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
...headerCardStyle,
|
||||
background: 'linear-gradient(135deg, #81C784 0%, #4CAF50 100%)',
|
||||
border: 'none',
|
||||
boxShadow: '0 8px 24px rgba(76,175,80,0.25)',
|
||||
flex: '0 1 auto',
|
||||
minWidth: 260,
|
||||
}}>
|
||||
<FaWallet size={28} style={{ color: '#fff' }} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ ...headerLabelStyle, color: 'rgba(255,255,255,0.9)' }}>Баланс</div>
|
||||
<div style={{ ...headerValueStyle, color: '#fff', fontSize: 22 }}>
|
||||
{balance.toFixed(2)} BYN
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ============ ОСНОВНАЯ ЗОНА ============ */}
|
||||
<div style={mainZoneStyle}>
|
||||
{/* Вертикальная колонка категорий */}
|
||||
<div style={categoriesPanelStyle}>
|
||||
<div style={categoriesTitleStyle}>КАТЕГОРИИ</div>
|
||||
<div style={categoriesListStyle}>
|
||||
{categories.map((c) => {
|
||||
const isActive = c.id === activeCat;
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => { setActiveCat(c.id); setExpandedItem(null); }}
|
||||
style={{
|
||||
...categoryBtnStyle,
|
||||
background: isActive ? '#E2F0D9' : 'transparent',
|
||||
color: isActive ? '#4CAF50' : '#546E7A',
|
||||
fontWeight: isActive ? 800 : 600,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 32 }}>{c.emoji || '🍽️'}</span>
|
||||
<span style={{ fontSize: 18 }}>{c.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Сетка блюд */}
|
||||
<div style={itemsPanelStyle}>
|
||||
{activeItems.map((it) => {
|
||||
const qty = cart[it.id] || 0;
|
||||
const cat = categories.find((c) => c.id === activeCat);
|
||||
const emoji = CATEGORY_EMOJI_FALLBACK[cat?.id || 0] || '🍽️';
|
||||
const isExpanded = expandedItem === it.id || qty > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={it.id}
|
||||
style={{
|
||||
...itemCardStyle,
|
||||
border: isExpanded ? '2px solid #81C784' : '2px solid transparent',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (qty === 0) {
|
||||
changeQty(it.id, 1);
|
||||
setExpandedItem(it.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={itemImgStyle}>
|
||||
{it.imageUrl && !imageErrors[it.id] ? (
|
||||
<img
|
||||
src={`${API_BASE}${it.imageUrl}`}
|
||||
alt={it.name}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={() => {
|
||||
setImageErrors(prev => ({ ...prev, [it.id]: true }));
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
emoji
|
||||
)}
|
||||
</div>
|
||||
<div style={itemInfoStyle}>
|
||||
<div style={itemNameStyle}>{it.name}</div>
|
||||
{it.description && <div style={itemDescStyle}>{it.description}</div>}
|
||||
<div style={{ flex: 1 }} />
|
||||
<div style={itemBottomStyle}>
|
||||
<div style={itemPriceStyle}>
|
||||
{it.price.toFixed(2)}
|
||||
<span style={{ fontSize: 14, color: '#90A4AE', marginLeft: 4, fontWeight: 500 }}>BYN</span>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
style={qtyInlineStyle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
changeQty(it.id, -1);
|
||||
if (qty <= 1) setExpandedItem(null);
|
||||
}}
|
||||
style={qtyItemBtnStyle}
|
||||
>
|
||||
<FaMinus size={16} />
|
||||
</button>
|
||||
<span style={qtyInlineNumStyle}>{qty}</span>
|
||||
<button
|
||||
onClick={() => changeQty(it.id, 1)}
|
||||
style={qtyItemBtnStyle}
|
||||
>
|
||||
<FaPlus size={16} />
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ============ ПЛАВАЮЩАЯ ПЛАШКА КОРЗИНЫ ============ */}
|
||||
<AnimatePresence>
|
||||
{cartCount > 0 && (
|
||||
<motion.div
|
||||
initial={{ y: 100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 100, opacity: 0 }}
|
||||
style={cartBarStyle}
|
||||
onClick={() => setIsCartOpen(true)}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<div style={{ background: 'rgba(255,255,255,0.2)', borderRadius: '50%', width: 48, height: 48, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<FaShoppingCart size={22} />
|
||||
</div>
|
||||
<span style={{ fontWeight: 600, fontSize: 20 }}>
|
||||
{cartCount} поз.
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 20 }}>
|
||||
<span style={{ fontWeight: 800, fontSize: 24 }}>
|
||||
{cartTotal.toFixed(2)} BYN
|
||||
</span>
|
||||
<div style={{ background: '#fff', color: '#4CAF50', borderRadius: '50%', width: 48, height: 48, display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 'bold', fontSize: 24 }}>
|
||||
→
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* ============ ВЫЕЗЖАЮЩАЯ КОРЗИНА СПРАВА ============ */}
|
||||
<AnimatePresence>
|
||||
{isCartOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(46,59,50,0.4)', backdropFilter: 'blur(4px)', zIndex: 200 }}
|
||||
onClick={() => setIsCartOpen(false)}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
||||
style={{
|
||||
position: 'fixed', top: 0, right: 0, bottom: 0, width: 540,
|
||||
background: '#FFFFFF', boxShadow: '-10px 0 40px rgba(0,0,0,0.1)',
|
||||
zIndex: 201, display: 'flex', flexDirection: 'column',
|
||||
paddingBottom: BOTTOM_BAR_HEIGHT
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '24px', borderBottom: '1px solid #E0E6ED', background: '#E2F0D9' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 28, fontWeight: 800, color: '#2E3B32' }}>Ваш заказ</h2>
|
||||
<button onClick={() => setIsCartOpen(false)} style={{ background: '#FFFFFF', border: 'none', width: 48, height: 48, borderRadius: '50%', cursor: 'pointer', color: '#4CAF50', boxShadow: '0 2px 10px rgba(0,0,0,0.05)' }}>
|
||||
<FaTimes size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{entries.map(([idStr, qty]) => {
|
||||
const id = Number(idStr);
|
||||
const it = allItems[id];
|
||||
if (!it) return null;
|
||||
const cat = categories.find((c) => c.items.some((i) => i.id === id));
|
||||
const emoji = CATEGORY_EMOJI_FALLBACK[cat?.id || 0] || '🍽️';
|
||||
return (
|
||||
<div key={id} style={cartItemStyle}>
|
||||
<div style={cartItemImgStyle}>
|
||||
{it.imageUrl && !imageErrors[it.id] ? (
|
||||
<img
|
||||
src={`${API_BASE}${it.imageUrl}`}
|
||||
alt={it.name}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={() => {
|
||||
setImageErrors(prev => ({ ...prev, [it.id]: true }));
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
emoji
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={cartItemNameStyle}>{it.name}</div>
|
||||
<div style={cartItemPriceStyle}>{it.price.toFixed(2)} BYN</div>
|
||||
</div>
|
||||
<div style={qtyControlsStyle}>
|
||||
<button onClick={() => changeQty(id, -1)} style={qtyBigBtnStyle}><FaMinus size={16} /></button>
|
||||
<span style={qtyNumStyle}>{qty}</span>
|
||||
<button onClick={() => changeQty(id, 1)} style={qtyBigBtnStyle}><FaPlus size={16} /></button>
|
||||
</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 800, color: '#2E3B32', minWidth: 80, textAlign: 'right' }}>
|
||||
{(it.price * qty).toFixed(2)}
|
||||
</div>
|
||||
<button onClick={() => removeItem(id)} style={removeBtnStyle}><FaTrashAlt size={20} /></button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '24px', borderTop: '1px solid #E0E6ED', background: '#F7F9F6' }}>
|
||||
{error && <div style={errorStyle}>{error}</div>}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: '#546E7A' }}>Итого:</div>
|
||||
<div style={{ fontSize: 40, fontWeight: 800, color: '#2E3B32' }}>
|
||||
{cartTotal.toFixed(2)} <span style={{ fontSize: 20, color: '#78909C' }}>BYN</span>
|
||||
</div>
|
||||
</div>
|
||||
{notEnough && <div style={{ fontSize: 16, color: '#E74C3C', marginBottom: 16, fontWeight: 600, textAlign: 'center' }}>Недостаточно средств (баланс {balance.toFixed(2)})</div>}
|
||||
<button
|
||||
onClick={submitOrder}
|
||||
disabled={sending || notEnough || entries.length === 0}
|
||||
style={{
|
||||
...submitBtnStyle,
|
||||
background: sending || notEnough || entries.length === 0 ? '#B0BEC5' : 'linear-gradient(135deg, #81C784 0%, #4CAF50 100%)',
|
||||
cursor: sending || notEnough || entries.length === 0 ? 'not-allowed' : 'pointer',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{sending ? <><FaSpinner size={24} style={{ animation: 'spin 1s linear infinite' }} /> Отправка…</> : <><FaPaperPlane size={24} /> Отправить заказ</>}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* ============ ВЫЕЗЖАЮЩАЯ ПАНЕЛЬ "МОИ ЗАКАЗЫ" ============ */}
|
||||
<AnimatePresence>
|
||||
{showMyOrders && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(46,59,50,0.4)', backdropFilter: 'blur(4px)', zIndex: 200 }}
|
||||
onClick={() => setShowMyOrders(false)}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
||||
style={{
|
||||
position: 'fixed', top: 0, right: 0, bottom: 0, width: 540,
|
||||
background: '#FFFFFF', boxShadow: '-10px 0 40px rgba(0,0,0,0.1)',
|
||||
zIndex: 201, display: 'flex', flexDirection: 'column',
|
||||
paddingBottom: BOTTOM_BAR_HEIGHT
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '24px', borderBottom: '1px solid #E0E6ED', background: '#E2F0D9' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 28, fontWeight: 800, color: '#2E3B32' }}>Мои заказы</h2>
|
||||
<button onClick={() => setShowMyOrders(false)} style={{ background: '#FFFFFF', border: 'none', width: 48, height: 48, borderRadius: '50%', cursor: 'pointer', color: '#4CAF50', boxShadow: '0 2px 10px rgba(0,0,0,0.05)' }}>
|
||||
<FaTimes size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{loadingOrders ? (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: '#78909C' }}>Загрузка...</div>
|
||||
) : myOrders.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: '#78909C', fontSize: 18 }}>У вас пока нет заказов</div>
|
||||
) : (
|
||||
myOrders.map((order) => {
|
||||
let statusColor = '#78909C';
|
||||
let statusText = order.status;
|
||||
if (order.status === 'new') { statusColor = '#F39C12'; statusText = 'В обработке'; }
|
||||
if (order.status === 'accepted') { statusColor = '#3498DB'; statusText = 'Готовится'; }
|
||||
if (order.status === 'done') { statusColor = '#2ECC71'; statusText = 'Готов'; }
|
||||
if (order.status === 'cancelled') { statusColor = '#E74C3C'; statusText = 'Отменен'; }
|
||||
|
||||
return (
|
||||
<div key={order.id} style={{ background: '#F7F9F6', borderRadius: 16, padding: 20, border: '1px solid #E0E6ED' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<div style={{ fontWeight: 800, fontSize: 18, color: '#2E3B32' }}>Заказ #{order.id}</div>
|
||||
<div style={{
|
||||
padding: '4px 12px', borderRadius: 20, fontSize: 14, fontWeight: 700,
|
||||
background: `${statusColor}20`, color: statusColor,
|
||||
border: `1px solid ${statusColor}50`
|
||||
}}>
|
||||
{statusText}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: '#78909C', marginBottom: 12 }}>
|
||||
{new Date(order.createdAt).toLocaleString('ru-RU')}
|
||||
</div>
|
||||
<ul style={{ margin: 0, paddingLeft: 20, fontSize: 16, color: '#2E3B32', marginBottom: 12 }}>
|
||||
{order.items.map((it: any, i: number) => (
|
||||
<li key={i}>{it.name} <span style={{ color: '#78909C' }}>x{it.quantity}</span></li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12, paddingTop: 12, borderTop: '1px solid #E0E6ED' }}>
|
||||
<div style={{ fontSize: 20, fontWeight: 800, color: '#4CAF50' }}>
|
||||
{order.totalAmount.toFixed(2)} BYN
|
||||
</div>
|
||||
|
||||
{order.status === 'new' && (
|
||||
<button
|
||||
onClick={() => handleCancelClick(order.id, order.totalAmount)}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
background: '#FFEBEE',
|
||||
flex: '0 0 100px',
|
||||
color: '#E74C3C',
|
||||
border: '2px solid #E74C3C',
|
||||
borderRadius: 8,
|
||||
cursor: 'pointer',
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<FaTimes size={16} /> Отменить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* ============ МИНИ-МОДАЛКА ПОДТВЕРЖДЕНИЯ ОТМЕНЫ ============ */}
|
||||
<AnimatePresence>
|
||||
{cancelConfirm && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, background: 'rgba(46,59,50,0.5)', backdropFilter: 'blur(4px)',
|
||||
zIndex: 300, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, y: 20 }} animate={{ scale: 1, y: 0 }} exit={{ scale: 0.9, y: 20 }}
|
||||
style={{
|
||||
background: '#FFFFFF', borderRadius: 20, padding: '32px',
|
||||
maxWidth: 460, width: '100%', textAlign: 'center',
|
||||
boxShadow: '0 24px 80px rgba(0,0,0,0.2)'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: 64, height: 64, borderRadius: '50%',
|
||||
background: '#FFEBEE', color: '#E74C3C',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
margin: '0 auto 16px', fontSize: 28
|
||||
}}>
|
||||
<FaTimes />
|
||||
</div>
|
||||
<h3 style={{ margin: '0 0 8px', fontSize: 22, fontWeight: 800, color: '#2E3B32' }}>
|
||||
Отменить заказ?
|
||||
</h3>
|
||||
<p style={{ margin: '0 0 24px', color: '#607D8B', fontSize: 16 }}>
|
||||
Заказ #{cancelConfirm.id} будет отменён.<br />
|
||||
<b style={{ color: '#2E3B32' }}>{cancelConfirm.total.toFixed(2)} BYN</b> вернутся на баланс.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<button
|
||||
onClick={() => setCancelConfirm(null)}
|
||||
style={{
|
||||
flex: 1, padding: '14px', border: '2px solid #E0E6ED',
|
||||
background: '#FFFFFF', color: '#546E7A', borderRadius: 12,
|
||||
fontSize: 16, fontWeight: 700, cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmCancelOrder}
|
||||
style={{
|
||||
flex: 1, padding: '14px',
|
||||
background: 'linear-gradient(135deg, #EF5350 0%, #E74C3C 100%)',
|
||||
color: '#FFFFFF', border: 'none', borderRadius: 12,
|
||||
fontSize: 16, fontWeight: 700, cursor: 'pointer',
|
||||
boxShadow: '0 8px 24px rgba(231,76,60,0.3)'
|
||||
}}
|
||||
>
|
||||
Да, отменить
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<style>{keyframes}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// СТИЛИ (FRESH MINT + КНОПКА 10%)
|
||||
// ============================================================
|
||||
const keyframes = `@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }`;
|
||||
|
||||
const loadingStyle: React.CSSProperties = { position: 'fixed', inset: 0, background: '#F7F9F6', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 50 };
|
||||
const spinnerStyle: React.CSSProperties = { width: 60, height: 60, border: '5px solid #E2F0D9', borderTop: '5px solid #4CAF50', borderRadius: '50%', animation: 'spin 1s linear infinite' };
|
||||
|
||||
const menuRootStyle: React.CSSProperties = {
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
paddingTop: 90, paddingBottom: BOTTOM_BAR_HEIGHT,
|
||||
background: '#F7F9F6', display: 'flex', flexDirection: 'column', overflow: 'hidden', boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
// Шапка
|
||||
const headerZoneStyle: React.CSSProperties = {
|
||||
padding: '16px 24px',
|
||||
background: '#E2F0D9',
|
||||
borderBottom: '1px solid #C8E6C9',
|
||||
flexShrink: 0, zIndex: 10, boxShadow: '0 4px 16px rgba(76,175,80,0.05)',
|
||||
display: 'flex', flexDirection: 'column', gap: 16,
|
||||
};
|
||||
|
||||
// Первая строка шапки
|
||||
const headerRow1Style: React.CSSProperties = {
|
||||
display: 'flex', alignItems: 'center', gap: 20, justifyContent: 'flex-start'
|
||||
};
|
||||
const btnRectBackStyle: React.CSSProperties = {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
flex: '0 0 100px',
|
||||
height: 52,
|
||||
background: '#FFFFFF', color: '#4CAF50',
|
||||
border: 'none', borderRadius: 12, cursor: 'pointer',
|
||||
fontSize: 16, fontWeight: 800, flexShrink: 0,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.05)', transition: 'all 0.2s',
|
||||
};
|
||||
const companyTitleStyle: React.CSSProperties = {
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
fontSize: 36, fontWeight: 800, color: '#2E3B32',
|
||||
letterSpacing: '-0.5px'
|
||||
};
|
||||
|
||||
// Вторая строка шапки (Сотрудник + Баланс)
|
||||
const headerRow2Style: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap' };
|
||||
const headerCardStyle: React.CSSProperties = {
|
||||
flex: '1 1 220px', display: 'flex', alignItems: 'center', gap: 14, padding: '12px 20px',
|
||||
background: '#FFFFFF', borderRadius: 16, minWidth: 0, boxShadow: '0 2px 12px rgba(0,0,0,0.03)',
|
||||
};
|
||||
const headerLabelStyle: React.CSSProperties = { fontSize: 12, color: '#607D8B', textTransform: 'uppercase', letterSpacing: 0.5, fontWeight: 800 };
|
||||
const headerValueStyle: React.CSSProperties = { fontSize: 18, fontWeight: 700, color: '#2E3B32', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', marginTop: 2 };
|
||||
const avatarCircleStyle: React.CSSProperties = { width: 44, height: 44, borderRadius: '50%', background: '#E2F0D9', color: '#4CAF50', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 18, fontWeight: 800, flexShrink: 0 };
|
||||
|
||||
|
||||
const mainZoneStyle: React.CSSProperties = { flex: 1, display: 'flex', minHeight: 0, overflow: 'hidden' };
|
||||
|
||||
// Категории
|
||||
const categoriesPanelStyle: React.CSSProperties = {
|
||||
width: 220, background: '#FFFFFF', borderRight: '1px solid #E0E6ED',
|
||||
display: 'flex', flexDirection: 'column', padding: '16px 0', overflowY: 'auto', flexShrink: 0,
|
||||
};
|
||||
const categoriesTitleStyle: React.CSSProperties = { fontSize: 13, fontWeight: 800, color: '#607D8B', padding: '0 24px 16px', letterSpacing: 0.5 };
|
||||
const categoriesListStyle: React.CSSProperties = { flex: 1, overflowY: 'auto' };
|
||||
const categoryBtnStyle: React.CSSProperties = {
|
||||
width: '100%', padding: '20px 24px', border: 'none', borderRadius: 0, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', gap: 16, textAlign: 'left', transition: 'all 0.2s',
|
||||
};
|
||||
|
||||
// Сетка блюд
|
||||
const itemsPanelStyle: React.CSSProperties = {
|
||||
flex: 1, display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
|
||||
gap: 20, alignContent: 'start', overflowY: 'auto', padding: '24px', minHeight: 0,
|
||||
};
|
||||
|
||||
const itemCardStyle: React.CSSProperties = {
|
||||
background: '#FFFFFF', borderRadius: 20, overflow: 'hidden',
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.03)', display: 'flex', flexDirection: 'column', minWidth: 0, transition: 'all 0.2s',
|
||||
};
|
||||
const itemImgStyle: React.CSSProperties = {
|
||||
height: 140, background: '#F7F9F6',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 72, overflow: 'hidden',
|
||||
};
|
||||
const itemInfoStyle: React.CSSProperties = { padding: '16px', flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 };
|
||||
const itemNameStyle: React.CSSProperties = { fontSize: 17, fontWeight: 800, color: '#2E3B32', lineHeight: 1.3 };
|
||||
const itemDescStyle: React.CSSProperties = {
|
||||
fontSize: 13, color: '#78909C', marginTop: 6,
|
||||
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden',
|
||||
};
|
||||
const itemBottomStyle: React.CSSProperties = { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 16, gap: 8, flexWrap: 'wrap' };
|
||||
const itemPriceStyle: React.CSSProperties = { fontSize: 20, fontWeight: 800, color: '#4CAF50' };
|
||||
|
||||
const qtyInlineStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 4, background: '#F0F4F8', borderRadius: 999, padding: '4px' };
|
||||
const qtyItemBtnStyle: React.CSSProperties = {
|
||||
width: 40, height: 40, borderRadius: '50%', background: '#FFFFFF', border: 'none',
|
||||
cursor: 'pointer', color: '#4CAF50', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0, boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
|
||||
};
|
||||
const qtyInlineNumStyle: React.CSSProperties = { minWidth: 32, textAlign: 'center', fontWeight: 800, fontSize: 18, color: '#2E3B32' };
|
||||
|
||||
const cartBarStyle: React.CSSProperties = {
|
||||
position: 'fixed', bottom: BOTTOM_BAR_HEIGHT + 12, left: '50%', transform: 'translateX(-50%)',
|
||||
background: '#2E3B32', color: '#fff', borderRadius: 999, padding: '10px 16px 10px 12px',
|
||||
display: 'flex', alignItems: 'center', gap: 32, boxShadow: '0 12px 40px rgba(46,59,50,0.3)',
|
||||
cursor: 'pointer', zIndex: 100, minWidth: 380, justifyContent: 'space-between',
|
||||
};
|
||||
|
||||
// Выезжающая корзина
|
||||
const cartItemStyle: React.CSSProperties = {
|
||||
background: '#FFFFFF', borderRadius: 16, padding: 16, display: 'flex', alignItems: 'center', gap: 16,
|
||||
border: '1px solid #F0F4F8',
|
||||
};
|
||||
const cartItemImgStyle: React.CSSProperties = {
|
||||
width: 60, height: 60, borderRadius: 12, background: '#F7F9F6',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 32, overflow: 'hidden', flexShrink: 0,
|
||||
};
|
||||
const cartItemNameStyle: React.CSSProperties = { fontSize: 16, fontWeight: 700, color: '#2E3B32', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' };
|
||||
const cartItemPriceStyle: React.CSSProperties = { fontSize: 14, color: '#78909C', marginTop: 4, fontWeight: 500 };
|
||||
const qtyControlsStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 8, background: '#F0F4F8', borderRadius: 999, padding: '4px 6px' };
|
||||
const qtyBigBtnStyle: React.CSSProperties = {
|
||||
width: 36, height: 36, borderRadius: '50%', background: '#FFFFFF', border: 'none',
|
||||
cursor: 'pointer', color: '#2E3B32', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
|
||||
};
|
||||
const qtyNumStyle: React.CSSProperties = { minWidth: 28, textAlign: 'center', fontWeight: 800, fontSize: 16, color: '#2E3B32' };
|
||||
const removeBtnStyle: React.CSSProperties = { background: 'transparent', border: 'none', color: '#EF5350', cursor: 'pointer', padding: 8 };
|
||||
|
||||
const submitBtnStyle: React.CSSProperties = {
|
||||
color: '#fff', border: 'none', borderRadius: 16, padding: '20px 40px',
|
||||
fontSize: 22, fontWeight: 800, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 12,
|
||||
boxShadow: '0 8px 24px rgba(76,175,80,0.3)', transition: 'all 0.2s',
|
||||
};
|
||||
const errorStyle: React.CSSProperties = { background: '#FFEBEE', color: '#C62828', padding: '12px 18px', borderRadius: 12, fontSize: 16, marginBottom: 16, fontWeight: 600, textAlign: 'center' };
|
||||
|
||||
// SUCCESS
|
||||
const successWrapperStyle: React.CSSProperties = { position: 'fixed', inset: 0, background: 'rgba(46,59,50,0.7)', backdropFilter: 'blur(8px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 300, padding: 20 };
|
||||
const successCardStyle: React.CSSProperties = { background: '#FFFFFF', borderRadius: 32, padding: '56px 64px', textAlign: 'center', maxWidth: 600, boxShadow: '0 24px 80px rgba(0,0,0,0.2)' };
|
||||
const successBtnStyle: React.CSSProperties = { padding: '20px 64px', background: 'linear-gradient(135deg, #81C784 0%, #4CAF50 100%)', color: '#fff', border: 'none', borderRadius: 16, fontSize: 22, fontWeight: 800, cursor: 'pointer', boxShadow: '0 8px 24px rgba(76,175,80,0.3)' };
|
||||
|
||||
export default FoodMenu;
|
||||
@@ -19,7 +19,8 @@ const MainMenu: React.FC<MainMenuProps> = ({ onSectionSelect, employee }) => {
|
||||
{ id: 'labor-safety', title: 'Охрана труда', emoji: '⛑️' },
|
||||
{ id: 'contacts', title: 'Контакты', emoji: '📞' },
|
||||
{ id: 'news', title: 'Новости', emoji: '📰' },
|
||||
{ id: 'profile', title: 'Обо мне', emoji: '👤' },
|
||||
{ id: 'profile', title: 'Лучший по профессии', emoji: '👤' },
|
||||
{ id: 'food', title: 'Питание', emoji: '🍔' },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { FaTimes, FaPrint, FaSpinner, FaCheckCircle, FaExclamationCircle } from 'react-icons/fa';
|
||||
|
||||
interface PayslipFormPreviewProps {
|
||||
tnom: string;
|
||||
period: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const PayslipFormPreview: React.FC<PayslipFormPreviewProps> = ({ tnom, period, onClose }) => {
|
||||
const [htmlContent, setHtmlContent] = useState<string>('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPrinting, setIsPrinting] = useState(false);
|
||||
const [printStatus, setPrintStatus] = useState<'idle' | 'success' | 'error' | 'sending'>('idle');
|
||||
const [printMessage, setPrintMessage] = useState('');
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
const BACKEND_URL = 'http://localhost:3001';
|
||||
|
||||
useEffect(() => {
|
||||
const loadForm = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
// ✅ Загружаем HTML-форму из бэкенда
|
||||
const response = await fetch(`${BACKEND_URL}/api/payslip/form/${tnom}?period=${period}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Ошибка загрузки формы');
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
setHtmlContent(html);
|
||||
} catch (err) {
|
||||
console.error('[PayslipFormPreview] Ошибка:', err);
|
||||
setError(err instanceof Error ? err.message : 'Неизвестная ошибка');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadForm();
|
||||
}, [tnom, period]);
|
||||
|
||||
const handlePrint = async () => {
|
||||
try {
|
||||
setIsPrinting(true);
|
||||
setPrintStatus('sending');
|
||||
setPrintMessage('Подготовка документа к печати...');
|
||||
|
||||
if (!htmlContent || htmlContent.length < 10) {
|
||||
throw new Error('HTML контент пустой или слишком короткий');
|
||||
}
|
||||
|
||||
const electronAPI = (window as any).electronAPI;
|
||||
|
||||
if (electronAPI && electronAPI.printPayslipForm) {
|
||||
console.log('[Print] Печать через Electron API');
|
||||
console.log('[Print] Длина HTML:', htmlContent.length);
|
||||
|
||||
const result = await electronAPI.printPayslipForm({
|
||||
formHtml: htmlContent,
|
||||
printerName: 'GP-80250 Series'
|
||||
});
|
||||
|
||||
console.log('[Print] Результат:', result);
|
||||
|
||||
if (result && result.success === false) {
|
||||
throw new Error(result.error || 'Ошибка печати');
|
||||
}
|
||||
|
||||
setPrintStatus('success');
|
||||
setPrintMessage(`✅ Документ отправлен на печать`);
|
||||
} else {
|
||||
console.log('[Print] Electron API не доступен');
|
||||
setPrintStatus('error');
|
||||
setPrintMessage('❌ Electron API не доступен');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Print] Ошибка:', error);
|
||||
setPrintStatus('error');
|
||||
setPrintMessage('❌ Ошибка: ' + (error instanceof Error ? error.message : 'Неизвестная ошибка'));
|
||||
} finally {
|
||||
setIsPrinting(false);
|
||||
setTimeout(() => {
|
||||
setPrintStatus('idle');
|
||||
setPrintMessage('');
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.6)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
zIndex: 9999,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '20px',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, y: 30 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
exit={{ scale: 0.9, y: 30 }}
|
||||
transition={{ type: 'spring', damping: 25 }}
|
||||
style={{
|
||||
background: '#fff',
|
||||
borderRadius: '16px',
|
||||
width: '100%',
|
||||
maxWidth: '500px',
|
||||
maxHeight: '90vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Заголовок */}
|
||||
<div style={{
|
||||
padding: '16px 24px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<span style={{ fontSize: '20px' }}>📄</span>
|
||||
<span style={{ color: '#fff', fontSize: '16px', fontWeight: 500 }}>
|
||||
Расчетный листок
|
||||
</span>
|
||||
<span style={{
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
fontSize: '12px',
|
||||
background: 'rgba(255,255,255,0.15)',
|
||||
padding: '2px 10px',
|
||||
borderRadius: '12px',
|
||||
}}>
|
||||
{tnom}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.2)',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 12px',
|
||||
fontSize: '20px',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255,255,255,0.3)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255,255,255,0.2)';
|
||||
}}
|
||||
>
|
||||
<FaTimes />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Контент */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
padding: '20px',
|
||||
background: '#f5f6fa',
|
||||
}}>
|
||||
{loading ? (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '40px',
|
||||
gap: '15px',
|
||||
}}>
|
||||
<FaSpinner style={{ animation: 'spin 1s linear infinite', fontSize: '32px', color: '#667eea' }} />
|
||||
<span style={{ color: '#636e72' }}>Загрузка формы...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '40px',
|
||||
color: '#e74c3c',
|
||||
}}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '10px' }}>❌</div>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
background: '#fff',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
|
||||
overflow: 'auto',
|
||||
maxHeight: '60vh',
|
||||
}}>
|
||||
{/* ✅ Показываем HTML-форму как есть */}
|
||||
<div dangerouslySetInnerHTML={{ __html: htmlContent }} />
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={htmlContent}
|
||||
style={{ display: 'none' }}
|
||||
title="Print Frame"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Кнопка печати */}
|
||||
<div style={{
|
||||
padding: '16px 24px',
|
||||
borderTop: '1px solid #e9ecef',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<button
|
||||
onClick={handlePrint}
|
||||
disabled={isPrinting || !!error || !htmlContent}
|
||||
style={{
|
||||
padding: '12px 40px',
|
||||
background: isPrinting || error || !htmlContent ? '#b2bec3' : 'linear-gradient(135deg, #00b894, #00cec9)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '10px',
|
||||
cursor: isPrinting || error || !htmlContent ? 'not-allowed' : 'pointer',
|
||||
fontSize: '16px',
|
||||
fontWeight: 500,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
transition: 'all 0.3s ease',
|
||||
boxShadow: isPrinting || error || !htmlContent ? 'none' : '0 4px 15px rgba(0, 206, 201, 0.3)',
|
||||
opacity: isPrinting || error || !htmlContent ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{isPrinting ? (
|
||||
<>
|
||||
<FaSpinner style={{ animation: 'spin 1s linear infinite' }} />
|
||||
Печать...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FaPrint />
|
||||
Печать
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</motion.div>
|
||||
|
||||
{/* Уведомление о статусе печати */}
|
||||
<AnimatePresence>
|
||||
{printStatus !== 'idle' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -50 }}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '20px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 99999,
|
||||
padding: '16px 32px',
|
||||
borderRadius: '12px',
|
||||
background: printStatus === 'success' ? '#10b981' :
|
||||
printStatus === 'error' ? '#ef4444' :
|
||||
'#f59e0b',
|
||||
color: 'white',
|
||||
boxShadow: '0 10px 40px rgba(0,0,0,0.2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
fontSize: '16px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{printStatus === 'sending' && <FaSpinner style={{ animation: 'spin 1s linear infinite' }} />}
|
||||
{printStatus === 'success' && <FaCheckCircle />}
|
||||
{printStatus === 'error' && <FaExclamationCircle />}
|
||||
{printMessage}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PayslipFormPreview;
|
||||
@@ -0,0 +1,106 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface PayslipFormPrintProps {
|
||||
htmlContent: string;
|
||||
onPrintComplete: () => void;
|
||||
printerName?: string;
|
||||
}
|
||||
|
||||
const PayslipFormPrint: React.FC<PayslipFormPrintProps> = ({
|
||||
htmlContent,
|
||||
onPrintComplete,
|
||||
printerName = 'GP-80250 Series'
|
||||
}) => {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const printIframe = async () => {
|
||||
try {
|
||||
// ✅ Используем (window as any) для обхода типов
|
||||
const electronAPI = (window as any).electronAPI;
|
||||
|
||||
if (electronAPI && electronAPI.printPayslipForm) {
|
||||
console.log('[Print] Печать через Electron API');
|
||||
await electronAPI.printPayslipForm({
|
||||
formHtml: htmlContent,
|
||||
printerName: printerName
|
||||
});
|
||||
console.log('[Print] Форма отправлена на печать');
|
||||
} else {
|
||||
console.log('[Print] Electron API не доступен, используем fallback');
|
||||
const iframe = iframeRef.current;
|
||||
if (iframe) {
|
||||
iframe.onload = () => {
|
||||
setTimeout(() => {
|
||||
iframe.contentWindow?.print();
|
||||
}, 500);
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[PayslipFormPrint] Ошибка:', error);
|
||||
} finally {
|
||||
setTimeout(onPrintComplete, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
printIframe();
|
||||
}, [htmlContent, printerName, onPrintComplete]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.5 }}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
zIndex: 9999,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
background: '#fff',
|
||||
padding: '40px',
|
||||
borderRadius: '16px',
|
||||
textAlign: 'center',
|
||||
maxWidth: '400px',
|
||||
}}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🖨️</div>
|
||||
<h3 style={{ margin: '0 0 8px 0', color: '#2d3436' }}>Печать...</h3>
|
||||
<p style={{ margin: 0, color: '#636e72', fontSize: '14px' }}>
|
||||
Документ отправляется на печать
|
||||
</p>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
margin: '20px auto 0',
|
||||
border: '4px solid #f3f3f3',
|
||||
borderTop: '4px solid #667eea',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
}} />
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={htmlContent}
|
||||
style={{ display: 'none' }}
|
||||
title="Print Frame"
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PayslipFormPrint;
|
||||
+767
-597
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
|
||||
import { FaChevronLeft, FaChevronRight, FaIdCard, FaKeyboard } from 'react-icons/fa';
|
||||
|
||||
interface VideoBackgroundProps {
|
||||
onComplete: () => void;
|
||||
@@ -19,20 +19,21 @@ interface Slide {
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
const VideoBackground: React.FC<VideoBackgroundProps> = ({
|
||||
onComplete,
|
||||
videoSrc = '/assets/videos/flag.mp4'
|
||||
const ADMIN_SERVER_URL = 'http://localhost:3005';
|
||||
|
||||
const VideoBackground: React.FC<VideoBackgroundProps> = ({
|
||||
onComplete,
|
||||
videoSrc = '/assets/videos/flag.mp4',
|
||||
}) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [slides, setSlides] = useState<Slide[]>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAutoPlay, setIsAutoPlay] = useState(true);
|
||||
const [showHint, setShowHint] = useState(false);
|
||||
const autoPlayInterval = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const ADMIN_SERVER_URL = 'http://localhost:3005';
|
||||
|
||||
// ✅ Загрузка слайдов с сервера модератора
|
||||
// ---------- Загрузка слайдов ----------
|
||||
useEffect(() => {
|
||||
const fetchSlides = async () => {
|
||||
try {
|
||||
@@ -40,151 +41,120 @@ const VideoBackground: React.FC<VideoBackgroundProps> = ({
|
||||
const response = await fetch(`${ADMIN_SERVER_URL}/moders/content/slides`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const sortedSlides = Array.isArray(data)
|
||||
? data.sort((a: Slide, b: Slide) => (a.order || 0) - (b.order || 0))
|
||||
const sorted = Array.isArray(data)
|
||||
? data
|
||||
.filter((s: Slide) => s.is_active !== 0)
|
||||
.sort((a: Slide, b: Slide) => (a.order || 0) - (b.order || 0))
|
||||
: [];
|
||||
setSlides(sortedSlides);
|
||||
setSlides(sorted.length ? sorted : getDefaultSlides());
|
||||
} else {
|
||||
console.warn('Не удалось загрузить слайды, используем тестовые');
|
||||
setSlides(getDefaultSlides());
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Ошибка загрузки слайдов:', error);
|
||||
} catch {
|
||||
setSlides(getDefaultSlides());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSlides();
|
||||
}, []);
|
||||
|
||||
// ✅ Тестовые слайды (если модератор не настроен)
|
||||
const getDefaultSlides = (): Slide[] => {
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
image_url: 'https://picsum.photos/1920/1080?random=1',
|
||||
title: 'Добро пожаловать!',
|
||||
description: 'Инфокиоск для сотрудников',
|
||||
order: 1,
|
||||
is_active: 1
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
image_url: 'https://picsum.photos/1920/1080?random=2',
|
||||
title: 'Новости предприятия',
|
||||
description: 'Будьте в курсе событий',
|
||||
order: 2,
|
||||
is_active: 1
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
image_url: 'https://picsum.photos/1920/1080?random=3',
|
||||
title: 'С днем рождения!',
|
||||
description: 'Поздравляем именинников',
|
||||
order: 3,
|
||||
is_active: 1
|
||||
},
|
||||
];
|
||||
};
|
||||
const getDefaultSlides = (): Slide[] => [
|
||||
{ id: 1, image_url: 'https://picsum.photos/1920/1080?random=1', title: 'Добро пожаловать!', description: 'Инфокиоск для сотрудников', order: 1, is_active: 1 },
|
||||
{ id: 2, image_url: 'https://picsum.photos/1920/1080?random=2', title: 'Новости предприятия', description: 'Будьте в курсе событий', order: 2, is_active: 1 },
|
||||
{ id: 3, image_url: 'https://picsum.photos/1920/1080?random=3', title: 'С днём рождения!', description: 'Поздравляем именинников', order: 3, is_active: 1 },
|
||||
];
|
||||
|
||||
// ✅ Видео плеер
|
||||
// ---------- Видео ----------
|
||||
useEffect(() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.play().catch(err => {
|
||||
console.warn('Видео не воспроизвелось автоматически:', err);
|
||||
setTimeout(onComplete, 5000);
|
||||
videoRef.current.play().catch(() => {
|
||||
// если видео не проигрывается (нет файла) — ничего не делаем,
|
||||
// пользователь сам нажмёт кнопку входа
|
||||
});
|
||||
}
|
||||
}, [onComplete]);
|
||||
}, []);
|
||||
|
||||
// ✅ Автопрокрутка слайдов
|
||||
useEffect(() => {
|
||||
if (slides.length <= 1 || !isAutoPlay) return;
|
||||
|
||||
autoPlayInterval.current = setInterval(() => {
|
||||
goToNext();
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
if (autoPlayInterval.current) {
|
||||
clearInterval(autoPlayInterval.current);
|
||||
}
|
||||
};
|
||||
}, [slides.length, isAutoPlay, currentIndex]);
|
||||
|
||||
// ✅ Навигация
|
||||
// ---------- Навигация ----------
|
||||
const goToNext = () => {
|
||||
setCurrentIndex((prev) => (prev + 1) % slides.length);
|
||||
if (slides.length <= 1) return;
|
||||
setCurrentIndex((p) => (p + 1) % slides.length);
|
||||
};
|
||||
|
||||
const goToPrev = () => {
|
||||
setCurrentIndex((prev) => (prev - 1 + slides.length) % slides.length);
|
||||
if (slides.length <= 1) return;
|
||||
setCurrentIndex((p) => (p - 1 + slides.length) % slides.length);
|
||||
};
|
||||
|
||||
const goToSlide = (index: number) => {
|
||||
setCurrentIndex(index);
|
||||
resetAutoPlay();
|
||||
};
|
||||
|
||||
const resetAutoPlay = () => {
|
||||
if (autoPlayInterval.current) {
|
||||
clearInterval(autoPlayInterval.current);
|
||||
}
|
||||
if (slides.length > 1) {
|
||||
autoPlayInterval.current = setInterval(() => {
|
||||
goToNext();
|
||||
}, 5000);
|
||||
if (autoPlayInterval.current) clearInterval(autoPlayInterval.current);
|
||||
if (slides.length > 1 && isAutoPlay) {
|
||||
autoPlayInterval.current = setInterval(goToNext, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- Автопрокрутка ----------
|
||||
useEffect(() => {
|
||||
if (slides.length <= 1 || !isAutoPlay) return;
|
||||
autoPlayInterval.current = setInterval(goToNext, 5000);
|
||||
return () => {
|
||||
if (autoPlayInterval.current) clearInterval(autoPlayInterval.current);
|
||||
};
|
||||
}, [slides.length, isAutoPlay, currentIndex]);
|
||||
|
||||
// ---------- Клавиатура ----------
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); goToPrev(); }
|
||||
else if (e.key === 'ArrowRight') { e.preventDefault(); goToNext(); }
|
||||
else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onComplete(); }
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onComplete, slides.length]);
|
||||
|
||||
// ---------- Ховер по краям — пауза автопрокрутки ----------
|
||||
const handleMouseEnter = () => {
|
||||
setIsAutoPlay(false);
|
||||
if (autoPlayInterval.current) {
|
||||
clearInterval(autoPlayInterval.current);
|
||||
}
|
||||
if (autoPlayInterval.current) clearInterval(autoPlayInterval.current);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setIsAutoPlay(true);
|
||||
resetAutoPlay();
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
// ---------- Переход к вводу ID ----------
|
||||
const handleEnterClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onComplete();
|
||||
};
|
||||
|
||||
// ---------- Загрузка ----------
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 9999,
|
||||
background: '#000',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
background: '#000', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
width: 60, height: 60,
|
||||
border: '4px solid #2d2d2d',
|
||||
borderTop: '4px solid #667eea',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
}} />
|
||||
<style>{`@keyframes spin { from{transform:rotate(0)} to{transform:rotate(360deg)} }`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasSlides = slides.length > 0;
|
||||
const current = hasSlides ? slides[currentIndex] : null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@@ -193,63 +163,36 @@ const VideoBackground: React.FC<VideoBackgroundProps> = ({
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 9999,
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
background: '#000',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* ✅ ВИДЕО (фон) */}
|
||||
{/* ============ ВИДЕО ФОН ============ */}
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoSrc}
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
loop
|
||||
autoPlay muted playsInline loop
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
zIndex: 0,
|
||||
position: 'absolute', width: '100%', height: '100%',
|
||||
objectFit: 'cover', zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ✅ ТЕМНЫЙ ОВЕРЛЕЙ */}
|
||||
{/* ============ ТЁМНЫЙ ОВЕРЛЕЙ ============ */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.35)',
|
||||
zIndex: 1,
|
||||
position: 'absolute', inset: 0,
|
||||
background: 'rgba(0, 0, 0, 0.45)', zIndex: 1,
|
||||
}} />
|
||||
|
||||
{/* ✅ КАРУСЕЛЬ ИЗОБРАЖЕНИЙ */}
|
||||
{/* ============ КАРУСЕЛЬ ============ */}
|
||||
{hasSlides && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'absolute', inset: 0, zIndex: 2,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
@@ -259,9 +202,9 @@ const VideoBackground: React.FC<VideoBackgroundProps> = ({
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
style={{
|
||||
width: '80%',
|
||||
width: '78%',
|
||||
maxWidth: '900px',
|
||||
maxHeight: '70vh',
|
||||
maxHeight: '65vh',
|
||||
borderRadius: '20px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 30px 80px rgba(0,0,0,0.5)',
|
||||
@@ -271,191 +214,154 @@ const VideoBackground: React.FC<VideoBackgroundProps> = ({
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`${ADMIN_SERVER_URL}${slides[currentIndex].image_url}`}
|
||||
alt={slides[currentIndex].title || 'Слайд'}
|
||||
src={
|
||||
current!.image_url.startsWith('http')
|
||||
? current!.image_url
|
||||
: `${ADMIN_SERVER_URL}${current!.image_url}`
|
||||
}
|
||||
alt={current!.title || 'Слайд'}
|
||||
crossOrigin="anonymous"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
maxHeight: '70vh',
|
||||
maxHeight: '65vh',
|
||||
objectFit: 'cover',
|
||||
display: 'block',
|
||||
}}
|
||||
onError={(e) => {
|
||||
console.error('❌ Ошибка загрузки:', `${ADMIN_SERVER_URL}${slides[currentIndex].image_url}`);
|
||||
(e.target as HTMLImageElement).src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="100%25" height="100%25"%3E%3Crect width="100%25" height="100%25" fill="%232d3436"/%3E%3Ctext x="50%25" y="50%25" text-anchor="middle" dy=".3em" fill="%23636e72" font-size="24" font-family="sans-serif"%3EИзображение не найдено%3C/text%3E%3C/svg%3E';
|
||||
(e.target as HTMLImageElement).src =
|
||||
'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="100%25" height="100%25"%3E%3Crect width="100%25" height="100%25" fill="%232d3436"/%3E%3Ctext x="50%25" y="50%25" text-anchor="middle" dy=".3em" fill="%23636e72" font-size="24" font-family="sans-serif"%3EИзображение не найдено%3C/text%3E%3C/svg%3E';
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ✅ Текст поверх изображения */}
|
||||
{(slides[currentIndex].title || slides[currentIndex].description) && (
|
||||
{(current!.title || current!.description) && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
position: 'absolute', left: 0, right: 0, bottom: 0,
|
||||
padding: '30px 40px 40px',
|
||||
background: 'linear-gradient(transparent, rgba(0,0,0,0.7))',
|
||||
color: 'white',
|
||||
background: 'linear-gradient(transparent, rgba(0,0,0,0.75))',
|
||||
color: '#fff',
|
||||
}}>
|
||||
{slides[currentIndex].title && (
|
||||
{current!.title && (
|
||||
<h2 style={{
|
||||
margin: 0,
|
||||
fontSize: '28px',
|
||||
margin: 0, fontSize: '28px',
|
||||
fontWeight: 600,
|
||||
textShadow: '0 2px 10px rgba(0,0,0,0.3)',
|
||||
}}>
|
||||
{slides[currentIndex].title}
|
||||
{current!.title}
|
||||
</h2>
|
||||
)}
|
||||
{slides[currentIndex].description && (
|
||||
{current!.description && (
|
||||
<p style={{
|
||||
margin: '8px 0 0',
|
||||
fontSize: '16px',
|
||||
opacity: 0.8,
|
||||
margin: '8px 0 0', fontSize: '16px',
|
||||
opacity: 0.85,
|
||||
textShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
||||
}}>
|
||||
{slides[currentIndex].description}
|
||||
{current!.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* ✅ КНОПКИ НАВИГАЦИИ
|
||||
{slides.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goToPrev();
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '20px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
width: '50px',
|
||||
height: '50px',
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '20px',
|
||||
transition: 'all 0.3s ease',
|
||||
zIndex: 10,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255,255,255,0.2)';
|
||||
e.currentTarget.style.transform = 'translateY(-50%) scale(1.1)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255,255,255,0.1)';
|
||||
e.currentTarget.style.transform = 'translateY(-50%) scale(1)';
|
||||
}}
|
||||
>
|
||||
<FaChevronLeft />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goToNext();
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '20px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
width: '50px',
|
||||
height: '50px',
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '20px',
|
||||
transition: 'all 0.3s ease',
|
||||
zIndex: 10,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255,255,255,0.2)';
|
||||
e.currentTarget.style.transform = 'translateY(-50%) scale(1.1)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255,255,255,0.1)';
|
||||
e.currentTarget.style.transform = 'translateY(-50%) scale(1)';
|
||||
}}
|
||||
>
|
||||
<FaChevronRight />
|
||||
</button>
|
||||
</>
|
||||
)} */}
|
||||
|
||||
{/* ✅ ИНДИКАТОРЫ СЛАЙДОВ */}
|
||||
{slides.length > 1 && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '30px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
zIndex: 10,
|
||||
}}>
|
||||
{slides.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goToSlide(index);
|
||||
}}
|
||||
style={{
|
||||
width: index === currentIndex ? '12px' : '8px',
|
||||
height: '8px',
|
||||
borderRadius: '4px',
|
||||
background: index === currentIndex
|
||||
? 'rgba(255,255,255,0.9)'
|
||||
: 'rgba(255,255,255,0.3)',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
padding: 0,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ✅ ПОДСКАЗКА */}
|
||||
{/* ============ КНОПКИ НАВИГАЦИИ ============ */}
|
||||
{slides.length > 1 && (
|
||||
<>
|
||||
<NavArrow direction="left" onClick={goToPrev} />
|
||||
<NavArrow direction="right" onClick={goToNext} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ============ ИНДИКАТОРЫ ============ */}
|
||||
{slides.length > 1 && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '120px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
zIndex: 10,
|
||||
}}>
|
||||
{slides.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={(e) => { e.stopPropagation(); goToSlide(index); }}
|
||||
aria-label={`Слайд ${index + 1}`}
|
||||
style={{
|
||||
width: index === currentIndex ? '28px' : '10px',
|
||||
height: '10px',
|
||||
borderRadius: '5px',
|
||||
background: index === currentIndex
|
||||
? 'rgba(255,255,255,0.9)'
|
||||
: 'rgba(255,255,255,0.35)',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
padding: 0,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ============ КНОПКА ВХОДА ============ */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '20px',
|
||||
left: 0,
|
||||
right: 0,
|
||||
textAlign: 'center',
|
||||
color: 'rgba(255,255,255,0.3)',
|
||||
fontSize: '13px',
|
||||
zIndex: 3,
|
||||
textShadow: '0 2px 4px rgba(0,0,0,0.5)',
|
||||
letterSpacing: '1px',
|
||||
pointerEvents: 'none',
|
||||
bottom: '40px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 20,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
}}>
|
||||
{hasSlides ? 'Нажмите для входа · Для просмотра используйте стрелки' : 'Нажмите для продолжения'}
|
||||
<motion.button
|
||||
onClick={handleEnterClick}
|
||||
whileHover={{ scale: 1.04 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '16px 34px',
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: '#fff',
|
||||
fontSize: '17px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 10px 30px rgba(102, 126, 234, 0.45)',
|
||||
letterSpacing: '0.3px',
|
||||
}}
|
||||
>
|
||||
<FaIdCard style={{ fontSize: 20 }} />
|
||||
Ввод табельного номера
|
||||
</motion.button>
|
||||
|
||||
{/* Подсказка «Enter» (появляется при наведении) */}
|
||||
<div
|
||||
onMouseEnter={() => setShowHint(true)}
|
||||
onMouseLeave={() => setShowHint(false)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
fontSize: 12,
|
||||
transition: 'opacity 0.2s',
|
||||
opacity: showHint ? 1 : 0.55,
|
||||
}}
|
||||
>
|
||||
<FaKeyboard /> или нажмите <b style={{ margin: '0 4px' }}>Enter</b>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ============ СТИЛИ ============ */}
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
@@ -466,4 +372,56 @@ const VideoBackground: React.FC<VideoBackgroundProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Кнопка-стрелка (вынесена отдельно, чтобы не дублировать стили)
|
||||
// ============================================================
|
||||
const NavArrow: React.FC<{
|
||||
direction: 'left' | 'right';
|
||||
onClick: () => void;
|
||||
}> = ({ direction, onClick }) => {
|
||||
const isLeft = direction === 'left';
|
||||
|
||||
return (
|
||||
// ── Позиционирование — на обычном div, БЕЗ framer-motion ──
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
[isLeft ? 'left' : 'right']: '25px',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{/* ── Анимация — только scale, без translate ── */}
|
||||
<motion.button
|
||||
onClick={(e) => { e.stopPropagation(); onClick(); }}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
|
||||
aria-label={isLeft ? 'Предыдущий слайд' : 'Следующий слайд'}
|
||||
style={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.12)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
WebkitBackdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 22,
|
||||
transition: 'background 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,0.25)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,0.12)')}
|
||||
>
|
||||
{isLeft ? <FaChevronLeft /> : <FaChevronRight />}
|
||||
</motion.button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoBackground;
|
||||
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
// src/types/electron.d.ts
|
||||
|
||||
interface ElectronAPI {
|
||||
getAppConfig: () => Promise<any>;
|
||||
onCardDetected: (callback: (uid: string) => void) => void;
|
||||
removeCardDetectedListeners: () => void;
|
||||
printPdf: (options: { pdfUrl: string; deviceName?: string }) => Promise<void>;
|
||||
getPrinters: () => Promise<Array<{ name: string; displayName: string; isDefault: boolean; status: string }>>;
|
||||
printPayslipForm: (data: { formHtml: string; printerName?: string }) => Promise<void>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI?: ElectronAPI;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,59 @@
|
||||
// ============================================
|
||||
// 📄 ВСПОМОГАТЕЛЬ ДЛЯ ПЕЧАТИ
|
||||
// ============================================
|
||||
|
||||
import { ipcRenderer } from 'electron';
|
||||
|
||||
/**
|
||||
* Печать формы расчетного листка через основной процесс
|
||||
*/
|
||||
export async function printPayslipForm(html: string, printerName?: string) {
|
||||
try {
|
||||
// ✅ ПЫТАЕМСЯ НАПРЯМУЮ ПЕЧАТЬ HTML
|
||||
try {
|
||||
const result = await ipcRenderer.invoke('print-html-direct', {
|
||||
html,
|
||||
printerName: printerName || 'GP-80250 Series',
|
||||
customCss: `
|
||||
.header { font-size: 10pt !important; }
|
||||
.item-row { font-size: 7pt !important; }
|
||||
.total-row { font-size: 9pt !important; }
|
||||
.info-row { font-size: 7pt !important; }
|
||||
.footer { font-size: 6pt !important; }
|
||||
`
|
||||
});
|
||||
|
||||
if (result) {
|
||||
console.log('[PRINT] Успешно напечатано через прямой HTML');
|
||||
return result;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[PRINT] Прямая печать не удалась, пробуем через PDF:', e);
|
||||
}
|
||||
|
||||
// ✅ ЕСЛИ НЕ ПОЛУЧИЛОСЬ - ПЕЧАТАЕМ ЧЕРЕЗ PDF
|
||||
const result = await ipcRenderer.invoke('print-payslip-form', {
|
||||
formHtml: html,
|
||||
printerName: printerName || 'GP-80250 Series'
|
||||
});
|
||||
|
||||
console.log('[PRINT] Успешно напечатано через PDF');
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error('[PRINT] Ошибка печати:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение списка принтеров
|
||||
*/
|
||||
export async function getPrinters() {
|
||||
try {
|
||||
return await ipcRenderer.invoke('get-printers');
|
||||
} catch (error) {
|
||||
console.error('[PRINT] Ошибка получения принтеров:', error);
|
||||
return [{ name: 'GP-80250 Series', displayName: 'GP-80250 Series' }];
|
||||
}
|
||||
}
|
||||
Generated
+273
@@ -0,0 +1,273 @@
|
||||
{
|
||||
"name": "electron",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"pdfmake": "^0.3.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/ciphers": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
|
||||
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.23",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
||||
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
|
||||
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/brotli": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
|
||||
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/brotli/node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/browserify-zlib": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
|
||||
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "~1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/clone": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/dfa": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fontkit": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
||||
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.12",
|
||||
"brotli": "^1.3.2",
|
||||
"clone": "^2.1.2",
|
||||
"dfa": "^1.2.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"restructure": "^3.0.0",
|
||||
"tiny-inflate": "^1.0.3",
|
||||
"unicode-properties": "^1.4.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/js-md5": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
|
||||
"integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/linebreak": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
||||
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "0.0.8",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/pdfkit": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz",
|
||||
"integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^1.0.0",
|
||||
"@noble/hashes": "^1.6.0",
|
||||
"fontkit": "^2.0.4",
|
||||
"js-md5": "^0.8.3",
|
||||
"linebreak": "^1.1.0",
|
||||
"png-js": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfmake": {
|
||||
"version": "0.3.11",
|
||||
"resolved": "https://registry.npmjs.org/pdfmake/-/pdfmake-0.3.11.tgz",
|
||||
"integrity": "sha512-Uc49J9hUMyuqJk+U+PxlpBpPr96A4HOOfesGx609EPr2ue82+5/Smq/KTAkEqh0/jUGSi1fumvqZ5yAWijJTJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"linebreak": "^1.1.0",
|
||||
"pdfkit": "^0.19.1",
|
||||
"xmldoc": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/png-js": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz",
|
||||
"integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==",
|
||||
"dependencies": {
|
||||
"browserify-zlib": "^0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/restructure": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
|
||||
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
|
||||
"integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-inflate": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/unicode-properties": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
||||
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-properties/node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicode-trie": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^0.2.5",
|
||||
"tiny-inflate": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-trie/node_modules/pako": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xmldoc": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/xmldoc/-/xmldoc-2.0.3.tgz",
|
||||
"integrity": "sha512-6gRk4NY/Jvg67xn7OzJuxLRsGgiXBaPUQplVJ/9l99uIugxh4FTOewYz5ic8WScj7Xx/2WvhENiQKwkK9RpE4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sax": "^1.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"pdfmake": "^0.3.11"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user