food
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user