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
+460
View File
@@ -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) => ({
'&': '&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>