Files
electron/admin-server1/views/food-orders.html
T
2026-10-29 16:51:26 +03:00

460 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[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>