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 = { 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 = ({ onBack, employee }) => { const tnom = employee?.tnom || ''; const fullName = employee ? `${employee.famaly || ''} ${employee.ima || ''} ${employee.otch || ''}`.trim() : 'Сотрудник'; const [company, setCompany] = useState('АзотСервис'); const [categories, setCategories] = useState([]); const [activeCat, setActiveCat] = useState(null); const [balance, setBalance] = useState(0); const [cart, setCart] = useState>({}); const [expandedItem, setExpandedItem] = useState(null); const [imageErrors, setImageErrors] = useState>({}); const [loading, setLoading] = useState(true); const [sending, setSending] = useState(false); const [view, setView] = useState('menu'); const [isCartOpen, setIsCartOpen] = useState(false); const [successOrder, setSuccessOrder] = useState<{ id: number; total: number } | null>(null); const [error, setError] = useState(null); // Состояния для "Мои заказы" const [showMyOrders, setShowMyOrders] = useState(false); const [myOrders, setMyOrders] = useState([]); 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 = {}; 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 (
); } if (view === 'success' && successOrder) { return (

Заказ оформлен!

Номер заказа: #{successOrder.id}

Сумма: {successOrder.total.toFixed(2)} BYN

); } const entries = Object.entries(cart); const notEnough = cartTotal > balance; return (
{/* ============ ШАПКА ============ */}
{/* 1-я строка: Кнопка Назад (10%) + Название столовой + Мои заказы */}
{company}
{/* Кнопка Мои заказы */}
{/* 2-я строка: Сотрудник + Баланс */}
{getInitials(fullName) || '👤'}
Сотрудник
{fullName}
Баланс
{balance.toFixed(2)} BYN
{/* ============ ОСНОВНАЯ ЗОНА ============ */}
{/* Вертикальная колонка категорий */}
КАТЕГОРИИ
{categories.map((c) => { const isActive = c.id === activeCat; return ( ); })}
{/* Сетка блюд */}
{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 (
{ if (qty === 0) { changeQty(it.id, 1); setExpandedItem(it.id); } }} >
{it.imageUrl && !imageErrors[it.id] ? ( {it.name} { setImageErrors(prev => ({ ...prev, [it.id]: true })); }} /> ) : ( emoji )}
{it.name}
{it.description &&
{it.description}
}
{it.price.toFixed(2)} BYN
{isExpanded && ( e.stopPropagation()} > {qty} )}
); })}
{/* ============ ПЛАВАЮЩАЯ ПЛАШКА КОРЗИНЫ ============ */} {cartCount > 0 && ( setIsCartOpen(true)} >
{cartCount} поз.
{cartTotal.toFixed(2)} BYN
→
)}
{/* ============ ВЫЕЗЖАЮЩАЯ КОРЗИНА СПРАВА ============ */} {isCartOpen && ( <> setIsCartOpen(false)} />

Ваш заказ

{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 (
{it.imageUrl && !imageErrors[it.id] ? ( {it.name} { setImageErrors(prev => ({ ...prev, [it.id]: true })); }} /> ) : ( emoji )}
{it.name}
{it.price.toFixed(2)} BYN
{qty}
{(it.price * qty).toFixed(2)}
); })}
{error &&
{error}
}
Итого:
{cartTotal.toFixed(2)} BYN
{notEnough &&
Недостаточно средств (баланс {balance.toFixed(2)})
}
)}
{/* ============ ВЫЕЗЖАЮЩАЯ ПАНЕЛЬ "МОИ ЗАКАЗЫ" ============ */} {showMyOrders && ( <> setShowMyOrders(false)} />

Мои заказы

{loadingOrders ? (
Загрузка...
) : myOrders.length === 0 ? (
У вас пока нет заказов
) : ( 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 (
Заказ #{order.id}
{statusText}
{new Date(order.createdAt).toLocaleString('ru-RU')}
    {order.items.map((it: any, i: number) => (
  • {it.name} x{it.quantity}
  • ))}
{order.totalAmount.toFixed(2)} BYN
{order.status === 'new' && ( )}
); }) )}
)}
{/* ============ МИНИ-МОДАЛКА ПОДТВЕРЖДЕНИЯ ОТМЕНЫ ============ */} {cancelConfirm && (

Отменить заказ?

Заказ #{cancelConfirm.id} будет отменён.
{cancelConfirm.total.toFixed(2)} BYN вернутся на баланс.

)}
); }; // ============================================================ // СТИЛИ (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;