This commit is contained in:
2026-10-29 16:51:26 +03:00
parent 70a20665ae
commit 4b2f0903b1
51 changed files with 6540 additions and 1212 deletions
+1
View File
@@ -21,3 +21,4 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*
/node_modules
+7 -1
View File
@@ -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;