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