Files
2026-10-11 22:24:54 +05:00

3871 lines
190 KiB
JavaScript
Raw Permalink 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.
/* ============================================================
НЕОН CRM — клиент рабочего места (PostgreSQL + REST API)
Данные только с сервера; офлайн-режима у продакшн-CRM нет.
============================================================ */
const TOKEN_KEY = 'neon-crm-token';
const STATUS = {
new: { label: 'Новый', cls: 'b-new' },
active: { label: 'В работе', cls: 'b-active' },
done: { label: 'Выдан', cls: 'b-done' },
cancelled: { label: 'Отменён', cls: 'b-cancel' },
};
const state = {
role: null, // 'owner' | 'admin' | 'waiter' | 'hookah' — с сервера
login: '', // логин текущего пользователя
name: '', // имя сотрудника
view: null,
menuQuery: '',
menuCat: 'Все',
menuSort: 'default', // сортировка меню (владелец): default | name | price_asc | price_desc | category
tobaccoQuery: '',
tobBrand: 'Все',
tobaccoSort: 'default', // сортировка табаков: default | flavor | brand | strength_asc | strength_desc
mixes: [], // миксы для сайта (ведут владелец и кальянщик)
mixQuery: '', // поиск по вкусам в конструкторе микса
mixBrand: 'Все', // фильтр брендов в конструкторе микса
mixSort: 'default', // сортировка миксов: default | name | price_asc | price_desc | strength_asc | strength_desc
staffReport: null, // статистика сотрудников за период отчёта (только владелец)
// форма нового микса: крепость/холодок/цена/теги — расширенная информация,
// сайт покажет её в карточке и по тегам подмешает микс в автоподбор
newMix: { name: '', note: '', flavorIds: [], strength: '', cooling: '', price: '', tags: [] },
mixErrors: {}, // ошибки валидации формы микса: { поле: сообщение }
mixEditId: null, // id микса в форме редактирования (null — создание)
siteMixEditId: null, // id витринного микса сайта в форме правки (null — создание)
menuEditId: null, // id позиции меню в форме редактирования (null — создание)
orderFilter: 'all',
orders: [],
menu: [],
tobacco: [],
users: [], // сотрудники (только владелец)
shiftCounts: [], // количество смен у официантов (только владелец)
report: null, // последний построенный отчёт (только владелец)
reportFrom: '', // период отчёта, ГГГГ-ММ-ДД
reportTo: '',
onShiftList: [], // кто на смене сейчас
onShift: false, // текущий официант на смене?
staffList: [], // официанты и кальянщики с флагом on_shift (для владельца/админа)
halls: [], // занятость столов и зон (GET /api/halls)
resReminded: new Set(), // брони, о которых уже напомнили в этой сессии
games: [], // библиотека игр: PS5 и настолки
gameFilter: 'all', // 'all' | 'ps5' | 'board'
gameTrash: false, // показывать удалённые игры (корзина, админ/владелец)
gameEditId: null, // id игры в форме редактирования (null — создание)
gameForm: { kind: 'ps5', title: '', note: '', difficulty: 2, icon: 'dices', accent: '#b026ff' },
accentFor: '', // id позиции меню, для которой открыта палитра подсветки
siteMixes: [], // авторские миксы сайта (read-only, вкладка «Миксы»)
photoKind: 'menu', // что загружаем фото: 'menu' | 'game'
photoTarget: '', // id позиции, для которой выбирают фото
newOrder: { table: '', items: {}, comment: '' }, // ручной заказ: стол + {id: qty} + комментарий
newOrderError: '', // 'table' | 'items' — что не заполнено при попытке создать заказ
newOrderQuery: '',
newOrderCat: 'Все', // фильтр категорий в ручном заказе
lastOrderNumber: 0, // для уведомлений о новых заказах с сайта
boards: [], // доступные доски (GET /api/boards)
boardId: null, // выбранная доска на странице «Заметки»
notes: [], // заметки выбранной доски
notesSince: null, // курсор инкрементального опроса (server_time)
noteFilter: 'all', // 'all' | 'my'
noteEditId: null, // id задачи в форме (null — режим создания)
noteForm: { title: '', body: '', assignee: '', due: '', important: false },
boardFormOpen: false, // панель «новая приватная доска» (владелец)
boardActivity: [], // лента событий выбранной доски
};
const halls = [
{ name: 'Стол 1', sub: 'основной зал' },
{ name: 'Стол 2', sub: 'основной зал' },
{ name: 'Стол 3', sub: 'основной зал' },
{ name: 'Стол 4', sub: 'основной зал' },
{ name: 'Стол 5', sub: 'основной зал' },
{ name: 'VIP-зал', sub: 'до 8 гостей' },
{ name: 'Кинозал', sub: 'до 15 гостей' },
];
/* имена должны совпадать с HALLS в crm/storage.py — сервер валидирует по нему */
/* ---------------- утилиты ---------------- */
const pad2 = (n) => String(n).padStart(2, '0');
const money = (n) => (Number.isFinite(n) ? n.toLocaleString('ru-RU') : '—') + ' ₽';
/* время в локальной зоне: сервер может отдать UTC (смены SQLite) */
const fmtTime = (iso) => {
if (!iso) return '—';
const d = new Date(iso);
return isNaN(d) ? '—' : pad2(d.getHours()) + ':' + pad2(d.getMinutes());
};
const fmtDate = (iso) => {
if (!iso) return '—';
const d = new Date(iso);
return isNaN(d) ? '—' : pad2(d.getDate()) + '.' + pad2(d.getMonth() + 1);
};
const fmtDur = (sec) => {
if (sec == null) return '—';
if (sec < 60) return 'менее минуты';
const m = Math.round(sec / 60);
if (m < 60) return m + ' мин';
return Math.floor(m / 60) + ' ч ' + pad2(m % 60) + ' мин';
};
const plural = (n, forms) => {
const a = Math.abs(n) % 100, b = a % 10;
if (a > 10 && a < 20) return forms[2];
if (b > 1 && b < 5) return forms[1];
if (b === 1) return forms[0];
return forms[2];
};
const orderTotal = (o) => (o.price != null ? o.price : o.items.reduce((s, i) => s + i.price * i.qty, 0));
/* Состав заказов приходит с сайта — экранируем всё, что попадает в innerHTML */
const esc = (v) =>
String(v ?? '').replace(/[&<>"']/g, (ch) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch]
));
const NOVELTY_MS = 7 * 24 * 60 * 60 * 1000; // неделя для «новинок» владельца
const isNewItem = (createdAt) => {
if (!createdAt) return false;
const ts = new Date(createdAt).getTime();
return Number.isFinite(ts) && Date.now() - ts < NOVELTY_MS;
};
/* ---------------- API ---------------- */
async function api(path, opts = {}) {
const headers = { ...(opts.headers || {}) };
const token = localStorage.getItem(TOKEN_KEY);
if (token) headers['X-Auth-Token'] = token;
if (opts.body != null) headers['Content-Type'] = 'application/json';
const r = await fetch(path, { ...opts, headers });
if (r.status === 401 && state.role) {
// сессия истекла — на экран входа
logout(true);
throw new Error('session expired');
}
if (!r.ok) {
let msg = 'HTTP ' + r.status;
try {
const err = await r.json();
if (err && err.error) msg = err.error;
} catch { /* не JSON — оставляем код */ }
throw new Error(msg);
}
return r.json();
}
async function refreshOrders() {
state.orders = await api('/api/orders');
}
async function refreshHalls() {
try {
state.halls = await api('/api/halls');
checkReserveReminders();
} catch { /* сеть моргнула — покажем прошлые данные */ }
}
async function refreshCatalog() {
const [menu, tobacco] = await Promise.all([api('/api/menu'), api('/api/tobacco')]);
state.menu = menu;
state.tobacco = tobacco;
}
async function refreshMixes() {
try {
state.mixes = await api('/api/mixes');
await refreshSiteMixes();
} catch { /* сеть моргнула — покажем прошлые данные */ }
}
/* Заказы официантам: опрос раз в 10 с — новых с сайта узнаём по номеру */
function beep() {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = 880;
gain.gain.setValueAtTime(0.08, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.6);
osc.connect(gain).connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.6);
setTimeout(() => ctx.close(), 900);
} catch { /* звук не критичен */ }
}
async function pollOrders() {
if (!state.role) return;
let fresh = null;
try {
const orders = await api('/api/orders');
state.orders = orders;
// id — внутренний, монотонный: новые заказы узнаём по нему даже
// когда недельный номер после сброса меньше прежних
const maxId = orders.reduce((m, o) => Math.max(m, o.id), 0);
if (state.lastOrderNumber && maxId > state.lastOrderNumber) {
fresh = orders.find((o) => o.id === maxId);
}
state.lastOrderNumber = Math.max(state.lastOrderNumber, maxId);
} catch {
return; // сеть моргнула — попробуем в следующий тик
}
refreshShifts();
refreshHalls();
render();
if (fresh) {
beep();
toast(`🔔 Заказ №${fresh.number} — ${fresh.table}, ждёт подтверждения`, '');
}
}
/* ---------------- вход / роли ---------------- */
async function login(loginValue, password) {
let session;
try {
session = await api('/api/login', {
method: 'POST',
body: JSON.stringify({ login: loginValue, password }),
});
} catch (e) {
showLoginError(e.message === 'Failed to fetch'
? 'Сервер недоступен — проверь, что CRM запущена'
: e.message);
return;
}
localStorage.setItem(TOKEN_KEY, session.token);
state.role = session.role;
state.login = session.login;
state.name = session.name;
state.view = SHIFT_ROLES_JS.includes(session.role) ? 'orders' : 'dashboard';
document.getElementById('loginScreen').classList.add('hidden');
document.getElementById('app').classList.remove('hidden');
showLoginError('');
try {
state.lastOrderNumber = 0;
await Promise.all([refreshOrders(), refreshCatalog(), refreshShifts(), refreshHalls()]);
state.lastOrderNumber = state.orders.reduce((m, o) => Math.max(m, o.id), 0);
} catch (e) {
enterOffline(e.message);
return;
}
render();
toast(`Добро пожаловать, ${session.name}!`, '');
}
function logout(expired = false) {
const token = localStorage.getItem(TOKEN_KEY);
if (token && !expired) api('/api/logout', { method: 'POST' }).catch(() => {});
localStorage.removeItem(TOKEN_KEY);
state.role = null;
state.view = null;
state.orders = [];
state.menu = [];
state.tobacco = [];
document.getElementById('app').classList.add('hidden');
document.getElementById('loginScreen').classList.remove('hidden');
if (expired) showLoginError('Сессия истекла — войдите заново');
}
function enterOffline(reason) {
const btn = document.getElementById('offlineRetry');
btn.classList.remove('hidden');
toast('Нет связи с сервером: ' + reason, 't-red');
}
async function retryConnection() {
const btn = document.getElementById('offlineRetry');
btn.disabled = true;
try {
await Promise.all([refreshOrders(), refreshCatalog()]);
btn.classList.add('hidden');
render();
toast('Связь восстановлена', 't-green');
} catch {
toast('Сервер всё ещё недоступен', 't-red');
}
btn.disabled = false;
}
function showLoginError(text) {
const el = document.getElementById('loginError');
el.textContent = text || '';
el.classList.toggle('hidden', !text);
}
function toast(msg, type = '') {
const wrap = document.getElementById('toasts');
const el = document.createElement('div');
el.className = 'toast ' + type;
el.textContent = msg;
wrap.appendChild(el);
setTimeout(() => el.classList.add('out'), 2600);
setTimeout(() => el.remove(), 3000);
}
/* ---------------- модальные окна (подтверждение и ввод) ---------------- */
/* Заменяют нативные confirm()/prompt(): те не стилизуются под тёмную тему.
Окна живут в #modalOverlay вне viewRoot — опрос заказов их не закрывает. */
let modalActive = null; // открытое окно: { close, confirm }
let modalChain = Promise.resolve(); // очередь: окна открываются строго по одному
function uiModal(opts) {
const run = () => new Promise((resolve) => openModal(opts, resolve));
const p = modalChain.then(run, run);
modalChain = p.then(() => {}, () => {});
return p;
}
/* uiConfirm({ title, text, ok, cancel, danger }) → Promise<boolean> */
function uiConfirm(opts) {
if (typeof opts === 'string') opts = { text: opts };
return uiModal({ ...opts, confirm: true }).then((v) => v === true);
}
/* uiPrompt({ title, text, value, placeholder, numeric, ok, cancel }) → Promise<string|null> */
function uiPrompt(opts) {
if (typeof opts === 'string') opts = { text: opts };
return uiModal({ ...opts, confirm: false });
}
/* uiPassword({ title }) → Promise<{current, next}|null> — смена пароля (F-16) */
function uiPassword(opts) {
return uiModal({ ...opts, passwords: true, ok: 'Сменить пароль' });
}
/* uiNewPassword({ title, text, ok }) → Promise<string|null> — ввод одного
нового пароля без текущего (сброс пароля сотрудника владельцем) */
function uiNewPassword(opts) {
return uiModal({ ...opts, newPass: true, ok: opts.ok || 'Сбросить' });
}
function openModal(opts, resolve) {
const overlay = document.getElementById('modalOverlay');
const box = document.getElementById('modalBox');
const o = opts || {};
const danger = o.confirm && o.danger !== false; // подтверждения по умолчанию «опасные»
const okLabel = o.ok || (o.confirm ? 'Да' : 'ОК');
box.className = 'modal' + (danger ? ' danger' : '');
box.innerHTML = `
<div class="modal-title">${esc(o.title || (o.confirm ? 'Подтвердите действие' : 'Ввод'))}</div>
${o.text ? `<div class="modal-text">${esc(o.text)}</div>` : ''}
${o.newPass ? `<input type="password" class="modal-input" id="modalNew" placeholder="Новый пароль — минимум 8 символов" autocomplete="new-password">` :
o.confirm || !o.passwords ? `<input type="text" class="modal-input" id="modalInput" autocomplete="off" spellcheck="false"
value="${esc(o.value != null ? String(o.value) : '')}"
${o.placeholder ? `placeholder="${esc(o.placeholder)}"` : ''}
${o.numeric ? 'inputmode="numeric"' : ''}>` : `
<input type="password" class="modal-input" id="modalCur" placeholder="Текущий пароль" autocomplete="current-password">
<input type="password" class="modal-input" id="modalNew" placeholder="Новый пароль — минимум 8 символов" autocomplete="new-password">`}
<div class="modal-actions">
<button class="btn btn-ghost" type="button" data-modal-cancel>${esc(o.cancel || 'Отмена')}</button>
<button class="btn ${danger ? 'btn-danger' : 'btn-neon'}" type="button" data-modal-ok>${esc(okLabel)}</button>
</div>`;
overlay.classList.remove('hidden');
document.body.style.overflow = 'hidden';
const close = (v) => {
if (!modalActive || modalActive.resolve !== resolve) return;
modalActive = null;
overlay.classList.add('hidden');
document.body.style.overflow = '';
resolve(v);
};
modalActive = { close, confirm: !!o.confirm, passwords: !!o.passwords, newPass: !!o.newPass, resolve };
box.querySelector('[data-modal-ok]').addEventListener('click', () => {
if (o.passwords) {
close({ current: box.querySelector('#modalCur').value, next: box.querySelector('#modalNew').value });
} else if (o.newPass) {
close(box.querySelector('#modalNew').value);
} else {
close(o.confirm ? true : document.getElementById('modalInput').value);
}
});
box.querySelector('[data-modal-cancel]').addEventListener('click', () => close(null));
const inp = box.querySelector('#modalInput');
if (inp) { inp.focus(); inp.select(); }
else box.querySelector('#modalNew').focus();
}
(function initModalDom() {
const overlay = document.getElementById('modalOverlay');
if (!overlay) return;
overlay.addEventListener('mousedown', (e) => {
if (e.target === overlay && modalActive) modalActive.close(null);
});
document.addEventListener('keydown', (e) => {
if (!modalActive) return;
if (e.key === 'Escape') { e.preventDefault(); modalActive.close(null); }
else if (e.key === 'Enter') {
e.preventDefault();
if (modalActive.confirm) modalActive.close(true);
else if (modalActive.passwords) modalActive.close({
current: document.getElementById('modalCur').value,
next: document.getElementById('modalNew').value,
});
else if (modalActive.newPass) modalActive.close(document.getElementById('modalNew').value);
else modalActive.close(document.getElementById('modalInput').value);
}
});
})();
/* ---------------- каркас (сайдбар, шапка) ---------------- */
const NAV = {
owner: [
{ id: 'dashboard', label: 'Обзор', ico: '◈' },
{ id: 'neworder', label: 'Новый заказ', ico: '➕' },
{ id: 'halls', label: 'Залы', ico: '🪑' },
{ id: 'notes', label: 'Заметки', ico: '🗒' },
{ id: 'games', label: 'Игры', ico: '🎮' },
{ id: 'menu', label: 'Меню', ico: '☕' },
{ id: 'tobacco', label: 'Табаки', ico: '☁' },
{ id: 'mixes', label: 'Миксы', ico: '💨' },
{ id: 'orders', label: 'Заказы', ico: '🧾' },
{ id: 'users', label: 'Сотрудники', ico: '👥' },
{ id: 'reports', label: 'Отчёты', ico: '📊' },
],
admin: [
{ id: 'dashboard', label: 'Обзор', ico: '◈' },
{ id: 'neworder', label: 'Новый заказ', ico: '➕' },
{ id: 'halls', label: 'Залы', ico: '🪑' },
{ id: 'notes', label: 'Заметки', ico: '🗒' },
{ id: 'games', label: 'Игры', ico: '🎮' },
{ id: 'menu', label: 'Меню', ico: '☕' },
{ id: 'tobacco', label: 'Табаки', ico: '☁' },
{ id: 'orders', label: 'Заказы', ico: '🧾' },
],
waiter: [
{ id: 'neworder', label: 'Новый заказ', ico: '➕' },
{ id: 'halls', label: 'Залы', ico: '🪑' },
{ id: 'notes', label: 'Заметки', ico: '🗒' },
{ id: 'orders', label: 'Заказы', ico: '🧾' },
{ id: 'stoplist', label: 'Стоп-лист', ico: '⛔' },
],
hookah: [
{ id: 'neworder', label: 'Новый заказ', ico: '➕' },
{ id: 'halls', label: 'Залы', ico: '🪑' },
{ id: 'notes', label: 'Заметки', ico: '🗒' },
{ id: 'orders', label: 'Заказы', ico: '🧾' },
{ id: 'stoplist', label: 'Стоп-лист', ico: '⛔' },
{ id: 'tobacco', label: 'Табаки', ico: '☁' },
{ id: 'mixes', label: 'Миксы', ico: '💨' },
],
};
/* роли, работающие на смене: видят канбан заказов и выходят на смену */
const SHIFT_ROLES_JS = ['waiter', 'hookah'];
const ROLE_LABELS = { owner: 'Владелец', admin: 'Администратор', waiter: 'Официант', hookah: 'Кальянщик' };
const TITLES = {
dashboard: ['Обзор', 'Сводка по смене в реальном времени'],
menu: ['Меню · стоп-лист', 'Управление доступностью позиций меню. Позиция «в стопе» помечается на сайте «Нет в наличии» и недоступна к онлайн-заказу'],
tobacco: ['Табаки для кальянов · стоп-лист', 'Отключение отдельных вкусов — гость не сможет выбрать их в конструкторе кальяна, на сайте вкус помечается «Закончился»'],
orders: ['Заказы', 'Все заказы зала — статусы, состав, выручка смены'],
stoplist: ['Стоп-лист', 'Позиции, недоступные к заказу прямо сейчас (управляет администратор)'],
users: ['Сотрудники', 'Официанты и администраторы — создание и удаление. Доступно только владельцу'],
reports: ['Отчёты по заказам', 'Выручка, заказы и топ позиций за выбранный период — из базы данных'],
neworder: ['Новый заказ', 'Заказ из зала вручную: стол, кальяны и позиции меню — кальяны добавляет кальянщик'],
halls: ['Залы и столы', 'Занятость столов и зон: свободно/занято, таймер до освобождения'],
mixes: ['Миксы', 'Фирменные миксы для страницы «Миксы НЕОНа» на сайте — название и вкусы, пропорции делает кальянщик'],
notes: ['Заметки и задачи', 'Общая доска и приватные доски владельца с админами. Доступны в любое время — без привязки к смене'],
games: ['Игры', 'Библиотека бара: PS5 и настолки. Что есть — сразу видно, гостям предлагайте свободные'],
};
function renderChrome() {
const roleLabel = ROLE_LABELS[state.role] || state.role;
document.getElementById('userName').textContent = state.name;
document.getElementById('userRole').textContent = roleLabel;
document.getElementById('userAvatar').textContent = (state.name || '?').slice(0, 1);
document.getElementById('brandRole').textContent = roleLabel;
const newCount = state.orders.filter((o) => o.status === 'new').length;
const nav = document.getElementById('nav');
nav.innerHTML = NAV[state.role].map((n) => `
<button class="nav-item ${state.view === n.id ? 'active' : ''}" data-nav="${n.id}" type="button">
<span class="ico">${n.ico}</span>${n.label}
${n.id === 'orders' && newCount ? `<span class="nav-badge">${newCount}</span>` : ''}
</button>`).join('');
}
function renderTopbar() {
let t, s;
if (state.view === 'orders' && SHIFT_ROLES_JS.includes(state.role)) {
t = 'Мои заказы';
s = 'Новые заказы с сайта и из зала — принимайте и отмечайте выдачу';
} else {
[t, s] = TITLES[state.view] || ['—', ''];
if (state.view === 'mixes' && state.role === 'hookah') {
s = 'Фирменные миксы для страницы «Миксы НЕОНа» на сайте — название, вкусы, крепость, холодок, цена и теги';
}
}
document.getElementById('pageTitle').textContent = t;
document.getElementById('pageSub').textContent = s;
const newCount = state.orders.filter((o) => o.status === 'new').length;
const bell = document.getElementById('bellBtn');
const cnt = document.getElementById('bellCount');
cnt.textContent = newCount;
cnt.classList.toggle('hidden', !newCount);
bell.classList.toggle('has-new', !!newCount);
}
/* ---------------- вьюхи ---------------- */
/* занятость стола/зоны из состояния залов (обновляется в общем опросе) */
function hallByName(name) {
return state.halls.find((x) => x.name === name) || null;
}
function hallBadge(name) {
const st = hallByName(name);
const res = st && (st.reservations || [])[0];
const resHint = res ? ` · 🔒 ${fmtTime(res.from)}` : '';
if (!st || !st.busy) return `<span class="badge b-done"><span class="b-dot"></span>Свободен${resHint}</span>`;
if (st.busy_until) {
return `<span class="badge b-stop"><span class="b-dot"></span>Занят · `
+ `<span class="busy-timer" data-deadline="${new Date(st.busy_until).getTime()}"></span>${resHint}</span>`;
}
return `<span class="badge b-stop"><span class="b-dot"></span>Занят · без таймера${resHint}</span>`;
}
function viewDashboard() {
const active = state.orders.filter((o) => o.status === 'active').length;
const done = state.orders.filter((o) => o.status === 'done').length;
const stoppedMenu = state.menu.filter((m) => m.stopped && !m.deleted);
const stoppedTob = state.tobacco.filter((t) => t.stopped && !t.deleted);
const revenue = state.orders.filter((o) => o.status === 'done').reduce((s, o) => s + orderTotal(o), 0);
const durs = state.orders.filter((o) => o.duration_seconds != null).map((o) => o.duration_seconds);
const avgDur = durs.length ? Math.round(durs.reduce((a, b) => a + b, 0) / durs.length) : null;
const miniOrders = [...state.orders]
.sort((a, b) => b.id - a.id).slice(0, 5)
.map((o) => `
<div class="mini-order">
<span class="mo-num">№${o.number}</span>
<span class="mo-info">${esc(o.table)} · ${o.items.length} поз. · ${money(orderTotal(o))}</span>
<span class="badge ${STATUS[o.status].cls}">${STATUS[o.status].label}</span>
</div>`).join('');
const hallList = halls.map((h) => `
<div class="hall ${hallByName(h.name)?.busy ? 'hall-busy' : ''}">
<div>
<div class="h-name">${esc(h.name)}</div>
<div class="h-sub">${esc(h.sub)}</div>
</div>
${hallBadge(h.name)}
</div>`).join('');
const stopItems = [
...stoppedMenu.map((m) => ({ name: m.name, type: 'Меню', id: m.id, kind: 'menu' })),
...stoppedTob.map((t) => ({ name: t.flavor, type: 'Табак · ' + t.brand, id: t.id, kind: 'tobacco' })),
].map((s) => `
<div class="stop-item">
<span class="si-ico">⛔</span>${esc(s.name)}
<span class="si-type">${esc(s.type)}</span>
<button class="btn btn-ghost" data-unstop="${s.kind}:${encodeURIComponent(s.id)}" type="button">Вернуть</button>
</div>`).join('');
return `
<div class="stats-grid">
<div class="stat c-cyan"><div class="num">${state.orders.filter(o => o.status === 'new').length}</div><div class="lbl">Новых заказов</div><div class="sub">ожидают подтверждения</div></div>
<div class="stat c-purple"><div class="num">${active}</div><div class="lbl">В работе</div><div class="sub">кальяны и кухня готовят</div></div>
<div class="stat c-green"><div class="num">${done}</div><div class="lbl">Выдано сегодня</div><div class="sub">выручка ${money(revenue)}</div></div>
<div class="stat c-red"><div class="num">${stoppedMenu.length + stoppedTob.length}</div><div class="lbl">Позиций в стопе</div><div class="sub">${stoppedMenu.length} ${plural(stoppedMenu.length, ['позиция', 'позиции', 'позиций'])} меню · ${stoppedTob.length} ${plural(stoppedTob.length, ['табак', 'табака', 'табаков'])}</div></div>
<div class="stat c-cyan"><div class="num${avgDur != null ? ' txt' : ''}">${avgDur != null ? fmtDur(avgDur) : '—'}</div><div class="lbl">Средняя подача</div><div class="sub">от принятия до выдачи</div></div>
</div>
<div class="dash-grid">
<div class="panel">
<div class="panel-title">Последние заказы <small>сейчас</small></div>
<div class="mini-orders">${miniOrders}</div>
</div>
<div style="display:flex;flex-direction:column;gap:16px">
${!SHIFT_ROLES_JS.includes(state.role) ? `
<div class="panel">
<div class="panel-title">Сейчас на смене</div>
${state.onShiftList.length
? `<div class="mini-orders">${state.onShiftList.map((s) => `
<div class="mini-order">
<span class="mo-num">${esc(s.name)}</span>
<span class="mo-info">на смене с ${fmtTime(s.started_at)}</span>
<button class="btn btn-danger btn-sm" data-shiftkick="${esc(s.login)}" type="button" title="Снять со смены">Снять</button>
</div>`).join('')}</div>`
: '<div class="stop-empty">Никто не на смене</div>'}
${(() => {
const off = state.staffList.filter((s) => !s.on_shift);
if (!off.length) return '';
return `<div class="panel-title" style="margin-top:12px;margin-bottom:8px">Записать на смену</div>
<div class="chips">
${off.map((s) => `<button class="chip" data-shiftreg="${esc(s.login)}" type="button">+ ${esc(s.name)}</button>`).join('')}
</div>`;
})()}
</div>` : ''}
<div class="panel">
<div class="panel-title">Залы</div>
<div class="hall-list">${hallList}</div>
</div>
<div class="panel">
<div class="panel-title">Сейчас в стопе</div>
<div class="stop-now">${stopItems || '<div class="stop-empty">Стоп-лист пуст — всё доступно</div>'}</div>
</div>
</div>
</div>`;
}
/* ---------------- сортировка списков (владелец) ---------------- */
const MENU_SORTS = [
['default', 'по умолчанию'],
['name', 'название А→Я'],
['name_desc', 'название Я→А'],
['price_asc', 'цена ↑'],
['price_desc', 'цена ↓'],
['category', 'по категории'],
['stopped_first', 'в стопе сверху'],
];
const TOBACCO_SORTS = [
['default', 'по умолчанию'],
['flavor', 'вкус А→Я'],
['brand', 'по бренду'],
['strength_asc', 'лёгкие сверху'],
['strength_desc', 'крепкие сверху'],
];
const MIX_SORTS = [
['default', 'по умолчанию'],
['name', 'название А→Я'],
['price_asc', 'цена ↑'],
['price_desc', 'цена ↓'],
['strength_asc', 'лёгкие сверху'],
['strength_desc', 'крепкие сверху'],
];
const ruCmp = (key) => (a, b) => String(a[key] || '').localeCompare(String(b[key] || ''), 'ru');
/* цена/крепость могут быть не заданы (миксы мастера) — пустые всегда внизу */
const numCmp = (key, dir) => (a, b) => {
const av = a[key] == null ? Infinity : a[key];
const bv = b[key] == null ? Infinity : b[key];
return (av - bv) * dir;
};
const sortSelect = (id, options, value) => `
<select class="sort-select" id="${id}" aria-label="Сортировка" title="Сортировка списка">
${options.map(([v, label]) => `<option value="${v}" ${value === v ? 'selected' : ''}>${label}</option>`).join('')}
</select>`;
function viewMenu() {
const cats = ['Все', ...new Set(state.menu.map((m) => m.category))];
const q = state.menuQuery.trim().toLowerCase();
const isNov = (m) => m.custom && isNewItem(m.created_at);
// новинки — вверху списка, остальные по категории и названию
// сортировка: «по умолчанию» держит новинки вверху, остальные — по ключу
const menuSorters = {
default: (a, b) => (isNov(b) ? 1 : 0) - (isNov(a) ? 1 : 0),
name: ruCmp('name'),
name_desc: (a, b) => ruCmp('name')(b, a),
price_asc: (a, b) => a.price - b.price,
price_desc: (a, b) => b.price - a.price,
category: (a, b) => String(a.category).localeCompare(String(b.category), 'ru') || ruCmp('name')(a, b),
stopped_first: (a, b) => (b.stopped ? 1 : 0) - (a.stopped ? 1 : 0) || ruCmp('name')(a, b),
};
const items = state.menu
.filter((m) =>
(state.menuCat === 'Все' || m.category === state.menuCat) &&
(!q || m.name.toLowerCase().includes(q)))
.sort(menuSorters[state.menuSort] || menuSorters.default);
const isOwner = state.role === 'owner';
const canAccent = state.role === 'owner' || state.role === 'admin';
/* поштучный учёт (владелец): кнопка включения/прихода/выключения.
Бейдж остатка виден всем — админ и персонал понимают, сколько осталось */
const stockControls = (m) => {
if (m.stock == null) {
return isOwner
? `<button class="btn btn-ghost btn-sm" data-stock-on="${esc(m.id)}" type="button" title="Включить поштучный учёт: пиво, энергетики, банки">📦 штучный учёт</button>`
: '';
}
return `
${isOwner ? `<button class="btn btn-ghost btn-sm" data-stock-add="${esc(m.id)}" type="button" title="Докупили — прибавить к остатку">+ приход</button>
<button class="btn btn-ghost btn-sm" data-stock-off="${esc(m.id)}" type="button" title="Выключить поштучный учёт">✕</button>` : ''}`;
};
const stockBadge = (m) => (m.stock == null ? '' :
`<span class="stock-badge ${m.stock === 0 ? 'is-zero' : ''}" title="Поштучный учёт: остаток на полке">📦 ${m.stock} шт</span>`);
const accentPicker = (m) => {
if (state.accentFor !== m.id) return '';
return `
<div class="accent-palette">
<span class="switch-label">Подсветка на сайте:</span>
${NEON_ACCENTS.map((c) => `
<button class="chip ${m.accent === c ? 'active' : ''}" data-set-accent="${c}"
data-set-item="${esc(m.id)}" type="button" title="${c}">
<span style="display:inline-block;width:14px;height:14px;border-radius:50%;background:${c}"></span></button>`).join('')}
${m.accent ? `<button class="chip" data-set-accent="" data-set-item="${esc(m.id)}" type="button">как у всех</button>` : ''}
</div>`;
};
const rows = items.map((m) => `
<div class="menu-row ${m.deleted ? 'row-deleted' : m.stopped ? 'stopped' : ''}">
<div>
<div class="m-name">${esc(m.name)}</div>
<div class="m-cat">${esc(m.category)}${m.portion ? ' · ' + esc(m.portion) : ''}${m.custom && isNewItem(m.created_at) ? ' · ✨ новинка' : ''}${m.has_photo ? ' · 🖼' : ''}</div>
</div>
<div class="m-price">${money(m.price)}</div>
${m.deleted
? '<span class="badge b-cancel"><span class="b-dot"></span>Удалена</span>'
: m.stopped
? `<span class="badge b-stop"><span class="b-dot"></span>В стопе${m.stock != null && m.stock === 0 ? ' · закончился' : ''} · скрыто с сайта</span>`
: '<span class="badge b-done"><span class="b-dot"></span>Доступна</span>'}
<div class="m-stopcell">
${stockBadge(m)}
${m.deleted
? (isOwner ? `<button class="btn btn-ghost btn-sm" data-restore="menu:${esc(m.id)}" type="button">Вернуть</button>${m.custom ? `<button class="btn btn-danger btn-sm" data-purge="menu:${esc(m.id)}" type="button" title="Стереть позицию из базы — восстановить будет нельзя">Удалить навсегда</button>` : ''}` : '')
: `
<label class="switch">
<input type="checkbox" data-menu-stop="${esc(m.id)}" ${m.stopped ? 'checked' : ''} aria-label="Стоп-лист для ${esc(m.name)}">
<span class="track"></span>
</label>
<span class="switch-label">${m.stopped ? 'стоп' : 'в работе'}</span>
${isOwner && m.custom ? `<button class="btn btn-ghost btn-sm" data-menuedit="${esc(m.id)}" type="button" title="Изменить название, цену, порцию или категорию">✎</button>` : ''}
${canAccent ? `<button class="btn btn-ghost btn-sm" data-accent-menu="${esc(m.id)}" type="button"
title="Подсветка карточки на сайте"><span class="accent-dot" style="background:${m.accent || 'var(--muted-2)'}"></span>🎨</button>` : ''}
${isOwner ? `<button class="btn btn-ghost btn-sm" data-photo="${esc(m.id)}" type="button" title="Загрузить фото для сайта">${m.has_photo ? '📷 фото ✓' : '📷 фото'}</button>` : ''}
${isOwner ? `<button class="btn btn-danger btn-sm" data-del="menu:${esc(m.id)}" type="button" title="Удалить позицию">Удалить</button>` : ''}`}
${m.deleted ? '' : stockControls(m)}
</div>
${accentPicker(m)}
</div>`).join('');
return `
${state.role === 'owner' ? `
<div class="panel" style="margin-bottom:16px">
<div class="panel-title">${state.menuEditId ? 'Редактирование позиции' : 'Добавить позицию'} <small>${state.menuEditId ? 'изменения появятся на сайте сразу' : 'появится на сайте с пометкой «Новинка» на 7 дней'}</small></div>
<form class="user-form" id="menuForm" autocomplete="off">
<input type="text" id="newMenuName" placeholder="Название позиции" required>
<select id="newMenuCat" aria-label="Категория">
${cats.filter((c) => c !== 'Все').map((c) => `<option value="${esc(c)}">${esc(c)}</option>`).join('')}
</select>
<input type="number" id="newMenuPrice" placeholder="Цена, ₽" min="0" step="1" required>
<input type="text" id="newMenuPortion" placeholder="Порция, напр. 900 мл">
${state.menuEditId ? '' : '<input type="number" id="newMenuStock" placeholder="Кол-во, шт (пиво/банки)" min="0" step="1" title="Заполните, чтобы включить поштучный учёт: при продаже остаток будет уменьшаться, по нулю позиция уйдёт в стоп-лист">'}
<button class="btn btn-neon" type="submit">${state.menuEditId ? 'Сохранить изменения' : 'Добавить'}</button>
${state.menuEditId ? '<button class="btn btn-ghost" data-menu-cancel="1" type="button">Отмена</button>' : ''}
</form>
</div>` : ''}
<div class="note"><span class="n-ico">ℹ</span>
<span>Переключите тумблер, чтобы отправить позицию <b>в стоп</b> — на сайте neon-bar86.ru она мгновенно помечается как <b>«нет в наличии»</b> и исчезает из онлайн-заказа. ${isOwner ? '📦 <b>Штучный учёт</b>: вбейте купленное количество банок — при выдаче заказа остаток уменьшится сам, а по нулю позиция уйдёт в стоп-лист.' : ''}</span>
</div>
<div class="toolbar">
<div class="chips">
${cats.map((c) => `<button class="chip ${state.menuCat === c ? 'active' : ''}" data-cat="${esc(c)}" type="button">${esc(c)}</button>`).join('')}
</div>
${sortSelect('menuSortSel', MENU_SORTS, state.menuSort)}
<div class="search"><input type="text" id="menuSearch" placeholder="Поиск по названию…" value="${esc(state.menuQuery)}"></div>
</div>
<div class="menu-list">${rows || '<div class="stop-empty">Ничего не найдено</div>'}</div>`;
}
const strengthLabel = (s) => (s <= 1 ? 'Лёгкая' : s <= 2 ? 'Средняя' : 'Крепкая');
function viewTobacco() {
const brands = ['Все', ...new Set(state.tobacco.filter((t) => !t.deleted).map((t) => t.brand))];
const q = state.tobaccoQuery.trim().toLowerCase();
const isNov = (t) => t.custom && isNewItem(t.created_at);
// сортировка: «по умолчанию» держит новинки вверху
const tobSorters = {
default: (a, b) => (isNov(b) ? 1 : 0) - (isNov(a) ? 1 : 0),
flavor: ruCmp('flavor'),
brand: (a, b) => ruCmp('brand')(a, b) || ruCmp('flavor')(a, b),
strength_asc: (a, b) => a.strength - b.strength || ruCmp('flavor')(a, b),
strength_desc: (a, b) => b.strength - a.strength || ruCmp('flavor')(a, b),
};
const items = state.tobacco
.filter((t) =>
(state.tobBrand === 'Все' || t.brand === state.tobBrand) &&
(!q || (t.flavor + ' ' + t.brand + ' ' + (t.note || '')).toLowerCase().includes(q)))
.sort(tobSorters[state.tobaccoSort] || tobSorters.default);
const isOwner = state.role === 'owner';
const rows = items.map((t) => `
<div class="menu-row ${t.deleted ? 'row-deleted' : t.stopped ? 'stopped' : ''}">
<div>
<div class="m-name">${esc(t.flavor)}</div>
<div class="m-cat">${esc(t.brand)}${t.note ? ' · ' + esc(t.note) : ''}${t.cooling ? ' · ❄ холодок' : ''}${t.custom && isNewItem(t.created_at) ? ' · ✨ новинка' : ''}</div>
</div>
<div class="m-price">${strengthLabel(t.strength)}</div>
${t.deleted
? '<span class="badge b-cancel"><span class="b-dot"></span>Удалён</span>'
: t.stopped
? '<span class="badge b-stop"><span class="b-dot"></span>В стопе</span>'
: '<span class="badge b-done"><span class="b-dot"></span>Доступен</span>'}
<div class="m-stopcell">
${t.deleted
? (isOwner ? `<button class="btn btn-ghost btn-sm" data-restore="tobacco:${encodeURIComponent(t.id)}" type="button">Вернуть</button>${t.custom ? `<button class="btn btn-danger btn-sm" data-purge="tobacco:${encodeURIComponent(t.id)}" type="button" title="Стереть вкус из базы — восстановить будет нельзя">Удалить навсегда</button>` : ''}` : '')
: `
<label class="switch">
<input type="checkbox" data-tob-stop="${encodeURIComponent(t.id)}" ${t.stopped ? 'checked' : ''} aria-label="Стоп-лист для ${esc(t.flavor)}">
<span class="track"></span>
</label>
<span class="switch-label">${t.stopped ? 'стоп' : 'в работе'}</span>
${isOwner ? `<button class="btn btn-danger btn-sm" data-del="tobacco:${encodeURIComponent(t.id)}" type="button" title="Удалить вкус">Удалить</button>` : ''}`}
</div>
</div>`).join('');
return `
${state.role === 'owner' ? `
<div class="panel" style="margin-bottom:16px">
<div class="panel-title">Добавить вкус <small>появится на сайте с пометкой «Новинка» на 7 дней</small></div>
<form class="user-form" id="tobaccoForm" autocomplete="off">
<input type="text" id="newTobBrand" placeholder="Бренд" required>
<input type="text" id="newTobFlavor" placeholder="Название вкуса" required>
<input type="text" id="newTobNote" placeholder="Описание вкуса">
<select id="newTobStrength" aria-label="Крепость">
<option value="1">Лёгкая</option>
<option value="2" selected>Средняя</option>
<option value="3">Крепкая</option>
</select>
<label class="check-inline"><input type="checkbox" id="newTobCooling"> холодок</label>
<button class="btn btn-neon" type="submit">Добавить</button>
</form>
</div>` : ''}
<div class="note"><span class="n-ico">ℹ</span>
<span>Стоп-лист ведётся по <b>отдельным вкусам</b>, а не по маркам. Вкус <b>в стопе</b> нельзя выбрать в конструкторе кальяна на сайте — карточка вкуса помечается «Закончился». Используйте при окончании запаса конкретной позиции.</span>
</div>
<div class="toolbar">
<div class="chips">
${brands.map((b) => `<button class="chip ${state.tobBrand === b ? 'active' : ''}" data-tobcat="${esc(b)}" type="button">${esc(b)}</button>`).join('')}
</div>
${sortSelect('tobSortSel', TOBACCO_SORTS, state.tobaccoSort)}
<div class="search"><input type="text" id="tobSearch" placeholder="Поиск по вкусу…" value="${esc(state.tobaccoQuery)}"></div>
</div>
<div class="menu-list">${rows || '<div class="stop-empty">Ничего не найдено</div>'}</div>`;
}
/* ---------------- миксы для сайта (владелец и кальянщик) ---------------- */
/* подписи крепости/холодка миксов — как на сайте (MixesPage) */
const MIX_STRENGTH_LABELS = { 1: 'лёгкий', 2: 'ниже среднего', 3: 'средняя', 4: 'выше средней', 5: 'крепкий' };
const MIX_COOLING_LABELS = { 0: 'без холодка', 1: 'лёгкий', 2: 'средний', 3: 'сильный' };
/* теги миксов: по ним сайт подмешивает микс мастера в автоподбор
(пересечение с категориями запроса гостя) */
const MIX_TAGS = ['фрукты', 'ягоды', 'цитрус', 'тропики', 'десерты', 'напитки',
'пряности', 'цветы', 'мята', 'сливки', 'орехи', 'лёд'];
function viewMixes() {
const available = state.tobacco.filter((t) => !t.deleted);
const brands = ['Все', ...new Set(available.map((t) => t.brand))];
const q = state.mixQuery.trim().toLowerCase();
const choices = available.filter((t) =>
(state.mixBrand === 'Все' || t.brand === state.mixBrand) &&
(!q || (t.flavor + ' ' + t.brand).toLowerCase().includes(q)));
const chosen = state.newMix.flavorIds
.map((id) => available.find((t) => t.id === id))
.filter(Boolean);
const err = state.mixErrors || {};
const fieldError = (key) => (err[key]
? `<div class="form-error" role="alert">⚠ ${esc(err[key])}</div>` : '');
const errClass = (key) => (err[key] ? ' field-error' : '');
const mixMeta = (m) => {
const bits = [];
if (m.strength != null) bits.push(`крепость ${m.strength}/5${MIX_STRENGTH_LABELS[m.strength] ? ' · ' + MIX_STRENGTH_LABELS[m.strength] : ''}`);
if (m.cooling != null) bits.push(`холодок ${m.cooling}/3${MIX_COOLING_LABELS[m.cooling] ? ' · ' + MIX_COOLING_LABELS[m.cooling] : ''}`);
if (m.price != null) bits.push(money(m.price));
if ((m.tags || []).length) bits.push('🏷 ' + m.tags.map(esc).join(', '));
return bits.length ? ' · ' + bits.join(' · ') : '';
};
/* сортировка списка миксов; «по умолчанию» — как отдал сервер (свежие сверху) */
const mixSorters = {
default: null,
name: ruCmp('name'),
price_asc: numCmp('price', 1),
price_desc: numCmp('price', -1),
strength_asc: numCmp('strength', 1),
strength_desc: numCmp('strength', -1),
};
const sorter = mixSorters[state.mixSort];
const sortedMixes = sorter ? [...state.mixes].sort(sorter) : state.mixes;
const rows = sortedMixes.map((m) => `
<div class="menu-row">
<div>
<div class="m-name">${esc(m.name)}</div>
<div class="m-cat">${m.flavors.map((f) => `${esc(f.brand)} ${esc(f.flavor)}`).join(' · ')}${m.note ? ' · ' + esc(m.note) : ''}${mixMeta(m)}</div>
</div>
<div class="m-price">${fmtDate(m.created_at)}</div>
<span class="badge b-done"><span class="b-dot"></span>На сайте</span>
<div class="m-stopcell">
<button class="btn btn-ghost btn-sm" data-mixedit="${esc(m.id)}" type="button" title="Изменить название, состав, крепость, холодок, цену или теги">✎ Изменить</button>
${state.role === 'owner' ? `<button class="btn btn-danger btn-sm" data-purge="mixes:${esc(m.id)}" type="button" title="Стереть микс из базы — восстановить будет нельзя">Удалить навсегда</button>` : ''}
<button class="btn btn-danger btn-sm" data-mixdel="${esc(m.id)}" type="button">Удалить</button>
</div>
</div>`).join('');
/* авторские миксы сайта (seed catalog.ts): название/описание/состав/цена
правятся здесь и применяются на сайте через /api/catalog */
const siteRows = state.siteMixes.map((m) => `
<div class="menu-row">
<div>
<div class="m-name">${esc(m.name)}${m.featured ? ' ⭐' : ''}${m.edited ? ' ✏️' : ''}</div>
<div class="m-cat">${esc(m.note || '')}</div>
${m.flavors ? `<div class="m-cat">${m.flavors.map((f) => `${esc(f.brand)} ${esc(f.flavor)}`).join(' · ')}</div>` : ''}
<div class="m-cat">${esc((m.flavor_tags || []).join(', '))}</div>
</div>
<div class="m-price">${typeof m.price === 'number' ? money(m.price) : esc(m.price || '')}</div>
<span class="badge b-new"><span class="b-dot"></span>сила ${m.strength}/5 · холодок ${m.cooling}/3${m.edited ? ' · правка из CRM' : ''}</span>
<div class="m-stopcell">
${state.role === 'owner' ? `
<button class="btn btn-ghost btn-sm" data-sitemixedit="${esc(m.id)}" type="button" title="Изменить название, описание, состав, крепость, холодок или цену">✎ Изменить</button>
${m.edited ? `<button class="btn btn-ghost btn-sm" data-sitemixreset="${esc(m.id)}" type="button" title="Вернуть исходный вид микса">Вернуть оригинал</button>` : ''}
` : '<span class="switch-label">витрина сайта</span>'}
</div>
</div>`).join('');
return `
<div class="note"><span class="n-ico">ℹ</span>
<span>${state.role === 'hookah'
? 'Миксы видны гостям в разделе <b>«Миксы НЕОНа»</b> на сайте: название и состав, крепость, холодок, цена. Теги подмешивают микс в автоподбор на сайте. Пропорции забивки гость не видит. Кнопка ✎ — изменить название или состав микса.'
: 'Миксы видны гостям в разделе <b>«Миксы НЕОНа»</b> на сайте. Указывайте только сочетание вкусов — <b>пропорции подбирает кальянщик</b>, гость их не видит. Кнопка ✎ у микса — правка названия, состава и описания; в блоке «Авторские миксы сайта» ✎ правит витринный микс, «Вернуть оригинал» отменяет правку.'}</span>
</div>
<div class="panel" style="margin-bottom:16px">
<div class="panel-title">${state.siteMixEditId ? 'Правка витринного микса' : state.mixEditId ? 'Редактирование микса' : 'Добавить микс'} <small>${state.siteMixEditId ? 'изменения появятся на сайте сразу · очистите состав, чтобы вернуть исходный' : state.mixEditId ? 'изменения появятся на сайте сразу' : 'появится на сайте сразу · заметка не обязательна'}</small></div>
<form class="user-form" id="mixForm" autocomplete="off" novalidate>
<input type="text" id="newMixName" placeholder="Название микса" maxlength="120" class="${errClass('newMixName')}">
<input type="text" id="newMixNote" placeholder="Заметка, напр. «крепкий, на большую чашу» (необязательно)" maxlength="200">
<select id="newMixStrength" aria-label="Крепость микса" class="${errClass('newMixStrength')}">
<option value="">Крепость — выберите</option>
${[1, 2, 3, 4, 5].map((s) => `<option value="${s}">${s}/5 — ${MIX_STRENGTH_LABELS[s]}</option>`).join('')}
</select>
<select id="newMixCooling" aria-label="Холодок микса" class="${errClass('newMixCooling')}">
<option value="">Холодок — выберите</option>
${[0, 1, 2, 3].map((c) => `<option value="${c}">${c}/3 — ${MIX_COOLING_LABELS[c]}</option>`).join('')}
</select>
<input type="number" id="newMixPrice" placeholder="Цена, ₽" min="0" step="1" class="${errClass('newMixPrice')}">
<button class="btn btn-neon" type="submit">${state.siteMixEditId || state.mixEditId ? 'Сохранить изменения' : 'Добавить микс'}</button>
${state.siteMixEditId || state.mixEditId ? '<button class="btn btn-ghost" data-mix-cancel="1" type="button">Отмена</button>' : ''}
${fieldError('newMixName')}${fieldError('newMixStrength')}${fieldError('newMixCooling')}${fieldError('newMixPrice')}
</form>
${state.siteMixEditId && state.newMix.flavorIds.length === 0 ? '<p class="switch-label" style="margin-top:8px">Состав не выбран — на сайте останется исходный состав этого микса</p>' : ''}
<div class="chips ${err.tags ? 'field-error-block' : ''}" id="mixTagChips" style="margin-top:10px">
<span class="switch-label">Теги (для автоподбора):</span>
${MIX_TAGS.map((t) => `
<button class="chip ${state.newMix.tags.includes(t) ? 'active' : ''}" data-mixtag="${t}" type="button"
title="Микс будет подмешиваться в автоподбор по этому тегу">${t}</button>`).join('')}
</div>
${fieldError('tags')}
<div class="chips ${err.flavors ? 'field-error-block' : ''}" id="mixFlavorChips" style="margin-top:10px">
${chosen.length
? `Состав (${chosen.length}): ` + chosen.map((t) =>
`<button class="chip active" data-mixflavor="${esc(t.id)}" type="button" title="убрать">${esc(t.brand)} ${esc(t.flavor)} ✕</button>`).join('')
: '<span class="switch-label">Выберите 2–5 вкусов из списка ниже</span>'}
</div>
${fieldError('flavors')}
<div class="toolbar" style="margin-top:10px">
<div class="chips">
${brands.map((b) => `<button class="chip ${state.mixBrand === b ? 'active' : ''}" data-mixbrand="${esc(b)}" type="button">${esc(b)}</button>`).join('')}
</div>
<div class="search"><input type="text" id="mixSearch" placeholder="Поиск по вкусу…" value="${esc(state.mixQuery)}"></div>
</div>
<div class="chips" style="margin-top:10px;max-height:220px;overflow:auto">
${choices.map((t) => `
<button class="chip ${state.newMix.flavorIds.includes(t.id) ? 'active' : ''}"
data-mixflavor="${esc(t.id)}" type="button">${esc(t.brand)} ${esc(t.flavor)}</button>`).join('')
|| '<span class="switch-label">Ничего не найдено</span>'}
</div>
</div>
<div class="toolbar" style="margin-top:16px">
<span class="switch-label">Миксов: ${sortedMixes.length}</span>
${sortSelect('mixSortSel', MIX_SORTS, state.mixSort)}
</div>
<div class="menu-list">${rows || '<div class="stop-empty">Миксов пока нет — добавьте первый</div>'}</div>
${state.siteMixes.length ? `
<div class="panel" style="margin-top:16px">
<div class="panel-title">Авторские миксы сайта <small>${state.siteMixes.length} шт. — читаются из каталога сайта, здесь только просмотр</small></div>
<div class="menu-list">${siteRows}</div>
</div>` : ''}`;
}
function orderCard(o, actions) {
const items = o.items.map((i) => {
// 💨 у кальянов (в каталоге их ровно два, оба начинаются со «Кальян»):
// на планшете кальянщика свой ли это заказ видно без чтения списка
const hookah = /^кальян/i.test(i.name);
return `
<li${hookah ? ' class="is-hookah"' : ''}><span>${hookah ? '💨 ' : ''}<span class="q">${i.qty}</span>${esc(i.name)}</span><span class="p">${money(i.price * i.qty)}</span></li>`;
}).join('');
const dur = o.status === 'done' && o.duration_seconds != null
? `<div class="o-dur">⏱ принят → выдан за ${fmtDur(o.duration_seconds)}</div>` : '';
const guest = o.guest_name
? `<div class="o-waiter">гость: ${esc(o.guest_name)}${o.guest_phone ? ' · ' + esc(o.guest_phone) : ''}</div>` : '';
const comment = o.comment ? `<div class="o-comment">💬 ${esc(o.comment)}</div>` : '';
const origin = o.source === 'site' ? 'заказ с сайта' : 'заказ из зала';
return `
<div class="order-card is-${o.status}">
<div class="o-head">
<span class="o-num">Заказ №${o.number}</span>
<span class="o-table">${esc(o.table)}</span>
<span class="o-time">${fmtTime(o.created_at)}</span>
</div>
<ul class="o-items">${items}</ul>
<div class="o-foot">
<div>
<div class="o-total">${money(orderTotal(o))}</div>
<div class="o-waiter">${o.waiter ? 'официант: ' + esc(o.waiter) : origin}</div>
${guest}
${comment}
${dur}
</div>
${actions}
</div>
</div>`;
}
/* выданные заказы исчезают из канбана официанта через час после выдачи
(админ и владелец видят полный список в таблице заказов) */
const DONE_TTL_MS = 60 * 60 * 1000;
function isDoneLongAgo(o) {
if (!o.delivered_at) return false;
const ts = new Date(o.delivered_at).getTime();
return Number.isFinite(ts) && Date.now() - ts > DONE_TTL_MS;
}
function viewWaiterOrders() {
// вне смены управление заблокировано: смотрим заказы, кнопок нет
const blocked = !state.onShift;
const cols = [
{ key: 'new', cls: 'kan-new', title: 'Новые' },
{ key: 'active', cls: 'kan-active', title: 'В работе' },
{ key: 'done', cls: 'kan-done', title: 'Выданные' },
].map((c) => {
const orders = state.orders.filter((o) =>
o.status === c.key && !(c.key === 'done' && isDoneLongAgo(o)));
// кнопки в отдельной группе: на телефоне .o-actions перестраивается
// в свои ряды и не выезжает за карточку
const action = blocked ? () => '' :
c.key === 'new' ? (o) => `
<div class="o-actions">
<button class="btn btn-neon btn-sm" data-accept="${esc(o.id)}" type="button">Принять</button>
<button class="btn btn-green btn-sm" data-fastdone="${esc(o.id)}" type="button" title="Заказ отдали гостю сразу — один тап вместо двух">✓ Сразу выдать</button>
<button class="btn btn-danger btn-sm" data-cancel="${esc(o.id)}" type="button">Отменить</button>
</div>` :
c.key === 'active' ? (o) => `
<div class="o-actions">
<button class="btn btn-green btn-sm" data-deliver="${esc(o.id)}" type="button">✓ Выдать</button>
<button class="btn btn-danger btn-sm" data-cancel="${esc(o.id)}" type="button">Отменить</button>
</div>` :
() => '';
const cards = orders.map((o) => orderCard(o, action(o))).join('');
return `
<div class="kan-col ${c.cls}">
<div class="kan-head">${c.title}<span class="cnt">${orders.length}</span></div>
<div class="stack">${cards || '<div class="kan-empty">Пусто</div>'}</div>
</div>`;
}).join('');
return `
<div class="note"><span class="n-ico">${blocked ? '🔒' : '💡'}</span>
<span>${blocked
? 'Смена закрыта — управление заблокировано. Кнопка <b>«Выйти на смену»</b> появится за 20 минут до начала, отсчёт — в шапке.'
: 'Нажмите <b>«Принять»</b>, когда взяли заказ в работу, затем <b>«Выдать»</b>. Мгновенный заказ (напиток из холодильника) — <b>«✓ Сразу выдать»</b> в один тап. 💨 в составе — кальян. Ошибочный или отменённый гостем заказ — <b>«Отменить»</b>.'}</span>
</div>
<div class="kanban">${cols}</div>`;
}
function viewAdminOrders() {
const f = state.orderFilter;
const orders = [...state.orders]
.filter((o) => f === 'all' || o.status === f)
.sort((a, b) => b.id - a.id);
const chips = [
{ key: 'all', label: 'Все' },
{ key: 'new', label: 'Новые' },
{ key: 'active', label: 'В работе' },
{ key: 'done', label: 'Выданные' },
{ key: 'cancelled', label: 'Отменённые' },
].map((c) => {
const n = c.key === 'all' ? state.orders.length : state.orders.filter((o) => o.status === c.key).length;
return `<button class="chip ${f === c.key ? 'active' : ''}" data-ofilter="${c.key}" type="button">${c.label}<span class="cnt">${n}</span></button>`;
}).join('');
const rows = orders.map((o) => `
<div class="ot-row">
<span class="ot-num">№${o.number}</span>
<span class="ot-time-cell">${fmtTime(o.created_at)}</span>
<span class="ot-items-cell">${o.items.map((i) => `${i.qty}×${esc(i.name)}`).join(' · ')}${o.comment ? ` · 💬 ${esc(o.comment)}` : ''}</span>
<span class="ot-waiter-cell">${esc(o.waiter || '—')}</span>
<span class="ot-total-cell">${money(orderTotal(o))}</span>
<span class="ot-dur-cell" title="Время от принятия до выдачи">⏱ ${fmtDur(o.duration_seconds)}</span>
<select class="status-select s-${o.status}" data-status="${esc(o.id)}" title="Сменить статус заказа">
${Object.entries(STATUS).map(([key, v]) =>
`<option value="${key}" ${o.status === key ? 'selected' : ''}>${v.label}</option>`).join('')}
</select>
</div>`).join('');
return `
${state.role === 'owner' ? `
<div class="toolbar" style="margin-bottom:10px">
<button class="btn btn-ghost btn-sm" data-numreset="1" type="button"
title="Следующий заказ получит №1 — до конца текущей недели нумерации">⟲ Сбросить нумерацию</button>
</div>` : ''}
<div class="toolbar"><div class="chips">${chips}</div></div>
<div class="order-table">
<div class="ot-row head">
<span>Заказ</span><span>Время</span><span>Состав</span><span class="h-waiter">Официант</span><span>Сумма</span><span class="h-dur">Выполнен за</span><span>Статус</span>
</div>
${rows || '<div class="stop-empty">Нет заказов по фильтру</div>'}
</div>`;
}
function viewStopList() {
// кальянщику важен только табак: его стоп-лист — вкусы для кальянов
const hookahOnly = state.role === 'hookah';
const m = hookahOnly ? [] : state.menu.filter((x) => x.stopped && !x.deleted);
const t = state.tobacco.filter((x) => x.stopped && !x.deleted);
if (!m.length && !t.length) {
return `<div class="panel"><div class="stop-empty" style="padding:48px 20px">Стоп-лист пуст — все позиции доступны 😉</div></div>`;
}
const row = (name, type, price) => `
<div class="menu-row stopped">
<div><div class="m-name">${esc(name)}</div><div class="m-cat">${esc(type)}</div></div>
<div class="m-price">${price || '—'}</div>
<span class="badge b-stop"><span class="b-dot"></span>В стопе</span>
<span></span>
</div>`;
return `
<div class="note"><span class="n-ico">ℹ</span>
<span>${hookahOnly
? 'Эти <b>вкусы табака</b> сейчас <b>нельзя забить в кальян</b> — вкус помечен «Закончился» в конструкторе на сайте. Снять или вернуть стоп можно во вкладке <b>«Табаки»</b>.'
: 'Эти позиции <b>нельзя заказать</b> — сообщайте гостям при приёме заказа. Изменить стоп-лист может только администратор.'}</span>
</div>
<div class="menu-list">
${t.map((x) => row(x.flavor, 'Табак · ' + x.brand)).join('')}
${m.map((x) => row(x.name, 'Меню · ' + x.category, money(x.price))).join('')}
</div>`;
}
/* ---------------- залы: занятость столов и зон + брони ---------------- */
/* «90» — минуты; «23:30»/«01:30» — время, до которого занят стол.
Время раньше текущего означает следующие сутки (бар работает до утра). */
function parseUntilMinutes(raw) {
const s = (raw || '').trim();
if (!s) return null;
const tm = s.match(/^(\d{1,2}):(\d{2})$/);
if (tm) {
const now = new Date();
const target = new Date(now);
target.setHours(Number(tm[1]), Number(tm[2]), 0, 0);
if (target <= now) target.setDate(target.getDate() + 1);
return Math.round((target - now) / 60000);
}
const mins = Number(s);
return Number.isFinite(mins) && mins >= 5 ? Math.round(mins) : null;
}
/* напоминание о брони: окно за 20 минут до прихода гостя; каждый тост один раз */
const RESERVE_REMIND_MS = 20 * 60 * 1000;
function checkReserveReminders() {
const now = Date.now();
for (const h of state.halls) {
for (const r of h.reservations || []) {
const from = new Date(r.from).getTime();
if (from - RESERVE_REMIND_MS <= now && now < from && !state.resReminded.has(r.id)) {
state.resReminded.add(r.id);
const mins = Math.max(1, Math.round((from - now) / 60000));
beep();
toast(`🔔 Бронь: ${h.name} в ${fmtTime(r.from)}`
+ (r.guest ? ` — ${r.guest}` : '') + ` (через ${mins} мин)`, '');
}
}
}
}
function hallReserveBlock(h, canManage) {
const res = (h.reservations || [])[0];
if (!res) return '';
const now = Date.now();
const from = new Date(res.from).getTime();
const until = new Date(res.until).getTime();
const ongoing = now >= from && now < until;
const more = (h.reservations || []).slice(1).map((r) => fmtTime(r.from)).join(', ');
return `
<div class="h-reserve ${ongoing ? 'is-ongoing' : ''}">
<span class="h-reserve-ico">🔒</span>
<span class="h-reserve-what">Бронь ${fmtTime(res.from)}–${fmtTime(res.until)}${res.guest ? ' · ' + esc(res.guest) : ''}</span>
${ongoing
? `<span class="h-reserve-count">идёт · до ${fmtTime(res.until)}</span>`
: `<span class="h-reserve-count">через&nbsp;<span class="busy-timer" data-deadline="${from}" data-done="началась"></span></span>`}
${canManage ? `<button class="icon-btn" data-hall-res-cancel="${esc(res.id)}" type="button" title="Отменить бронь">✕</button>` : ''}
</div>
${more ? `<div class="h-reserve-more">ещё брони: ${more}</div>` : ''}`;
}
function viewHalls() {
const canManage = state.role === 'owner' || state.role === 'admin';
const isOwner = state.role === 'owner';
const cards = (state.halls.length ? state.halls : halls.map((h) => ({ name: h.name, sub: h.sub })))
.map((h) => {
const timer = h.busy && h.busy_until
? `<div class="h-timer"><span class="h-timer-ico">⏳</span><span class="busy-timer" data-deadline="${new Date(h.busy_until).getTime()}"></span></div>`
: '';
const meta = h.busy
? `<div class="h-busyby">занял: ${esc(h.busy_by || '—')}${h.busy_until ? ' · до ' + fmtTime(h.busy_until) : ' · без таймера'}</div>`
: '';
const reserve = hallReserveBlock(h, canManage);
const controls = !canManage ? '' : h.busy ? `
<div class="h-actions">
<button class="btn btn-ghost btn-sm" data-hall-ext="${esc(h.name)}:30" type="button">+30 мин</button>
<button class="btn btn-ghost btn-sm" data-hall-ext="${esc(h.name)}:60" type="button">+1 час</button>
<button class="btn btn-ghost btn-sm" data-hall-custom-ext="${esc(h.name)}" type="button">Своё время…</button>
<button class="btn btn-ghost btn-sm" data-hall-reserve="${esc(h.name)}" type="button">🔒 Бронь…</button>
<button class="btn btn-danger btn-sm" data-hall-free="${esc(h.name)}" type="button">Освободить</button>
</div>` : `
<div class="h-actions">
<button class="btn btn-neon btn-sm" data-hall-busy="${esc(h.name)}" type="button">Занять</button>
<button class="btn btn-ghost btn-sm" data-hall-busy-min="${esc(h.name)}:30" type="button">30 мин</button>
<button class="btn btn-ghost btn-sm" data-hall-busy-min="${esc(h.name)}:60" type="button">1 час</button>
<button class="btn btn-ghost btn-sm" data-hall-busy-min="${esc(h.name)}:120" type="button">2 часа</button>
<button class="btn btn-ghost btn-sm" data-hall-custom-busy="${esc(h.name)}" type="button">До времени…</button>
<button class="btn btn-ghost btn-sm" data-hall-reserve="${esc(h.name)}" type="button">🔒 Бронь…</button>
${isOwner ? `<button class="btn btn-danger btn-sm" data-hall-del="${esc(h.name)}" type="button" title="Удалить стол: исчезнет из CRM и из выбора столика на сайте">Удалить</button>` : ''}
</div>`;
return `
<div class="hall-card ${h.busy ? 'is-busy' : ''}">
<div class="h-top">
<div>
<div class="h-name">${esc(h.name)}</div>
<div class="h-sub">${esc(h.sub || '')}</div>
</div>
${h.busy
? '<span class="badge b-stop"><span class="b-dot"></span>Занят</span>'
: '<span class="badge b-done"><span class="b-dot"></span>Свободен</span>'}
</div>
${meta}${timer}${reserve}${controls}
</div>`;
})
.join('');
return `
<div class="note"><span class="n-ico">ℹ</span>
<span>${canManage
? 'Помечайте столы и зоны занятыми — по желанию с таймером до освобождения. 🔒 Бронь: стол помечается на нужное время, отсчёт виден всем, а за 20 минут до прихода гостя панель напомнит. До начала брони стол остаётся свободным для гостей.'
: 'Занятость и брони ведут администратор и владелец. Видно, какие столы заняты, сколько осталось до освобождения и когда ожидается гость с бронью.'}</span>
</div>
${isOwner ? `
<div class="panel" style="margin-bottom:16px">
<div class="panel-title">Добавить стол или зал <small>сразу появится в залах CRM и в выборе столика на сайте</small></div>
<form class="user-form" id="hallForm" autocomplete="off">
<input type="text" id="newHallName" placeholder="Название, напр. Стол 6" maxlength="60" required>
<input type="text" id="newHallSub" placeholder="Подпись, напр. «терраса» (необязательно)" maxlength="60">
<button class="btn btn-neon" type="submit">Добавить</button>
</form>
</div>` : ''}
<div class="hall-grid">${cards || '<div class="stop-empty">Загрузка состояния залов…</div>'}</div>`;
}
/* ---------------- владелец: сотрудники и отчёты ---------------- */
function viewUsers() {
const roleBadge = (r) =>
r === 'owner' ? '<span class="badge b-new"><span class="b-dot"></span>Владелец</span>' :
r === 'admin' ? '<span class="badge b-active"><span class="b-dot"></span>Администратор</span>' :
r === 'hookah' ? '<span class="badge b-active"><span class="b-dot"></span>Кальянщик</span>' :
'<span class="badge b-done"><span class="b-dot"></span>Официант</span>';
const shiftByLogin = {};
for (const sc of state.shiftCounts) shiftByLogin[sc.login] = sc.shifts;
const rows = state.users.map((u) => `
<div class="menu-row">
<div>
<div class="m-name">${esc(u.name)}</div>
<div class="m-cat">${esc(u.login)}${SHIFT_ROLES_JS.includes(u.role) ? ' · смен: ' + (shiftByLogin[u.login] || 0) : ''}</div>
</div>
<div class="m-price">${(u.role === 'waiter' || u.role === 'hookah' || u.role === 'admin')
? `<button class="btn btn-ghost btn-sm" data-user-pass="${esc(u.login)}" type="button" title="Задать новый пароль: сотрудник войдёт с ним, его открытые сессии закроются">Пароль</button>`
: ''}</div>
${roleBadge(u.role)}
<div class="m-stopcell">
${(u.role === 'waiter' || u.role === 'hookah' || u.role === 'admin') && u.login !== state.login
? `<button class="btn btn-danger btn-sm" data-user-del="${esc(u.login)}" type="button">Удалить</button>`
: `<span class="switch-label">${u.login === state.login ? 'это вы' : ''}</span>`}
</div>
</div>`).join('');
return `
<div class="note"><span class="n-ico">ℹ</span>
<span>Владелец создаёт <b>официантов и администраторов</b> — имя, логин, пароль. Администратор управляет меню, табаками и заказами; официант — только заказами. Кнопка «Пароль» задаёт сотруднику новый пароль (его сессии закроются). Удалённый сотрудник теряет доступ сразу.</span>
</div>
<div class="panel">
<div class="panel-title">Добавить сотрудника</div>
<form class="user-form" id="userForm" autocomplete="off">
<input type="text" id="newUserName" placeholder="Имя (например, Алиса)" required>
<input type="text" id="newUserLogin" placeholder="Логин (латиница)" required>
<input type="password" id="newUserPass" placeholder="Пароль" required>
<select id="newUserRole" aria-label="Роль сотрудника">
<option value="waiter">Официант</option>
<option value="hookah">Кальянщик</option>
<option value="admin">Администратор</option>
</select>
<button class="btn btn-neon" type="submit">Создать</button>
</form>
</div>
<div class="menu-list">${rows || '<div class="stop-empty">Сотрудников нет</div>'}</div>`;
}
function viewReports() {
const r = state.report;
return `
<div class="note"><span class="n-ico">ℹ</span>
<span>Отчёт собирается из базы заказов. Выручка — только по <b>выданным</b> заказам, отменённые показываются отдельно и в выручку не входят.</span>
</div>
<div class="panel">
<div class="panel-title">Период</div>
<div class="report-controls">
<label>С <input type="date" id="repFrom" value="${esc(state.reportFrom)}"></label>
<label>По <input type="date" id="repTo" value="${esc(state.reportTo)}"></label>
<button class="btn btn-neon" data-rep="build" type="button">Построить отчёт</button>
<button class="btn btn-ghost" data-rep="all" type="button">Отчёт за всё время</button>
<button class="btn btn-ghost" data-rep="excel" type="button" title="Скачать отчёт за выбранный период в Excel (со сводкой, днями, часами, позициями, столами и сотрудниками)">⬇ Excel</button>
<button class="btn btn-ghost" data-rep="excel-days" type="button" title="Excel с отдельным листом на каждый день периода (лист «Сводка» + день за днём; период до 31 дня)">⬇ Excel по дням</button>
</div>
</div>
${r ? reportBlock(r) : '<div class="panel"><div class="stop-empty">Выберите период и нажмите «Построить отчёт»</div></div>'}`;
}
function reportBlock(r) {
const t = r.totals;
const dur = t.avg_duration_seconds != null ? fmtDur(t.avg_duration_seconds) : '—';
const days = r.by_day.map((d) => `
<div class="report-row">
<span>${esc(d.day)}</span><span>${d.orders}</span><span>${money(d.revenue)}</span><span>${d.cancelled}</span>
</div>`).join('');
const top = r.top_items.length
? r.top_items.map((i, idx) => `
<div class="report-row">
<span class="ri-name">${idx + 1}. ${esc(i.name)}</span><span>${i.qty} шт</span><span>${money(i.revenue)}</span>
</div>`).join('')
: '<div class="stop-empty">Нет проданных позиций за период</div>';
const src = r.by_source || {};
const totalOrders = Object.values(src).reduce((s, x) => s + (x.orders || 0), 0);
const totalRevenue = Object.values(src).reduce((s, x) => s + (x.revenue || 0), 0);
const sources = [['Сайт', src.site], ['Из зала', src.hall]]
.filter(([, s]) => s)
.map(([label, s]) => `
<div class="report-row three"><span>${label}</span><span>${s.orders}</span><span>${money(s.revenue)}</span></div>`)
.join('');
const sourcesTotal = (src.site || src.hall) ? `
<div class="report-row three"><span><b>Всего (сайт + зал)</b></span><span><b>${totalOrders}</b></span><span><b>${money(totalRevenue)}</b></span></div>`
: '';
const tables = (r.top_tables || []).map((x) => `
<div class="report-row three"><span class="ri-name">${esc(x.table)}</span><span>${x.orders}</span><span>${money(x.revenue)}</span></div>`).join('');
const hours = (r.by_hour || []).filter((h) => h.orders || h.revenue).map((h) => `
<div class="report-row three">
<span>${pad2(h.hour)}:00–${pad2((h.hour + 1) % 24)}:00</span><span>${h.orders}</span><span>${money(h.revenue)}</span>
</div>`).join('');
return `
<div class="stats-grid">
<div class="stat c-green"><div class="num">${money(t.revenue)}</div><div class="lbl">Выручка</div><div class="sub">выданные заказы</div></div>
<div class="stat c-cyan"><div class="num">${t.orders}</div><div class="lbl">Заказов всего</div><div class="sub">новых: ${t.new} · в работе: ${t.active}</div></div>
<div class="stat c-green"><div class="num">${t.done}</div><div class="lbl">Выдано</div><div class="sub">средний чек ${money(t.avg_check)}</div></div>
<div class="stat c-red"><div class="num">${t.cancelled}</div><div class="lbl">Отменено</div><div class="sub">${money(t.cancelled_amount)}</div></div>
<div class="stat c-cyan"><div class="num${dur !== '—' ? ' txt' : ''}">${dur}</div><div class="lbl">Средняя подача</div><div class="sub">от принятия до выдачи</div></div>
</div>
<div class="dash-grid">
<div class="panel">
<div class="panel-title">По дням</div>
<div class="report-table">
<div class="report-row head"><span>День</span><span>Заказов</span><span>Выручка</span><span>Отменено</span></div>
${days || '<div class="stop-empty">Нет заказов за период</div>'}
</div>
</div>
<div class="panel">
<div class="panel-title">Топ позиций <small>без отменённых</small></div>
<div class="report-table">${top}</div>
</div>
</div>
<div class="dash-grid">
<div class="panel">
<div class="panel-title">Источники заказов <small>без отменённых</small></div>
<div class="report-table">
<div class="report-row head three"><span>Источник</span><span>Заказов</span><span>Выручка</span></div>
${sources}${sourcesTotal}
</div>
</div>
<div class="panel">
<div class="panel-title">Топ столов и зон</div>
<div class="report-table">
<div class="report-row head three"><span>Стол / зона</span><span>Заказов</span><span>Выручка</span></div>
${tables || '<div class="stop-empty">Нет заказов за период</div>'}
</div>
</div>
</div>
<div class="panel" style="margin-top:16px">
<div class="panel-title">По часам <small>когда идёт поток — заказы без отменённых</small></div>
<div class="report-table">${hours || '<div class="stop-empty">Нет заказов за период</div>'}</div>
</div>
${staffReportBlock()}`;
}
/* статистика сотрудников за тот же период, что и основной отчёт */
function staffReportBlock() {
const s = state.staffReport;
if (!s || !Array.isArray(s.staff)) return '';
const roleLabel = { owner: 'Владелец', admin: 'Администратор', waiter: 'Официант', hookah: 'Кальянщик' };
const rows = s.staff.map((u) => `
<div class="report-row staff">
<span class="ri-name"><b>${esc(u.name)}</b><small> · ${esc(roleLabel[u.role] || u.role)}</small></span>
<span title="Смен за период · суммарное время">${u.shifts} см · ${fmtDur(u.hours_seconds)}</span>
<span title="Заказов: принято / выдано / отменено">${u.orders_total} / ${u.orders_done} / ${u.orders_cancelled}</span>
<span title="Выручка выданных · средний чек">${money(u.revenue)}${u.avg_check != null ? ' · ср. ' + money(u.avg_check) : ''}</span>
<span title="Средняя подача">${u.avg_duration_seconds != null ? fmtDur(u.avg_duration_seconds) : '—'}</span>
<span title="Заказов с сайта / из зала">🌐 ${u.site_orders} · 🏠 ${u.hall_orders}</span>
</div>`).join('');
return `
<div class="panel" style="margin-top:16px">
<div class="panel-title">Сотрудники за период <small>${esc(s.period ? s.period.from + ' — ' + s.period.to : '')}</small></div>
<div class="report-table">
<div class="report-row staff head">
<span>Сотрудник</span><span>Смены</span><span>Заказы</span><span>Выручка</span><span>Подача</span><span>Источники</span>
</div>
${rows || '<div class="stop-empty">Сотрудников нет</div>'}
</div>
</div>`;
}
/* ---------------- владелец/персонал: ручной заказ из зала ---------------- */
function viewNewOrder() {
const blocked = SHIFT_ROLES_JS.includes(state.role) && !state.onShift;
const isHookah = state.role === 'hookah';
const q = state.newOrderQuery.trim().toLowerCase();
// правка: «заказ кальяна» — только у кальянщика; у остальных в списке
// и в быстрой панели кальянных позиций нет
const items = state.menu.filter((m) => !m.deleted && !m.stopped &&
(isHookah || m.category !== 'КАЛЬЯНЫ'));
// фильтр по категории + поиск по названию
const cats = ['Все', ...new Set(items.map((m) => m.category))];
const list = items.filter((m) =>
(state.newOrderCat === 'Все' || m.category === state.newOrderCat) &&
(!q || m.name.toLowerCase().includes(q)));
const hookahs = isHookah ? items.filter((m) => m.id === 'hookah-1400' || m.id === 'hookah-1800') : [];
const priceOf = (id) => { const m = state.menu.find((x) => x.id === id); return m ? m.price : 0; };
const nameOf = (id) => { const m = state.menu.find((x) => x.id === id); return m ? m.name : id; };
const orderIds = Object.keys(state.newOrder.items);
const orderRows = orderIds.map((id) => {
const qty = state.newOrder.items[id];
return `
<div class="mini-order">
<span class="mo-num">${esc(nameOf(id))}</span>
<span class="mo-info">${money(priceOf(id))} × ${qty} = ${money(priceOf(id) * qty)}</span>
<span class="no-qty">
<button class="btn btn-ghost btn-sm" data-noqty="${esc(id)}:-1" type="button">−</button>
<b>${qty}</b>
<button class="btn btn-ghost btn-sm" data-noqty="${esc(id)}:1" type="button">+</button>
</span>
</div>`;
}).join('');
const total = orderIds.reduce((s, id) => s + priceOf(id) * state.newOrder.items[id], 0);
// без стола заказ не создаётся: кнопка активна, но по клику показывает ошибку
const tableError = state.newOrderError === 'table';
const itemsError = state.newOrderError === 'items';
return `
${blocked ? `<div class="note"><span class="n-ico">🔒</span>
<span>Смена закрыта — создание заказов заблокировано. Выйдите на смену из шапки.</span></div>` : ''}
<div class="dash-grid">
<div style="display:flex;flex-direction:column;gap:16px">
${isHookah ? `
<div class="panel">
<div class="panel-title">Кальяны</div>
<div class="chips">
${hookahs.map((h) => `<button class="btn btn-neon" data-noadd="${esc(h.id)}" type="button">+ ${esc(h.name)}${h.portion ? ' · ' + esc(h.portion) : ''}</button>`).join('')}
</div>
</div>` : ''}
<div class="panel ${itemsError ? 'panel-error' : ''}" id="newOrderMenu">
<div class="panel-title">Меню</div>
<div class="toolbar">
<div class="chips">
${cats.map((c) => `<button class="chip ${state.newOrderCat === c ? 'active' : ''}" data-nocat="${esc(c)}" type="button">${esc(c)}</button>`).join('')}
</div>
<div class="search"><input type="text" id="noSearch" placeholder="Поиск по названию…" value="${esc(state.newOrderQuery)}"></div>
</div>
<div class="menu-list" style="max-height:430px;overflow:auto">
${list.map((m) => `
<div class="menu-row">
<div>
<div class="m-name">${esc(m.name)}</div>
<div class="m-cat">${esc(m.category)}${m.portion ? ' · ' + esc(m.portion) : ''}${m.stock != null ? ` · 📦 осталось ${m.stock} шт` : ''}</div>
</div>
<div class="m-price">${money(m.price)}</div>
<button class="btn btn-ghost btn-sm" data-noadd="${esc(m.id)}" type="button">+ Добавить</button>
</div>`).join('') || '<div class="stop-empty">Ничего не найдено</div>'}
</div>
</div>
${itemsError ? '<div class="form-error" role="alert">⚠ добавьте хотя бы одну позицию из меню</div>' : ''}
</div>
<div style="display:flex;flex-direction:column;gap:16px">
<div class="panel ${tableError ? 'panel-error' : ''}" id="newOrderTables">
<div class="panel-title">Стол / зал</div>
<div class="chips">
${halls.map((h) => {
const busy = hallByName(h.name)?.busy;
return `<button class="chip ${state.newOrder.table === h.name ? 'active' : ''} ${busy ? 'chip-busy' : ''}"
data-notable="${esc(h.name)}" type="button" title="${busy ? 'стол занят' : 'свободен'}">${esc(h.name)}${busy ? ' · занят' : ''}</button>`;
}).join('')}
</div>
</div>
${tableError ? '<div class="form-error" role="alert">⚠ выберите стол или зал — без него заказ не создать</div>' : ''}
<div class="panel">
<div class="panel-title">Ваш заказ <small>${esc(state.newOrder.table || 'стол не выбран')}</small></div>
<div class="mini-orders">${orderRows || '<div class="stop-empty">Добавьте позиции из меню</div>'}</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:12px">
<span class="m-name">Итого</span><span class="o-total">${money(total)}</span>
</div>
<textarea id="noComment" class="order-comment" maxlength="300" rows="2"
placeholder="Комментарий к заказу: пожелания гостя, крепость кальяна, куда ставить…">${esc(state.newOrder.comment)}</textarea>
<button class="btn btn-neon btn-block" style="margin-top:12px" data-nocreate="1" type="button"
${blocked ? 'disabled' : ''}>Создать заказ</button>
</div>
</div>
</div>`;
}
/* ---------------- рендер ---------------- */
/* Значения полей форм переживают перерисовку (опрос заказов рендерит каждые 10 с) */
const FORM_FIELD_IDS = [
'newUserName', 'newUserLogin', 'newUserPass', 'newUserRole',
'newMenuName', 'newMenuCat', 'newMenuPrice', 'newMenuPortion', 'newMenuStock',
'newTobBrand', 'newTobFlavor', 'newTobNote', 'newTobStrength', 'newTobCooling',
'newMixName', 'newMixNote', 'newMixStrength', 'newMixCooling', 'newMixPrice',
'repFrom', 'repTo', 'noSearch', 'noComment',
'ntTitle', 'ntBody', 'ntAssignee', 'ntDue', 'ntImportant',
'nbTitle', 'nbAdmin',
'newGameKind', 'newGameTitle', 'newGameNote', 'newGameDifficulty',
'newHallName', 'newHallSub',
];
function preserveFormValues() {
const saved = {};
for (const id of FORM_FIELD_IDS) {
const el = document.getElementById(id);
if (!el) continue;
saved[id] = el.type === 'checkbox' ? el.checked : el.value;
}
return () => {
for (const id of FORM_FIELD_IDS) {
const el = document.getElementById(id);
if (!el || !(id in saved)) continue;
if (el.type === 'checkbox') el.checked = saved[id];
else el.value = saved[id];
}
};
}
function render() {
if (!state.role) return;
// перерисовка не должна выбивать фокус: опрос заказов дёргает render()
// каждые 10 с, а официант в это время печатает комментарий к заказу
const focused = document.activeElement;
const focusKind = focused && focused.id && /^(INPUT|TEXTAREA|SELECT)$/.test(focused.tagName) ? focused.tagName : null;
const focusId = focusKind ? focused.id : null;
const caretStart = focusKind && focused.selectionStart != null ? focused.selectionStart : null;
const caretEnd = caretStart != null ? focused.selectionEnd : null;
renderChrome();
renderTopbar();
renderShiftActions();
const root = document.getElementById('viewRoot');
const restoreForms = preserveFormValues();
switch (state.view) {
case 'dashboard': root.innerHTML = viewDashboard(); break;
case 'menu': root.innerHTML = viewMenu(); break;
case 'tobacco': root.innerHTML = viewTobacco(); break;
case 'orders':
root.innerHTML = SHIFT_ROLES_JS.includes(state.role) ? viewWaiterOrders() : viewAdminOrders(); break;
case 'stoplist': root.innerHTML = viewStopList(); break;
case 'halls': root.innerHTML = viewHalls(); break;
case 'mixes': root.innerHTML = viewMixes(); break;
case 'users': root.innerHTML = viewUsers(); break;
case 'reports': root.innerHTML = viewReports(); break;
case 'neworder': root.innerHTML = viewNewOrder(); break;
case 'notes': root.innerHTML = viewNotes(); break;
case 'games': root.innerHTML = viewGames(); break;
}
restoreForms();
if (focusId) {
const el = document.getElementById(focusId);
if (el && focusKind === el.tagName) {
// preventScroll обязателен: обычный focus() скроллит к элементу, и
// каждые 10 секунд страницу уносило к сфокусированной кнопке наверху
el.focus({ preventScroll: true });
if (caretStart != null) { try { el.setSelectionRange(caretStart, caretEnd); } catch (e) { /* тип поля не даёт caret */ } }
}
}
updateScrollNav();
}
/* ---------------- действия ---------------- */
async function setMenuAccent(itemId, accent) {
try {
const updated = await api('/api/menu/' + encodeURIComponent(itemId), {
method: 'PATCH', body: JSON.stringify({ accent }),
});
const i = state.menu.findIndex((m) => m.id === itemId);
if (i !== -1) state.menu[i] = { ...state.menu[i], accent: updated.accent };
toast(updated.accent
? `Подсветка «${updated.name}» обновится на сайте через минуту`
: `«${updated.name}» — снова как у всех`, 't-green');
} catch (e) {
if (e.message !== 'session expired') toast('Ошибка: ' + e.message, 't-red');
}
render();
}
/* сброс нумерации заказов: следующий заказ получит №1 (только владелец) */
async function resetOrderNumbering() {
if (!(await uiConfirm({
title: 'Сбросить нумерацию заказов?',
text: 'Следующий заказ получит №1, дальше 2, 3, …\nДействует до конца текущей недели нумерации (во вторник номера и так обнуляются).',
ok: 'Сбросить',
}))) return;
try {
const r = await api('/api/orders/numbering/reset', { method: 'POST' });
toast(`Нумерация сброшена — следующий заказ будет №${r.next_number}`, 't-green');
} catch (e) {
if (e.message !== 'session expired') toast('Ошибка: ' + e.message, 't-red');
}
}
async function toggleMenuStop(id, stopped) {
try {
const updated = await api('/api/menu/' + encodeURIComponent(id), {
method: 'PATCH',
body: JSON.stringify({ stopped }),
});
const i = state.menu.findIndex((m) => m.id === id);
if (i !== -1) state.menu[i] = updated;
toast(updated.stopped ? `«${updated.name}» — в стопе, скрыто с сайта` : `«${updated.name}» снова доступна`,
updated.stopped ? 't-red' : 't-green');
} catch (e) {
if (e.message !== 'session expired') toast('Не удалось изменить стоп-лист: ' + e.message, 't-red');
}
render();
}
async function toggleTobaccoStop(id, stopped) {
try {
const updated = await api('/api/tobacco/' + encodeURIComponent(id), {
method: 'PATCH',
body: JSON.stringify({ stopped }),
});
const i = state.tobacco.findIndex((t) => t.id === id);
if (i !== -1) state.tobacco[i] = updated;
toast(updated.stopped ? `Вкус «${updated.flavor}» (${updated.brand}) — в стопе` : `Вкус «${updated.flavor}» снова доступен`,
updated.stopped ? 't-red' : 't-green');
} catch (e) {
if (e.message !== 'session expired') toast('Не удалось изменить стоп-лист: ' + e.message, 't-red');
}
render();
}
/* владелец: мягкое удаление и восстановление позиций меню и вкусов */
async function setCatalogItemDeleted(kind, id, deleted) { const path = (kind === 'menu' ? '/api/menu/' : '/api/tobacco/') + id;
try {
const updated = await api(path, { method: deleted ? 'DELETE' : 'PATCH', body: JSON.stringify({ deleted }) });
if (kind === 'menu') {
const i = state.menu.findIndex((m) => m.id === id);
if (i !== -1) state.menu[i] = updated;
toast(deleted ? `«${updated.name}» удалена — на сайте позиция скрыта` : `«${updated.name}» восстановлена`,
deleted ? 't-red' : 't-green');
} else {
const i = state.tobacco.findIndex((t) => t.id === id);
if (i !== -1) state.tobacco[i] = updated;
toast(deleted ? `Вкус «${updated.flavor}» удалён — на сайте скрыт` : `Вкус «${updated.flavor}» восстановлен`,
deleted ? 't-red' : 't-green');
}
} catch (e) {
if (e.message !== 'session expired') toast('Ошибка: ' + e.message, 't-red');
}
render();
}
function deleteCatalogItem(kind, id) {
setCatalogItemDeleted(kind, id, true);
}
function restoreCatalogItem(kind, id) {
setCatalogItemDeleted(kind, id, false);
}
/* владелец: безвозвратное удаление позиции меню (только кастомной —
стартовые позиции сайта восстанавливает сид при рестарте) */
async function purgeCatalogItem(kind, id) {
try {
await api(`/api/${kind}/${encodeURIComponent(id)}?purge=1`, { method: 'DELETE' });
if (kind === 'menu') state.menu = state.menu.filter((m) => m.id !== id);
else if (kind === 'tobacco') state.tobacco = state.tobacco.filter((t) => t.id !== id);
else if (kind === 'mixes') await refreshMixes();
toast('Позиция удалена из базы безвозвратно', 't-red');
} catch (e) {
if (e.message !== 'session expired') toast('Ошибка: ' + e.message, 't-red');
}
render();
}
/* ---------------- поштучный учёт (владелец) ---------------- */
/* Пиво, энергетики и прочие баночные товары: владелец вбивает купленное
количество, при выдаче заказа остаток списывается сам, а по нулю
позиция уходит в стоп-лист (см. storage.update_order). */
async function patchMenuStock(id, body, msg) {
try {
const updated = await api('/api/menu/' + encodeURIComponent(id), {
method: 'PATCH', body: JSON.stringify(body),
});
const i = state.menu.findIndex((m) => m.id === id);
if (i !== -1) state.menu[i] = { ...state.menu[i], ...updated };
toast(msg(updated), 't-green');
} catch (e) {
if (e.message !== 'session expired') toast('Ошибка: ' + e.message, 't-red');
}
render();
}
/* включить учёт / задать новый общий остаток */
async function menuStockOn(id) {
const m = state.menu.find((x) => x.id === id);
if (!m) return;
const raw = await uiPrompt({
title: `Поштучный учёт «${m.name}»`,
text: 'Сколько штук на полке сейчас? (например 20)',
value: m.stock != null ? String(m.stock) : '20',
numeric: true,
ok: 'Сохранить',
});
if (raw === null) return;
const n = Math.floor(Number(String(raw).trim()));
if (!Number.isFinite(n) || n < 0) {
toast('Укажите целое количество штук (0 и больше)', 't-red');
return;
}
patchMenuStock(id, { stock: n },
(u) => `«${u.name}»: учёт включён, на полке ${u.stock} шт`);
}
/* приход: докупили N банок — прибавить к остатку */
async function menuStockAdd(id) {
const m = state.menu.find((x) => x.id === id);
if (!m) return;
const raw = await uiPrompt({
title: `Приход: «${m.name}»`,
text: `На полке ${m.stock} шт. Сколько штук докупили?`,
value: '20',
numeric: true,
ok: 'Добавить',
});
if (raw === null) return;
const n = Math.floor(Number(String(raw).trim()));
if (!Number.isFinite(n) || n <= 0) {
toast('Укажите целое количество штук (больше нуля)', 't-red');
return;
}
patchMenuStock(id, { stock_add: n },
(u) => `«${u.name}»: приход ${n} шт, на полке ${u.stock} шт${u.stopped ? '' : ' — снова в продаже'}`);
}
/* выключить учёт: позиция перестаёт быть штучной */
async function menuStockOff(id) {
const m = state.menu.find((x) => x.id === id);
if (!m) return;
if (!(await uiConfirm({
title: 'Выключить поштучный учёт?',
text: `Для «${m.name}». Остаток (${m.stock} шт) будет забыт.`,
ok: 'Выключить',
}))) return;
patchMenuStock(id, { stock: null }, (u) => `«${u.name}»: поштучный учёт выключен`);
}
/* ---------------- ручной заказ из зала ---------------- */
function newOrderAdd(id) {
state.newOrder.items[id] = (state.newOrder.items[id] || 0) + 1;
if (state.newOrderError === 'items') state.newOrderError = '';
render();
}
function newOrderQty(id, delta) {
const qty = (state.newOrder.items[id] || 0) + delta;
if (qty <= 0) delete state.newOrder.items[id];
else state.newOrder.items[id] = qty;
if (!Object.keys(state.newOrder.items).length && state.newOrderError === 'items') {
state.newOrderError = '';
}
render();
}
async function newOrderCreate() {
const items = Object.entries(state.newOrder.items).map(([id, qty]) => ({ id, qty }));
// без стола заказ не создаём: показываем, где именно незаполнено
if (!state.newOrder.table || !items.length) {
state.newOrderError = state.newOrder.table ? 'items' : 'table';
render();
const el = document.getElementById(state.newOrderError === 'table' ? 'newOrderTables' : 'newOrderMenu');
if (el) el.scrollIntoView({ block: 'center', behavior: 'smooth' });
toast('Заказ не создан: выберите стол и добавьте позиции', 't-red');
return;
}
state.newOrderError = '';
try {
const order = await api('/api/orders/manual', {
method: 'POST',
body: JSON.stringify({
table: state.newOrder.table,
items,
comment: state.newOrder.comment.trim() || undefined,
}),
});
toast(`Заказ №${order.number} создан — ${money(order.price)}`, 't-green');
state.newOrder = { table: '', items: {}, comment: '' };
} catch (e) {
if (e.message !== 'session expired') toast('Ошибка: ' + e.message, 't-red');
}
render();
}
/* ---------------- залы: занять / продлить / освободить ---------------- */
async function hallManage(name, busy, minutes) {
try {
await api('/api/halls/manage', {
method: 'POST',
body: JSON.stringify({ name, busy, minutes }),
});
toast(busy
? `${name} занят${minutes ? ` на ${minutes} мин` : ' — без таймера'}`
: `${name} освобождён`, busy ? 't-red' : 't-green');
} catch (e) {
if (e.message !== 'session expired') toast('Ошибка: ' + e.message, 't-red');
}
await refreshHalls();
render();
}
/* диалог «до какого времени»: «90» — минуты, «23:30» — время */
async function hallAskMinutes(name, busy) {
const what = busy ? `На сколько занять «${name}»?` : `Добавить время таймеру «${name}»`;
const raw = await uiPrompt({
title: what,
text: 'Минуты (например 90) или время (например 01:30):',
value: busy ? '60' : '30',
ok: 'Готово',
});
if (raw === null) return;
const minutes = parseUntilMinutes(raw);
if (!minutes) {
toast('Не понял время — укажите минуты (5–720) или ЧЧ:ММ', 't-red');
return;
}
if (minutes > 720) {
toast('Максимум 720 минут (12 часов)', 't-red');
return;
}
hallManage(name, true, minutes);
}
/* бронь стола: «со скольки» и «до скольки»; 01:30 означает следующие сутки */
async function hallAskReserve(name) {
const rawFrom = await uiPrompt({
title: `Бронь «${name}» — со скольки?`,
text: 'ЧЧ:ММ, например 21:00',
value: '21:00',
ok: 'Далее',
});
if (rawFrom === null) return;
const rawUntil = await uiPrompt({
title: `Бронь «${name}» — до скольки?`,
text: 'ЧЧ:ММ, например 23:00 (01:30 — следующие сутки)',
value: '23:00',
ok: 'Далее',
});
if (rawUntil === null) return;
const guestRaw = await uiPrompt({
title: 'Имя гостя',
text: 'Можно оставить пустым',
value: '',
ok: 'Забронировать',
});
const guest = guestRaw === null ? '' : guestRaw;
try {
await api('/api/halls/reserve', {
method: 'POST',
body: JSON.stringify({
name,
from: rawFrom.trim(),
until: rawUntil.trim(),
guest: guest.trim() || undefined,
}),
});
toast(`Бронь «${name}» на ${rawFrom.trim()}–${rawUntil.trim()} создана`, 't-green');
} catch (e) {
if (e.message !== 'session expired') toast('Не удалось создать бронь: ' + e.message, 't-red');
}
await refreshHalls();
render();
}
async function hallReserveCancel(id) {
if (!(await uiConfirm({ title: 'Отменить эту бронь?', ok: 'Да, отменить' }))) return;
try {
await api('/api/halls/reservations/' + id, { method: 'DELETE' });
toast('Бронь отменена', 't-red');
} catch (e) {
if (e.message !== 'session expired') toast('Ошибка: ' + e.message, 't-red');
}
await refreshHalls();
render();
}
/* ---------------- справочник столов и залов (владелец) ---------------- */
/* значения полей переживают перерисовку (restoreForms) — после добавления
чистим и DOM, как у формы микса */
function syncHallFormDom() {
const a = document.getElementById('newHallName');
const b = document.getElementById('newHallSub');
if (a) a.value = '';
if (b) b.value = '';
}
async function addHall() {
const name = document.getElementById('newHallName').value.trim();
const sub = document.getElementById('newHallSub').value.trim();
if (!name) {
toast('Укажите название стола или зала', 't-red');
return;
}
try {
const h = await api('/api/halls', {
method: 'POST',
body: JSON.stringify({ name, sub: sub || undefined }),
});
toast(`«${h.name}» добавлен — виден в залах CRM и в выборе столика на сайте`, 't-green');
await refreshHalls();
render();
syncHallFormDom();
} catch (e) {
if (e.message !== 'session expired') toast('Ошибка: ' + e.message, 't-red');
}
}
async function deleteHall(name) {
if (!(await uiConfirm({
title: `Удалить «${name}»?`,
text: 'Стол исчезнет из залов CRM и из выбора столика на сайте. История броней и занятости останется в базе.',
ok: 'Удалить',
}))) return;
try {
await api('/api/halls/' + encodeURIComponent(name), { method: 'DELETE' });
toast(`«${name}» удалён`, 't-red');
await refreshHalls();
} catch (e) {
if (e.message !== 'session expired') toast('Ошибка: ' + e.message, 't-red');
}
render();
}
/* ---------------- смены официантов ---------------- */
async function refreshShifts() {
try {
const data = await api('/api/shifts/current');
state.onShiftList = data.on_shift || [];
state.staffList = data.staff || [];
state.onShift = SHIFT_ROLES_JS.includes(state.role) &&
state.onShiftList.some((s) => s.login === state.login);
renderShiftActions();
} catch { /* сеть моргнула — попробуем в следующий тик */ }
}
async function clockIn() {
try {
await api('/api/shifts/clock-in', { method: 'POST' });
toast('Вы вышли на смену — управления разблокированы', 't-green');
} catch (e) {
if (e.message !== 'session expired') toast(e.message, 't-red');
}
await refreshShifts();
render();
}
async function clockOut() {
try {
await api('/api/shifts/clock-out', { method: 'POST' });
toast('Смена завершена. Хорошего отдыха!', 't-green');
} catch (e) {
if (e.message !== 'session expired') toast(e.message, 't-red');
}
await refreshShifts();
render();
}
/* владелец/админ вручную записывает или снимает официанта/кальянщика */
async function shiftManage(login, action) {
const name = (state.staffList.find((s) => s.login === login) || {}).name || login;
try {
await api('/api/shifts/manage', {
method: 'POST',
body: JSON.stringify({ login, action }),
});
toast(action === 'in'
? `${name} записан на смену`
: `${name} снят со смены`, action === 'in' ? 't-green' : 't-red');
} catch (e) {
if (e.message !== 'session expired') toast('Ошибка: ' + e.message, 't-red');
}
await refreshShifts();
render();
}
/* ---------------- загрузка фото позиции ---------------- */
async function uploadPhoto(itemId, file) {
try {
const r = await fetch('/api/menu/' + encodeURIComponent(itemId) + '/photo', {
method: 'POST',
headers: {
'Content-Type': file.type || 'application/octet-stream',
'X-Auth-Token': localStorage.getItem(TOKEN_KEY),
},
body: file,
});
if (!r.ok) {
const err = await r.json().catch(() => ({}));
throw new Error(err.error || 'HTTP ' + r.status);
}
toast('Фото загружено — на сайте появится через минуту', 't-green');
state.menu = await api('/api/menu');
} catch (e) {
if (e.message !== 'session expired') toast('Ошибка загрузки фото: ' + e.message, 't-red');
}
render();
}
async function patchOrder(id, body, successMsg) {
try {
const updated = await api('/api/orders/' + id, {
method: 'PATCH',
body: JSON.stringify(body),
});
const i = state.orders.findIndex((x) => x.id === Number(id));
if (i !== -1) state.orders[i] = updated;
toast(successMsg, body.status === 'done' ? 't-green' : '');
} catch (e) {
if (e.message !== 'session expired') toast('Ошибка: ' + e.message, 't-red');
}
render();
}
/* недельный номер заказа для сообщений (id в API — внутренний) */
function orderNumber(id) {
const o = state.orders.find((x) => x.id === Number(id));
return o ? o.number : id;
}
function acceptOrder(id) {
patchOrder(id, { status: 'active', waiter: state.name }, `Заказ №${orderNumber(id)} принят в работу`);
}
/* быстрый заказ «приняли — сразу отдали»: один тап вместо двух.
Метки «принят» не будет (нечего мерить) — отчёт корректно пропустит
такой заказ в средней подаче */
function fastDoneOrder(id) {
patchOrder(id, { status: 'done' }, `Заказ №${orderNumber(id)} выдан сразу ✓`);
}
function deliverOrder(id) {
patchOrder(id, { status: 'done' }, `Заказ №${orderNumber(id)} выдан ✓`);
}
async function cancelOrder(id) {
if (!(await uiConfirm({
title: `Отменить заказ №${orderNumber(id)}?`,
text: 'Действие необратимо.',
ok: 'Да, отменить',
}))) return;
patchOrder(id, { status: 'cancelled' }, `Заказ №${orderNumber(id)} отменён`);
}
/* администратор меняет статус произвольно */
function setOrderStatus(id, status) {
const o = state.orders.find((x) => x.id === Number(id));
if (!o || o.status === status) return;
patchOrder(id, { status }, `Заказ №${o.number}: статус «${STATUS[status].label}»`);
}
/* ---------------- владелец: сотрудники и отчёты ---------------- */
async function loadUsers() {
try {
const [users, counts] = await Promise.all([api('/api/users'), api('/api/shifts/counts')]);
state.users = users;
state.shiftCounts = counts;
} catch (e) {
if (e.message !== 'session expired') toast('Не удалось загрузить сотрудников: ' + e.message, 't-red');
}
render();
}
async function addUser(form) {
const body = {
name: document.getElementById('newUserName').value.trim(),
login: document.getElementById('newUserLogin').value.trim(),
password: document.getElementById('newUserPass').value,
role: document.getElementById('newUserRole').value,
};
try {
const created = await api('/api/users', { method: 'POST', body: JSON.stringify(body) });
toast(`Сотрудник ${created.name} (${created.login}) создан`, 't-green');
form.reset();
state.users = await api('/api/users');
} catch (e) {
if (e.message !== 'session expired') toast('Ошибка: ' + e.message, 't-red');
}
render();
}
async function deleteUser(login) {
if (!(await uiConfirm({
title: `Удалить сотрудника ${login}?`,
text: 'Доступ будет отозван сразу.',
ok: 'Удалить',
}))) return;
try {
await api('/api/users/' + encodeURIComponent(login), { method: 'DELETE' });
toast(`Сотрудник ${login} удалён`, 't-red');
state.users = await api('/api/users');
} catch (e) {
if (e.message !== 'session expired') toast('Ошибка: ' + e.message, 't-red');
}
render();
}
/* Владелец задаёт сотруднику новый пароль (текущий не требуется) */
async function resetUserPassword(login) {
const pass = await uiNewPassword({
title: `Новый пароль для ${login}`,
text: 'Текущий пароль не нужен. Открытые сессии сотрудника закроются — он войдёт заново с новым паролем.',
ok: 'Сбросить',
});
if (!pass) return;
if (pass.length < 8) { toast('Пароль — минимум 8 символов', 't-red'); return; }
try {
await api('/api/users/password', {
method: 'POST',
body: JSON.stringify({ login, new_password: pass }),
});
toast(`Пароль ${login} изменён`, 't-green');
} catch (e) {
if (e.message !== 'session expired') toast('Ошибка: ' + e.message, 't-red');
}
}
async function buildReport() {
try {
const qs = `from=${state.reportFrom}&to=${state.reportTo}`;
const [report, staff] = await Promise.all([
api(`/api/reports/orders?${qs}`),
api(`/api/reports/staff?${qs}`),
]);
state.report = report;
state.staffReport = staff;
} catch (e) {
if (e.message !== 'session expired') toast('Не удалось построить отчёт: ' + e.message, 't-red');
}
render();
}
/* preserveFormValues возвращает в поля введённое после любой перерисовки,
поэтому новые даты периода нужно после render() прописать и в DOM */
function syncReportRangeDom() {
const f = document.getElementById('repFrom');
const t = document.getElementById('repTo');
if (f) f.value = state.reportFrom;
if (t) t.value = state.reportTo;
}
/* отчёт с самого первого заказа по сегодня: даты периода отдаёт сервер */
async function buildAllTimeReport() {
try {
const [report, staff] = await Promise.all([
api('/api/reports/orders?all=1'),
api('/api/reports/staff?all=1'),
]);
state.report = report;
state.staffReport = staff;
if (state.report.period) {
state.reportFrom = state.report.period.from;
state.reportTo = state.report.period.to;
}
} catch (e) {
if (e.message !== 'session expired') toast('Не удалось построить отчёт: ' + e.message, 't-red');
}
render();
syncReportRangeDom();
}
/* выгрузка отчёта в Excel (файл собирает сервер); byDay — отдельный
лист на каждый день периода (сервер ограничивает период 31 днём) */
async function downloadReportExcel(byDay = false) {
try {
const qs = `from=${state.reportFrom}&to=${state.reportTo}` + (byDay ? '&by_day=1' : '');
const r = await fetch(`/api/reports/orders/excel?${qs}`, {
headers: { 'X-Auth-Token': localStorage.getItem(TOKEN_KEY) || '' },
});
if (!r.ok) {
let msg = 'HTTP ' + r.status;
try {
const e = await r.json();
if (e && e.error) msg = e.error;
} catch { /* не JSON */ }
throw new Error(msg);
}
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `neon-report${byDay ? '-days' : ''}_${state.reportFrom}_${state.reportTo}.xlsx`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 4000);
toast(byDay ? 'Excel по дням скачан — лист на каждый день' : 'Excel-отчёт скачан', 't-green');
} catch (e) {
toast('Не удалось скачать отчёт: ' + e.message, 't-red');
}
}
async function addMenuItem(form) {
const body = {
name: document.getElementById('newMenuName').value.trim(),
category: document.getElementById('newMenuCat').value,
price: Number(document.getElementById('newMenuPrice').value),
portion: document.getElementById('newMenuPortion').value.trim(),
};
// необязательное количество: заполнено — включаем поштучный учёт сразу
const stockRaw = document.getElementById('newMenuStock').value.trim();
if (stockRaw !== '') body.stock = Number(stockRaw);
try {
const item = await api('/api/menu', { method: 'POST', body: JSON.stringify(body) });
toast(`«${item.name}» добавлена — на сайте появится с пометкой «новинка»` +
(item.stock != null ? ` · учёт: ${item.stock} шт` : ''), 't-green');
form.reset();
state.menu = await api('/api/menu');
} catch (e) {
if (e.message !== 'session expired') toast('Ошибка: ' + e.message, 't-red');
}
render();
}
/* правка позиции меню (только владелец, только позиции из CRM):
та же форма, что у добавления — с предзаполнением и PATCH вместо POST */
function menuEditOpen(id) {
const m = state.menu.find((x) => x.id === id);
if (!m) return;
state.menuEditId = id;
render();
// предзаполняем после render(): restoreFormValues внутри него вернул бы
// прежние значения формы, поэтому значения ставим поверх
const set = (fieldId, v) => {
const el = document.getElementById(fieldId);
if (el) el.value = v;
};
set('newMenuName', m.name);
set('newMenuCat', m.category);
set('newMenuPrice', m.price);
set('newMenuPortion', m.portion || '');
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function menuFormReset() {
state.menuEditId = null;
render();
const form = document.getElementById('menuForm');
if (form) form.reset();
}
async function saveMenuItem(form) {
const body = {
name: document.getElementById('newMenuName').value.trim(),
category: document.getElementById('newMenuCat').value,
price: Number(document.getElementById('newMenuPrice').value),
portion: document.getElementById('newMenuPortion').value.trim(),
};
try {
const item = await api('/api/menu/' + encodeURIComponent(state.menuEditId), {
method: 'PATCH',
body: JSON.stringify(body),
});
toast(`«${item.name}» обновлена — изменения уже на сайте`, 't-green');
state.menuEditId = null;
form.reset();
state.menu = await api('/api/menu');
} catch (e) {
if (e.message !== 'session expired') toast('Ошибка: ' + e.message, 't-red');
}
render();
}
async function addTobacco(form) {
const body = {
brand: document.getElementById('newTobBrand').value.trim(),
flavor: document.getElementById('newTobFlavor').value.trim(),
note: document.getElementById('newTobNote').value.trim(),
strength: Number(document.getElementById('newTobStrength').value),
cooling: document.getElementById('newTobCooling').checked,
};
try {
const item = await api('/api/tobacco', { method: 'POST', body: JSON.stringify(body) });
toast(`Вкус «${item.flavor}» (${item.brand}) добавлен — на сайте появится как новинка`, 't-green');
form.reset();
state.tobacco = await api('/api/tobacco');
} catch (e) {
if (e.message !== 'session expired') toast('Ошибка: ' + e.message, 't-red');
}
render();
}
/* ---------------- миксы владельца: создание и удаление ---------------- */
function mixToggleFlavor(id) {
const ids = state.newMix.flavorIds;
if (ids.includes(id)) {
// в правке витринного микса состав можно и очистить — пусто = исходный
if (ids.length <= 1 && state.siteMixEditId == null) {
toast('В миксе должно остаться хотя бы два вкуса', 't-red');
return;
}
state.newMix.flavorIds = ids.filter((x) => x !== id);
} else {
if (ids.length >= 5) {
toast('Максимум пять вкусов в миксе', 't-red');
return;
}
state.newMix.flavorIds = [...ids, id];
}
if (state.mixErrors.flavors) delete state.mixErrors.flavors;
render();
}
/* теги микса: максимум три — по ним сайт подбирает микс в автоподбор */
function mixToggleTag(tag) {
const tags = state.newMix.tags;
if (tags.includes(tag)) {
state.newMix.tags = tags.filter((x) => x !== tag);
} else {
if (tags.length >= 3) {
toast('Максимум три тега на микс', 't-red');
return;
}
state.newMix.tags = [...tags, tag];
}
if (state.mixErrors.tags) delete state.mixErrors.tags;
render();
}
/* значения полей формы микса переживают перерисовку (restoreForms),
поэтому после сброса формы чистим и state, и DOM — как у заметок */
function syncMixFormDom() {
const f = state.newMix;
const set = (id, v) => {
const el = document.getElementById(id);
if (el) el.value = v;
};
set('newMixName', f.name);
set('newMixNote', f.note);
set('newMixStrength', f.strength);
set('newMixCooling', f.cooling);
set('newMixPrice', f.price);
}
/* проверка формы микса: обязательны все поля, кроме заметки.
Возвращает словарь «ключ поля → текст ошибки» */
function validateMixForm() {
const m = state.newMix;
const errors = {};
if (!m.name.trim()) errors.newMixName = 'укажите название микса';
if (m.flavorIds.length < 2) errors.flavors = 'выберите 2–5 вкусов из списка ниже';
if (!m.tags.length) errors.tags = 'выберите хотя бы один тег — по ним микс попадает в автоподбор';
if (m.strength === '') errors.newMixStrength = 'выберите крепость микса';
if (m.cooling === '') errors.newMixCooling = 'выберите уровень холодка';
if (m.price === '' || !Number.isFinite(Number(m.price)) || Number(m.price) < 0) {
errors.newMixPrice = 'укажите цену в рублях';
}
return errors;
}
/* прокрутить форму к первой ошибке и подсветить её */
function focusMixError(errors) {
const firstKey = Object.keys(errors)[0];
const targetId = { flavors: 'mixFlavorChips', tags: 'mixTagChips' }[firstKey] || firstKey;
const el = document.getElementById(targetId);
if (!el) return;
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
if (/^(INPUT|SELECT)$/.test(el.tagName)) el.focus({ preventScroll: true });
}
async function addMix() {
const errors = validateMixForm();
if (Object.keys(errors).length) {
state.mixErrors = errors;
render();
focusMixError(errors);
toast('Микс не добавлен: заполните выделенные поля формы', 't-red');
return;
}
const name = state.newMix.name.trim();
const opt = (v) => (v === '' ? undefined : Number(v));
try {
const mix = await api('/api/mixes', {
method: 'POST',
body: JSON.stringify({
name,
note: state.newMix.note.trim() || undefined,
flavor_ids: state.newMix.flavorIds,
strength: opt(state.newMix.strength),
cooling: opt(state.newMix.cooling),
price: opt(state.newMix.price),
tags: state.newMix.tags.length ? state.newMix.tags : undefined,
}),
});
toast(`Микс «${mix.name}» добавлен — виден на сайте`, 't-green');
// полная очистка формы: и state, и DOM (restoreForms вернул бы старое)
state.newMix = { name: '', note: '', flavorIds: [], strength: '', cooling: '', price: '', tags: [] };
state.mixErrors = {};
state.mixQuery = '';
state.mixBrand = 'Все';
await refreshMixes();
render();
syncMixFormDom();
} catch (e) {
if (e.message !== 'session expired') toast('Ошибка: ' + e.message, 't-red');
}
render();
}
async function deleteMix(id) {
const mix = state.mixes.find((m) => m.id === id);
if (!mix) return;
if (!(await uiConfirm({
title: `Удалить микс «${mix.name}»?`,
text: 'Он исчезнет с сайта.',
ok: 'Удалить',
}))) return;
try {
await api('/api/mixes/' + encodeURIComponent(id), { method: 'DELETE' });
toast(`Микс «${mix.name}» удалён с сайта`, 't-red');
await refreshMixes();
} catch (e) {
if (e.message !== 'session expired') toast('Ошибка: ' + e.message, 't-red');
}
render();
}
/* правка микса (владелец/кальянщик): та же форма, что у добавления —
с предзаполнением и PATCH вместо POST */
function mixEditOpen(id) {
const m = state.mixes.find((x) => x.id === id);
if (!m) return;
state.mixEditId = id;
state.siteMixEditId = null;
state.newMix = {
name: m.name,
note: m.note || '',
flavorIds: m.flavors.map((f) => f.id),
strength: m.strength == null ? '' : String(m.strength),
cooling: m.cooling == null ? '' : String(m.cooling),
price: m.price == null ? '' : String(m.price),
tags: [...(m.tags || [])],
};
state.mixErrors = {};
render();
// значения полей живут в DOM (restoreFormValues вернул бы прежние),
// поэтому предзаполняем поверх — из state, как при сбросе формы
syncMixFormDom();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
/* правка витринного микса сайта (только владелец): состав опционален —
пустой список означает «оставить исходный состав витрины» */
function siteMixEditOpen(id) {
const m = state.siteMixes.find((x) => x.id === id);
if (!m) return;
state.siteMixEditId = id;
state.mixEditId = null;
const priceNum = typeof m.price === 'number'
? m.price
: parseInt(String(m.price || '').replace(/\D/g, ''), 10);
// теги витрины хранятся как в seed («Цитрус», «лёд») — приводим к палитре
// чипов, чтобы активные чипы отражали их и не плодили дубли
const tagNorm = (s) => s.toLocaleLowerCase('ru-RU').replace(/ё/g, 'е').trim();
const tags = [...new Set((m.flavor_tags || [])
.map((t) => MIX_TAGS.find((x) => tagNorm(x) === tagNorm(t)) || t))];
state.newMix = {
name: m.name,
note: m.note || '',
flavorIds: (m.flavors || []).map((f) => f.id),
strength: m.strength == null ? '' : String(m.strength),
cooling: m.cooling == null ? '' : String(m.cooling),
price: Number.isFinite(priceNum) ? String(priceNum) : '',
tags,
};
state.mixErrors = {};
render();
syncMixFormDom();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
async function saveSiteMix() {
const m = state.newMix;
const errors = {};
if (!m.name.trim()) errors.newMixName = 'укажите название микса';
if (m.flavorIds.length === 1) errors.flavors = 'выберите 2–5 вкусов или очистите состав, чтобы оставить исходный';
if (m.strength === '') errors.newMixStrength = 'выберите крепость микса';
if (m.cooling === '') errors.newMixCooling = 'выберите уровень холодка';
if (m.price === '' || !Number.isFinite(Number(m.price)) || Number(m.price) < 0) {
errors.newMixPrice = 'укажите цену в рублях';
}
if (Object.keys(errors).length) {
state.mixErrors = errors;
render();
focusMixError(errors);
toast('Изменения не сохранены: заполните выделенные поля формы', 't-red');
return;
}
try {
await api('/api/mixes-site/' + encodeURIComponent(state.siteMixEditId), {
method: 'PATCH',
body: JSON.stringify({
name: m.name.trim(),
note: m.note.trim(),
strength: Number(m.strength),
cooling: Number(m.cooling),
price: Number(m.price),
tags: m.tags,
flavor_ids: m.flavorIds, // [] — оставить исходный состав витрины
}),
});
toast('Витринный микс обновлён — изменения уже на сайте', 't-green');
mixFormReset();
await refreshMixes();
} catch (e) {
if (e.message !== 'session expired') toast('Ошибка: ' + e.message, 't-red');
}
render();
syncMixFormDom();
}
async function siteMixReset(id) {
const m = state.siteMixes.find((x) => x.id === id);
if (!m) return;
if (!(await uiConfirm({
title: `Вернуть «${m.name}» к исходному виду?`,
text: 'Правка из CRM будет стёрта, витрина сайта покажет первоначальный микс.',
ok: 'Вернуть оригинал',
}))) return;
try {
await api('/api/mixes-site/' + encodeURIComponent(id), { method: 'DELETE' });
toast('Микс возвращён к исходному виду', 't-green');
await refreshMixes();
} catch (e) {
if (e.message !== 'session expired') toast('Ошибка: ' + e.message, 't-red');
}
render();
}
function mixFormReset() {
state.mixEditId = null;
state.siteMixEditId = null;
state.newMix = { name: '', note: '', flavorIds: [], strength: '', cooling: '', price: '', tags: [] };
state.mixErrors = {};
render();
syncMixFormDom();
}
async function saveMix() {
const errors = validateMixForm();
if (Object.keys(errors).length) {
state.mixErrors = errors;
render();
focusMixError(errors);
toast('Изменения не сохранены: заполните выделенные поля формы', 't-red');
return;
}
const opt = (v) => (v === '' ? undefined : Number(v));
try {
const mix = await api('/api/mixes/' + encodeURIComponent(state.mixEditId), {
method: 'PATCH',
body: JSON.stringify({
name: state.newMix.name.trim(),
note: state.newMix.note.trim() || undefined,
flavor_ids: state.newMix.flavorIds,
strength: opt(state.newMix.strength),
cooling: opt(state.newMix.cooling),
price: opt(state.newMix.price),
tags: state.newMix.tags.length ? state.newMix.tags : undefined,
}),
});
toast(`Микс «${mix.name}» обновлён — изменения уже на сайте`, 't-green');
mixFormReset();
state.mixQuery = '';
state.mixBrand = 'Все';
await refreshMixes();
} catch (e) {
if (e.message !== 'session expired') toast('Ошибка: ' + e.message, 't-red');
}
render();
syncMixFormDom();
}
/* ---------------- заметки и задачи (доски) ---------------- */
/* Общая доска для всех ролей + приватные «владелец ↔ админ».
К сменам не привязаны: владелец ставит задачи из дома, персонал
проверяет до выхода. Поллинг с ?since= раз в 12 с. */
const NOTE_COLS = [
{ key: 'open', cls: 'kan-new', title: 'Новые' },
{ key: 'in_progress', cls: 'kan-active', title: 'В работе' },
{ key: 'done', cls: 'kan-done', title: 'Готово' },
];
const NOTE_ACTIONS = {
created: 'создал(а) задачу',
edited: 'изменил(а) задачу',
status: 'статус',
assigned: 'исполнитель',
deleted: 'удалил(а) задачу',
board_created: 'создал(а) доску',
};
const NOTE_STATUS_DONE = { open: 'Новые', in_progress: 'В работе', done: 'Готово' };
const todayStr = () => {
const d = new Date();
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
};
const currentBoard = () => state.boards.find((b) => b.id === state.boardId) || null;
const isNoteOverdue = (n) =>
!!n.due_date && n.status !== 'done' && n.due_date < todayStr();
const canDeleteNote = (n) =>
state.role === 'owner' || state.role === 'admin' || n.author.id === state.login;
function noteFormReset() {
state.noteEditId = null;
state.noteForm = { title: '', body: '', assignee: '', due: '', important: false };
render();
syncNoteFormDom();
}
/* preserveFormValues возвращает в поля введённое после любой перерисовки,
поэтому сброс/предзаполнение формы заметки проводим и в state, и в DOM */
function syncNoteFormDom() {
const f = state.noteForm;
const set = (id, v) => {
const el = document.getElementById(id);
if (el) el.value = v;
};
set('ntTitle', f.title);
set('ntBody', f.body);
set('ntAssignee', f.assignee);
set('ntDue', f.due);
const imp = document.getElementById('ntImportant');
if (imp) imp.checked = f.important;
}
async function loadNotesData() {
try {
const data = await api('/api/boards');
state.boards = data.boards;
if (!state.boardId || !state.boards.some((b) => b.id === state.boardId)) {
state.boardId = (state.boards[0] || {}).id || null;
}
await refreshNotes();
} catch (e) {
if (e.message !== 'session expired') toast('Не удалось загрузить заметки: ' + e.message, 't-red');
}
// без этого вкладка висит на «Загрузка заметок…» до 10 секунд —
// пока её не перерисует очередной опрос заказов
if (state.view === 'notes') render();
}
async function refreshNotes() {
if (!state.boardId) return;
try {
const data = await api('/api/boards/' + state.boardId + '/notes');
state.notes = data.notes;
state.notesSince = data.server_time;
const act = await api('/api/boards/' + state.boardId + '/activity?limit=8');
state.boardActivity = act.events;
} catch { /* сеть моргнула — покажем прошлые данные */ }
}
/* инкрементальный опрос: только изменённые заметки + id удалённых */
async function pollNotes() {
if (!state.role || state.view !== 'notes' || !state.boardId || !state.notesSince) return;
try {
const data = await api('/api/boards/' + state.boardId
+ '/notes?since=' + encodeURIComponent(state.notesSince));
if (!data.notes.length && !data.deleted_ids.length) return;
for (const n of data.notes) {
const i = state.notes.findIndex((x) => x.id === n.id);
if (i === -1) state.notes.unshift(n);
else state.notes[i] = n;
}
if (data.deleted_ids.length) {
state.notes = state.notes.filter((n) => !data.deleted_ids.includes(n.id));
if (state.noteEditId != null && data.deleted_ids.includes(state.noteEditId)) {
noteFormReset();
}
}
const act = await api('/api/boards/' + state.boardId + '/activity?limit=8');
state.boardActivity = act.events;
render();
} catch { /* сеть моргнула — попробуем в следующий тик */ }
}
function noteCard(n) {
const overdue = isNoteOverdue(n);
const done = n.status === 'done';
const moves = [];
if (n.status === 'open') moves.push(['in_progress', '→', 'в работу']);
if (n.status === 'in_progress') {
moves.push(['open', '←', 'в новые']);
moves.push(['done', '→', 'в готово']);
}
if (done) moves.push(['open', '←', 'вернуть в новые']);
const moveBtns = moves.map(([st, ico, tip]) =>
`<button class="btn btn-ghost btn-sm n-move" data-nmove="${esc(n.id)}:${st}" type="button" title="${tip}">${ico}</button>`).join('');
return `
<div class="order-card note-card ${done ? 'is-done' : ''} ${n.important ? 'is-important' : ''} ${overdue ? 'is-overdue' : ''}"
draggable="true" data-nedit="${esc(n.id)}" title="Нажмите, чтобы редактировать">
<div class="o-head">
<span class="o-num">${n.important ? '❗ ' : ''}${esc(n.title)}</span>
${overdue ? '<span class="n-flag-over">просрочено</span>' : ''}
${n.important ? '<span class="n-flag-imp">важно</span>' : ''}
</div>
${n.body ? `<div class="n-body">${esc(n.body)}</div>` : ''}
<div class="o-foot">
<div>
<div class="o-waiter">👤 ${n.assignee ? esc(n.assignee.name) : 'исполнитель не назначен'}</div>
<div class="o-waiter">${n.due_date ? '⏳ до ' + fmtDate(n.due_date) + ' · ' : ''}автор: ${esc(n.author.name)}</div>
</div>
<div class="n-moves">${moveBtns}</div>
</div>
</div>`;
}
function viewNotes() {
const board = currentBoard();
if (!board) {
/* доски ещё грузятся или выбранная только что удалена */
return '<div class="panel"><div class="stop-empty">Загрузка заметок…</div></div>';
}
const isShared = board.kind === 'shared';
const members = board.members || [];
/* селектор доски: официант видит одну «Общую» — это норма */
const boardChips = state.boards.map((b) => `
<button class="chip ${b.id === state.boardId ? 'active' : ''}" data-nboard="${esc(b.id)}" type="button">
${b.kind === 'shared' ? '🌐' : '🔒'} ${esc(b.title)}${b.kind === 'private' ? ' · ' + esc((b.members || []).map((m) => m.name).join(' + ')) : ''}
</button>`).join('');
const ownerChips = state.role === 'owner'
? `<button class="chip ${state.boardFormOpen ? 'active' : ''}" data-nboard-form="1" type="button">+ доска с админом</button>
${!isShared ? `<button class="chip" data-nboard-rename="1" type="button">✎ переименовать</button>
<button class="chip chip-busy" data-nboard-del="${esc(board.id)}" type="button">✕ удалить доску</button>` : ''}`
: (state.role === 'admin' && !isShared
? '<button class="chip" data-nboard-rename="1" type="button">✎ переименовать</button>'
: '');
const boardForm = state.boardFormOpen && state.role === 'owner' ? `
<div class="panel" style="margin:14px 0">
<div class="panel-title">Новая приватная доска <small>ровно двое: вы и администратор</small></div>
<form class="user-form" id="boardForm" autocomplete="off">
<input type="text" id="nbTitle" placeholder="Название, напр. «Задачи для Ирины»" maxlength="200" required>
<select id="nbAdmin" aria-label="Администратор" required>
<option value="">— выберите администратора —</option>
${members.filter((m) => m.role === 'admin').map((m) =>
`<option value="${esc(m.login)}">${esc(m.name)} (${esc(m.login)})</option>`).join('')}
</select>
<button class="btn btn-neon" type="submit">Создать доску</button>
</form>
</div>` : '';
/* фильтр «все / мои» */
const filtered = state.notes.filter((n) => state.noteFilter === 'all' ||
(n.assignee && n.assignee.id === state.login));
const filterChips = [
{ key: 'all', label: 'Все', n: state.notes.length },
{ key: 'my', label: 'Мои', n: state.notes.filter((n) => n.assignee && n.assignee.id === state.login).length },
].map((c) => `
<button class="chip ${state.noteFilter === c.key ? 'active' : ''}" data-nfilter="${c.key}" type="button">
${c.label}<span class="cnt">${c.n}</span>
</button>`).join('');
const editing = state.noteEditId != null;
const editNote = editing ? state.notes.find((n) => n.id === state.noteEditId) : null;
const f = state.noteForm;
const noteForm = `
<div class="panel" style="margin:14px 0">
<div class="panel-title">${editing ? 'Редактировать задачу' : 'Новая задача'}
<small>${isShared ? 'видна всем сотрудникам' : 'видна только вам двоим'}</small></div>
<form class="user-form" id="noteForm" autocomplete="off">
<input type="text" id="ntTitle" placeholder="Что сделать" maxlength="200" required value="${esc(f.title)}">
<select id="ntAssignee" aria-label="Исполнитель">
<option value="">— исполнитель —</option>
${members.map((m) => `
<option value="${esc(m.login)}" ${f.assignee === m.login ? 'selected' : ''}>${esc(m.name)}</option>`).join('')}
</select>
<input type="date" id="ntDue" value="${esc(f.due)}" aria-label="Срок">
<label class="nt-imp"><input type="checkbox" id="ntImportant" ${f.important ? 'checked' : ''}> ❗ важно</label>
<button class="btn btn-neon" type="submit">${editing ? 'Сохранить' : 'Добавить'}</button>
${editing ? `
<button class="btn btn-ghost" data-nform-cancel="1" type="button">Отмена</button>
${editNote && canDeleteNote(editNote)
? `<button class="btn btn-danger" data-ndel="${state.noteEditId}" type="button">Удалить</button>` : ''}` : ''}
</form>
<textarea id="ntBody" class="order-comment" maxlength="4000" rows="2"
placeholder="Детали: как, где, до какого времени…">${esc(f.body)}</textarea>
</div>`;
const cols = NOTE_COLS.map((c) => {
const notes = filtered.filter((n) => n.status === c.key);
return `
<div class="kan-col ${c.cls}" data-nstatus="${c.key}">
<div class="kan-head">${c.title}<span class="cnt">${notes.length}</span></div>
<div class="stack">${notes.map(noteCard).join('') || '<div class="kan-empty">Пусто</div>'}</div>
</div>`;
}).join('');
const feed = state.boardActivity.map((e) => {
const what = NOTE_ACTIONS[e.action] || e.action;
let extra = '';
if (e.action === 'status') extra = ': ' + (NOTE_STATUS_DONE[e.details] || e.details);
else if (e.action === 'assigned') extra = e.details ? ': ' + e.details : ' снят';
else if (e.action === 'created') extra = ` «${e.details}»`;
return `
<div class="act-row">
<span class="act-who">${esc(e.user_name)}</span>
<span class="act-what">${esc(what + extra)}</span>
<span class="act-when">${fmtTime(e.created_at)}</span>
</div>`;
}).join('');
return `
<div class="note"><span class="n-ico">🗒</span>
<span>Задачи не привязаны к смене — доступны всегда после входа. Перетащите карточку мышью или кнопками, чтобы сменить статус. Клик по карточке — редактирование.</span>
</div>
<div class="toolbar"><div class="chips">${boardChips}${ownerChips}</div></div>
${boardForm}
<div class="toolbar" style="margin-top:10px"><div class="chips">${filterChips}</div></div>
${noteForm}
<div class="kanban">${cols}</div>
<div class="panel" style="margin-top:16px">
<div class="panel-title">Последние события <small>журнал доски</small></div>
<div class="act-feed">${feed || '<div class="stop-empty">Пока тихо</div>'}</div>
</div>`;
}
/* защита от двойного сабмита (двойной клик по кнопке создаёт дубль) */
let noteBusy = false;
async function noteSubmit() {
if (noteBusy) return;
noteBusy = true;
try {
await noteSubmitInner();
} finally {
noteBusy = false;
}
}
async function noteSubmitInner() {
const titleEl = document.getElementById('ntTitle');
const title = (titleEl.value || '').trim();
if (!title) {
toast('Укажите заголовок задачи', 't-red');
return;
}
const payload = {
title,
body: document.getElementById('ntBody').value || '',
assignee: document.getElementById('ntAssignee').value || null,
due_date: document.getElementById('ntDue').value || null,
important: document.getElementById('ntImportant').checked,
};
try {
if (state.noteEditId != null) {
const r = await api('/api/notes/' + state.noteEditId, {
method: 'PATCH', body: JSON.stringify(payload),
});
const i = state.notes.findIndex((x) => x.id === r.note.id);
if (i !== -1) state.notes[i] = r.note;
else state.notes.unshift(r.note);
toast('Задача обновлена', 't-green');
} else {
const r = await api('/api/boards/' + state.boardId + '/notes', {
method: 'POST', body: JSON.stringify(payload),
});
state.notes.unshift(r.note);
toast('Задача добавлена', 't-green');
}
noteFormReset();
refreshNotesActivity();
} catch (e) {
if (e.message !== 'session expired') toast('Ошибка: ' + e.message, 't-red');
}
render();
}
/* лента событий без перезагрузки заметок (после мутаций) */
async function refreshNotesActivity() {
if (!state.boardId) return;
try {
const act = await api('/api/boards/' + state.boardId + '/activity?limit=8');
state.boardActivity = act.events;
} catch { /* некритично */ }
}
/* оптимистичное перемещение: перерисовка → запрос; при ошибке откат */
async function noteMove(id, status) {
const i = state.notes.findIndex((n) => n.id === id);
if (i === -1 || state.notes[i].status === status) return;
const prev = state.notes[i].status;
state.notes[i] = { ...state.notes[i], status,
done_at: status === 'done' ? new Date().toISOString() : null };
render();
try {
const r = await api('/api/notes/' + id, { method: 'PATCH', body: JSON.stringify({ status }) });
const j = state.notes.findIndex((n) => n.id === id);
if (j !== -1) state.notes[j] = r.note;
refreshNotesActivity();
} catch (e) {
const j = state.notes.findIndex((n) => n.id === id);
if (j !== -1) state.notes[j] = { ...state.notes[j], status: prev, done_at: null };
if (e.message !== 'session expired') toast('Не удалось переместить: ' + e.message, 't-red');
}
render();
}
function noteEditOpen(id) {
const n = state.notes.find((x) => x.id === id);
if (!n) return;
state.noteEditId = id;
state.noteForm = {
title: n.title,
body: n.body || '',
assignee: n.assignee ? n.assignee.id : '',
due: n.due_date || '',
important: !!n.important,
};
render();
syncNoteFormDom();
const el = document.getElementById('ntTitle');
if (el) el.focus();
}
async function noteDelete(id) {
const n = state.notes.find((x) => x.id === id);
if (!n) return;
if (!(await uiConfirm({ title: `Удалить задачу «${n.title}»?`, ok: 'Удалить' }))) return;
try {
await api('/api/notes/' + id, { method: 'DELETE' });
state.notes = state.notes.filter((x) => x.id !== id);
if (state.noteEditId === id) noteFormReset();
toast('Задача удалена', 't-red');
refreshNotesActivity();
} catch (e) {
if (e.message !== 'session expired') toast('Ошибка: ' + e.message, 't-red');
}
render();
}
let boardBusy = false;
async function boardCreate() {
if (boardBusy) return;
boardBusy = true;
try {
await boardCreateInner();
} finally {
boardBusy = false;
}
}
async function boardCreateInner() {
const title = (document.getElementById('nbTitle').value || '').trim();
const admin = document.getElementById('nbAdmin').value;
if (!title || !admin) {
toast('Название и администратор обязательны', 't-red');
return;
}
try {
const r = await api('/api/boards', {
method: 'POST', body: JSON.stringify({ title, admin_login: admin }),
});
state.boardFormOpen = false;
state.boardId = r.board.id;
noteFormReset();
toast('Доска создана — видна вам и админу', 't-green');
await loadNotesData();
} catch (e) {
if (e.message !== 'session expired') toast('Ошибка: ' + e.message, 't-red');
}
render();
}
async function boardRename() {
const board = currentBoard();
if (!board) return;
const title = await uiPrompt({
title: 'Новое название доски',
value: board.title,
ok: 'Переименовать',
});
if (title === null) return;
const clean = title.trim();
if (!clean) {
toast('Название не может быть пустым', 't-red');
return;
}
try {
await api('/api/boards/' + board.id, { method: 'PATCH', body: JSON.stringify({ title: clean }) });
board.title = clean;
toast('Доска переименована', 't-green');
} catch (e) {
if (e.message !== 'session expired') toast('Ошибка: ' + e.message, 't-red');
}
render();
}
async function boardDelete(id) {
const board = state.boards.find((b) => b.id === id);
if (!board) return;
if (!(await uiConfirm({
title: `Удалить доску «${board.title}»?`,
text: 'Вместе со всеми задачами.',
ok: 'Удалить',
}))) return;
try {
await api('/api/boards/' + id, { method: 'DELETE' });
toast('Доска удалена', 't-red');
await loadNotesData();
} catch (e) {
if (e.message !== 'session expired') toast('Ошибка: ' + e.message, 't-red');
}
render();
}
/* drag&drop карточек (desktop): колонки помечены data-nstatus */
document.addEventListener('dragstart', (e) => {
if (state.view !== 'notes' || !e.target.closest) return;
const card = e.target.closest('[data-nedit]');
if (!card) return;
e.dataTransfer.setData('text/plain', card.dataset.nedit);
e.dataTransfer.effectAllowed = 'move';
card.classList.add('dragging');
});
document.addEventListener('dragover', (e) => {
if (state.view !== 'notes' || !e.target.closest) return;
const col = e.target.closest('.kan-col[data-nstatus]');
if (!col) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
col.classList.add('drag-over');
});
document.addEventListener('dragleave', (e) => {
if (!e.target.closest) return;
const col = e.target.closest('.kan-col[data-nstatus]');
if (col) col.classList.remove('drag-over');
});
document.addEventListener('drop', (e) => {
if (state.view !== 'notes' || !e.target.closest) return;
const col = e.target.closest('.kan-col[data-nstatus]');
if (!col) return;
e.preventDefault();
col.classList.remove('drag-over');
const id = Number(e.dataTransfer.getData('text/plain'));
if (id) noteMove(id, col.dataset.nstatus);
});
document.addEventListener('dragend', () => {
document.querySelectorAll('.note-card.dragging').forEach((el) => el.classList.remove('dragging'));
document.querySelectorAll('.kan-col.drag-over').forEach((el) => el.classList.remove('drag-over'));
});
/* ---------------- игры: PS5 и настолки ---------------- */
/* Библиотека видна всем ролям (официант подбирает игру гостю),
добавление/удаление — админ и владелец. Обложки — только у PS5. */
const GAME_KIND_LABELS = { ps5: 'PS5', board: 'Настолка' };
const GAME_KIND_ICONS = { ps5: '🎮', board: '🎲' };
/* логотипы настолок: ключи иконок сайта + эмодзи для CRM (набор BoardGamesPage) */
const GAME_ICONS = [
{ key: 'eye', emoji: '👁' }, { key: 'skull', emoji: '💀' }, { key: 'spade', emoji: '♠' },
{ key: 'crown', emoji: '👑' }, { key: 'dices', emoji: '🎲' }, { key: 'grid', emoji: '▦' },
{ key: 'circle', emoji: '⭕' }, { key: 'hash', emoji: '#️⃣' }, { key: 'landmark', emoji: '🏛' },
{ key: 'layout', emoji: '🔲' }, { key: 'brain', emoji: '🧠' }, { key: 'puzzle', emoji: '🧩' },
{ key: 'users', emoji: '👥' }, { key: 'club', emoji: '♣' }, { key: 'ghost', emoji: '👻' },
{ key: 'castle', emoji: '🏰' }, { key: 'heart', emoji: '❤' }, { key: 'target', emoji: '🎯' },
];
const GAME_ICON_EMOJI = Object.fromEntries(GAME_ICONS.map((x) => [x.key, x.emoji]));
/* неоновые цвета сайта — палитра подсветки (игры и меню) */
const NEON_ACCENTS = ['#00f0ff', '#b026ff', '#ff4d6d', '#2ee6a8', '#ffc857', '#ff8a00',
'#ec4899', '#22c55e', '#38bdf8', '#eab308', '#c084fc', '#ef4444'];
async function refreshGames() {
try {
const canManage = state.role === 'owner'; // правка: библиотеку ведёт владелец
const data = await api('/api/games' + (canManage ? '?all=1' : ''));
state.games = data.games;
} catch { /* сеть моргнула — покажем прошлые данные */ }
}
async function refreshSiteMixes() {
if (state.role !== 'owner' && state.role !== 'hookah') return;
try {
const data = await api('/api/mixes/site');
state.siteMixes = data.site_mixes;
} catch { /* сеть моргнула — покажем прошлые данные */ }
}
function gameCard(g, canManage) {
const emoji = GAME_ICON_EMOJI[g.icon] || GAME_KIND_ICONS[g.kind];
const accentColor = g.accent || (g.kind === 'board' ? '#b026ff' : null);
const cover = g.cover
? `<img class="g-cover-img" src="${esc(g.cover)}" alt="" loading="lazy">`
: `<div class="g-cover-img g-cover-empty" ${accentColor ? `style="color:${accentColor};background:linear-gradient(150deg, ${accentColor}26 0%, #0a0a0f 62%)"` : ''}>${emoji}</div>`;
const controls = g.deleted
? `<button class="btn btn-ghost btn-sm" data-game-restore="${esc(g.id)}" type="button">↩ Восстановить</button>`
: `
${g.kind === 'ps5' && canManage ? `
<button class="btn btn-ghost btn-sm" data-gphoto="${esc(g.id)}" type="button">
${g.cover ? '📷 заменить' : '📷 обложка'}</button>` : ''}
${canManage ? `<button class="btn btn-ghost btn-sm" data-game-edit="${esc(g.id)}" type="button">✎</button>
<button class="btn btn-danger btn-sm" data-game-del="${esc(g.id)}" type="button">Удалить</button>` : ''}`;
return `
<div class="game-card ${g.deleted ? 'is-deleted' : ''}">
<div class="g-cover">${cover}</div>
<div class="g-body">
<span class="badge ${g.kind === 'ps5' ? 'b-new' : 'b-active'}"><span class="b-dot"></span>${GAME_KIND_LABELS[g.kind]}</span>
<div class="g-title">${esc(g.title)}</div>
${g.note ? `<div class="g-note">${esc(g.note)}</div>` : ''}
<div class="g-actions">${controls}</div>
</div>
</div>`;
}
/* форма добавления/редактирования игры; у настолок — сложность и логотип */
function gameFormHtml() {
const editing = state.gameEditId != null;
const f = state.gameForm;
const isBoard = f.kind === 'board';
return `
<div class="panel" style="margin-bottom:16px">
<div class="panel-title">${editing ? 'Редактировать игру' : 'Добавить игру'}
<small>${isBoard ? 'сложность и логотип — как на сайте' : ''}</small></div>
<form class="user-form" id="gamesForm" autocomplete="off">
<select id="newGameKind" aria-label="Вид игры" ${editing ? 'disabled' : ''}>
<option value="ps5" ${f.kind === 'ps5' ? 'selected' : ''}>🎮 PS5</option>
<option value="board" ${f.kind === 'board' ? 'selected' : ''}>🎲 Настолка</option>
</select>
<input type="text" id="newGameTitle" placeholder="Название, напр. FIFA 25" maxlength="120" required value="${esc(f.title)}">
<input type="text" id="newGameNote" placeholder="Пометка, напр. «2–4 игрока»" maxlength="160" value="${esc(f.note)}">
${isBoard ? `
<select id="newGameDifficulty" aria-label="Сложность">
<option value="1" ${Number(f.difficulty) === 1 ? 'selected' : ''}>Простая</option>
<option value="2" ${Number(f.difficulty) === 2 ? 'selected' : ''}>Средняя</option>
<option value="3" ${Number(f.difficulty) === 3 ? 'selected' : ''}>Сложная</option>
</select>` : ''}
<button class="btn btn-neon" type="submit">${editing ? 'Сохранить' : 'Добавить'}</button>
${editing ? '<button class="btn btn-ghost" data-game-cancel="1" type="button">Отмена</button>' : ''}
</form>
${isBoard ? `
<div class="chips" style="margin-top:10px">
<span class="switch-label">Логотип:</span>
${GAME_ICONS.map((i) => `
<button class="chip ${f.icon === i.key ? 'active' : ''}" data-gicon="${i.key}" type="button"
title="${i.key}">${i.emoji}</button>`).join('')}
</div>
<div class="chips" style="margin-top:8px">
<span class="switch-label">Цвет:</span>
${NEON_ACCENTS.map((c) => `
<button class="chip ${f.accent === c ? 'active' : ''}" data-gaccent="${c}" type="button"
title="${c}"><span style="display:inline-block;width:14px;height:14px;border-radius:50%;background:${c}"></span></button>`).join('')}
</div>` : ''}
</div>`;
}
function viewGames() {
// правка: редактирует библиотеку только владелец; админ видит список,
// официант и кальянщик раздела не имеют вовсе
const canManage = state.role === 'owner';
const active = state.games.filter((g) => !g.deleted);
const pool = state.gameTrash && canManage ? state.games : active;
const kinds = [
{ key: 'all', label: 'Все' },
{ key: 'ps5', label: '🎮 PS5' },
{ key: 'board', label: '🎲 Настолки' },
].map((k) => {
const n = k.key === 'all' ? pool.length
: pool.filter((g) => g.kind === k.key).length;
return `<button class="chip ${state.gameFilter === k.key ? 'active' : ''}"
data-gfilter="${k.key}" type="button">${k.label}<span class="cnt">${n}</span></button>`;
}).join('');
const trashChip = canManage && state.games.some((g) => g.deleted)
? `<button class="chip ${state.gameTrash ? 'active chip-busy' : ''}" data-gtrash="1" type="button">
🗑 корзина<span class="cnt">${state.games.filter((g) => g.deleted).length}</span></button>`
: '';
const filtered = pool.filter((g) => state.gameFilter === 'all' || g.kind === state.gameFilter);
const cards = filtered.map((g) => gameCard(g, canManage)).join('');
return `
<div class="note"><span class="n-ico">🎮</span>
<span>${canManage
? 'Ведите библиотеку: игры PS5 — с обложкой (до 6 МБ), настолки — со сложностью и логотипом (цвет+значок, как на сайте). Кнопка ✎ — редактирование.'
: 'Список игр бара: предлагайте гостям то, что свободно прямо сейчас. Библиотеку ведёт владелец.'}</span>
</div>
${canManage ? gameFormHtml() : ''}
<div class="toolbar"><div class="chips">${kinds}${trashChip}</div></div>
<div class="games-grid">${cards || '<div class="stop-empty">Игр пока нет — добавьте первую</div>'}</div>`;
}
function gameFormReset() {
state.gameEditId = null;
state.gameForm = { kind: 'ps5', title: '', note: '', difficulty: 2, icon: 'dices', accent: '#b026ff' };
render();
}
function gameEditOpen(id) {
const g = state.games.find((x) => x.id === id);
if (!g) return;
state.gameEditId = id;
state.gameForm = {
kind: g.kind,
title: g.title,
note: g.note || '',
difficulty: g.difficulty || 2,
icon: g.icon || 'dices',
accent: g.accent || '#b026ff',
};
render();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function readGameForm() {
const f = state.gameForm;
return {
kind: f.kind,
title: (document.getElementById('newGameTitle').value || '').trim(),
note: (document.getElementById('newGameNote').value || '').trim(),
difficulty: f.kind === 'board' ? Number((document.getElementById('newGameDifficulty') || {}).value || f.difficulty) : null,
icon: f.kind === 'board' ? f.icon : null,
accent: f.kind === 'board' ? f.accent : null,
};
}
async function addGame() {
const payload = readGameForm();
if (!payload.title) {
toast('Укажите название игры', 't-red');
return;
}
try {
const r = await api('/api/games', {
method: 'POST', body: JSON.stringify(payload),
});
state.games.unshift(r.game);
toast(`Игра «${r.game.title}» добавлена`, 't-green');
gameFormReset();
} catch (e) {
if (e.message !== 'session expired') toast('Ошибка: ' + e.message, 't-red');
}
render();
}
async function saveGame() {
const payload = readGameForm();
if (!payload.title) {
toast('Укажите название игры', 't-red');
return;
}
delete payload.kind; // вид игры при редактировании не меняется
try {
const r = await api('/api/games/' + state.gameEditId, {
method: 'PATCH', body: JSON.stringify(payload),
});
const i = state.games.findIndex((x) => x.id === r.game.id);
if (i !== -1) state.games[i] = r.game;
toast('Игра обновлена', 't-green');
gameFormReset();
} catch (e) {
if (e.message !== 'session expired') toast('Ошибка: ' + e.message, 't-red');
}
render();
}
async function deleteGame(id) {
const g = state.games.find((x) => x.id === id);
if (!g) return;
if (!(await uiConfirm({ title: `Удалить игру «${g.title}»?`, text: 'Из библиотеки.', ok: 'Удалить' }))) return;
try {
await api('/api/games/' + id, { method: 'DELETE' });
toast(`Игра «${g.title}» удалена — вернётся из корзины, если что`, 't-red');
} catch (e) {
if (e.message !== 'session expired') toast('Ошибка: ' + e.message, 't-red');
}
await refreshGames();
render();
}
async function restoreGame(id) {
try {
await api('/api/games/' + id, { method: 'PATCH', body: JSON.stringify({ deleted: false }) });
toast('Игра восстановлена', 't-green');
} catch (e) {
if (e.message !== 'session expired') toast('Ошибка: ' + e.message, 't-red');
}
await refreshGames();
render();
}
/* обложка игры: тот же скрытый photoInput, другой endpoint */
async function uploadGameCover(gameId, file) {
try {
const r = await fetch('/api/games/' + gameId + '/photo', {
method: 'POST',
headers: {
'Content-Type': file.type || 'application/octet-stream',
'X-Auth-Token': localStorage.getItem(TOKEN_KEY),
},
body: file,
});
if (!r.ok) {
const err = await r.json().catch(() => ({}));
throw new Error(err.error || 'HTTP ' + r.status);
}
toast('Обложка загружена', 't-green');
await refreshGames();
} catch (e) {
if (e.message !== 'session expired') toast('Ошибка загрузки обложки: ' + e.message, 't-red');
}
render();
}
/* ---------------- события ---------------- */
document.addEventListener('click', async (e) => {
const t = e.target.closest('[data-nav],[data-cat],[data-tobcat],[data-nocat],[data-ofilter],[data-accept],[data-fastdone],[data-deliver],[data-cancel],[data-unstop],[data-user-del],[data-user-pass],[data-rep],[data-del],[data-restore],[data-purge],[data-photo],[data-shift],[data-shiftkick],[data-shiftreg],[data-noadd],[data-noqty],[data-notable],[data-nocreate],[data-hall-busy],[data-hall-busy-min],[data-hall-ext],[data-hall-free],[data-hall-custom-busy],[data-hall-custom-ext],[data-hall-reserve],[data-hall-res-cancel],[data-hall-del],[data-mixflavor],[data-mixbrand],[data-mixdel],[data-mixedit],[data-mix-cancel],[data-sitemixedit],[data-sitemixreset],[data-menuedit],[data-menu-cancel],[data-mixtag],[data-nboard],[data-nboard-form],[data-nboard-rename],[data-nboard-del],[data-nfilter],[data-nedit],[data-nmove],[data-nform-cancel],[data-ndel],[data-gfilter],[data-gtrash],[data-game-del],[data-game-restore],[data-game-edit],[data-game-cancel],[data-gicon],[data-gaccent],[data-accent-menu],[data-set-accent],[data-numreset],[data-gphoto],[data-stock-on],[data-stock-add],[data-stock-off]');
if (!t) return;
if (t.dataset.nav) {
state.view = t.dataset.nav;
// уходим со вкладки — выходим из режима правки, иначе вернёмся
// к «Редактированию» с чужими данными в форме
if (state.view !== 'mixes' && (state.mixEditId != null || state.siteMixEditId != null)) {
state.mixEditId = null;
state.siteMixEditId = null;
state.newMix = { name: '', note: '', flavorIds: [], strength: '', cooling: '', price: '', tags: [] };
state.mixErrors = {};
}
if (state.view !== 'menu') state.menuEditId = null;
if (state.view === 'users' && state.role === 'owner') loadUsers();
if (state.view === 'mixes' && (state.role === 'owner' || state.role === 'hookah')) refreshMixes();
if (state.view === 'notes') loadNotesData();
if (state.view === 'games') refreshGames();
render();
return;
}
if (t.dataset.stockOn) { menuStockOn(t.dataset.stockOn); return; }
if (t.dataset.stockAdd) { menuStockAdd(t.dataset.stockAdd); return; }
if (t.dataset.stockOff) { menuStockOff(t.dataset.stockOff); return; }
if (t.dataset.cat) { state.menuCat = t.dataset.cat; render(); return; }
if (t.dataset.tobcat) { state.tobBrand = t.dataset.tobcat; render(); return; }
if (t.dataset.nocat) { state.newOrderCat = t.dataset.nocat; render(); return; }
if (t.dataset.ofilter) { state.orderFilter = t.dataset.ofilter; render(); return; }
if (t.dataset.accept) { acceptOrder(t.dataset.accept); return; }
if (t.dataset.fastdone) { fastDoneOrder(t.dataset.fastdone); return; }
if (t.dataset.deliver) { deliverOrder(t.dataset.deliver); return; }
if (t.dataset.cancel) { cancelOrder(t.dataset.cancel); return; }
if (t.dataset.userDel) { deleteUser(t.dataset.userDel); return; }
if (t.dataset.userPass) { resetUserPassword(t.dataset.userPass); return; }
if (t.dataset.rep === 'build') { buildReport(); return; }
if (t.dataset.rep === 'all') { buildAllTimeReport(); return; }
if (t.dataset.rep === 'excel') { downloadReportExcel(false); return; }
if (t.dataset.rep === 'excel-days') { downloadReportExcel(true); return; }
if (t.dataset.shift === 'in') { clockIn(); return; }
if (t.dataset.shift === 'out') { clockOut(); return; }
if (t.dataset.shiftkick) {
const login = t.dataset.shiftkick;
const name = (state.onShiftList.find((s) => s.login === login) || {}).name || login;
if (await uiConfirm({
title: `Снять ${name} со смены?`,
text: 'Управление заказами для него заблокируется.',
ok: 'Снять',
})) {
shiftManage(login, 'out');
}
return;
}
if (t.dataset.shiftreg) { shiftManage(t.dataset.shiftreg, 'in'); return; }
if (t.dataset.photo) {
state.photoKind = 'menu';
state.photoTarget = t.dataset.photo;
// input статичный (в index.html): переживает перерисовки списков
const inp = document.getElementById('photoInput');
if (inp) {
inp.value = '';
inp.click();
}
return;
}
if (t.dataset.noadd) { newOrderAdd(t.dataset.noadd); return; }
if (t.dataset.noqty) {
const sep = t.dataset.noqty.lastIndexOf(':');
newOrderQty(t.dataset.noqty.slice(0, sep), Number(t.dataset.noqty.slice(sep + 1)));
return;
}
if (t.dataset.notable) {
state.newOrder.table = t.dataset.notable;
if (state.newOrderError === 'table') state.newOrderError = '';
render();
return;
}
if (t.dataset.nocreate) { newOrderCreate(); return; }
if (t.dataset.hallBusy) { hallManage(t.dataset.hallBusy, true, undefined); return; }
if (t.dataset.hallBusyMin) {
const sep = t.dataset.hallBusyMin.lastIndexOf(':');
hallManage(t.dataset.hallBusyMin.slice(0, sep), true, Number(t.dataset.hallBusyMin.slice(sep + 1)));
return;
}
if (t.dataset.hallExt) {
const sep = t.dataset.hallExt.lastIndexOf(':');
hallManage(t.dataset.hallExt.slice(0, sep), true, Number(t.dataset.hallExt.slice(sep + 1)));
return;
}
if (t.dataset.hallFree) { hallManage(t.dataset.hallFree, false, undefined); return; }
if (t.dataset.hallCustomBusy) { hallAskMinutes(t.dataset.hallCustomBusy, true); return; }
if (t.dataset.hallCustomExt) { hallAskMinutes(t.dataset.hallCustomExt, false); return; }
if (t.dataset.hallReserve) { hallAskReserve(t.dataset.hallReserve); return; }
if (t.dataset.hallResCancel) { hallReserveCancel(Number(t.dataset.hallResCancel)); return; }
if (t.dataset.hallDel) { deleteHall(t.dataset.hallDel); return; }
if (t.dataset.mixflavor) { mixToggleFlavor(t.dataset.mixflavor); return; }
if (t.dataset.mixtag) { mixToggleTag(t.dataset.mixtag); return; }
if (t.dataset.mixbrand) { state.mixBrand = t.dataset.mixbrand; render(); return; }
if (t.dataset.mixdel) { deleteMix(t.dataset.mixdel); return; }
if (t.dataset.mixedit) { mixEditOpen(t.dataset.mixedit); return; }
if (t.dataset.mixCancel) { mixFormReset(); return; }
if (t.dataset.sitemixedit) { siteMixEditOpen(t.dataset.sitemixedit); return; }
if (t.dataset.sitemixreset) { siteMixReset(t.dataset.sitemixreset); return; }
if (t.dataset.menuedit) { menuEditOpen(t.dataset.menuedit); return; }
if (t.dataset.menuCancel) { menuFormReset(); return; }
/* --- заметки и доски --- */
if (t.dataset.nboard) {
state.boardId = Number(t.dataset.nboard);
state.noteEditId = null;
state.boardFormOpen = false;
state.notesSince = null;
refreshNotes();
render();
return;
}
if (t.dataset.nboardForm) { state.boardFormOpen = !state.boardFormOpen; render(); return; }
if (t.dataset.nboardRename) { boardRename(); return; }
if (t.dataset.nboardDel) { boardDelete(Number(t.dataset.nboardDel)); return; }
if (t.dataset.nfilter) { state.noteFilter = t.dataset.nfilter; render(); return; }
if (t.dataset.nmove) {
const sep = t.dataset.nmove.lastIndexOf(':');
noteMove(Number(t.dataset.nmove.slice(0, sep)), t.dataset.nmove.slice(sep + 1));
return;
}
if (t.dataset.nedit) { noteEditOpen(Number(t.dataset.nedit)); return; }
if (t.dataset.nformCancel) { noteFormReset(); return; }
if (t.dataset.ndel) { noteDelete(Number(t.dataset.ndel)); return; }
/* --- игры --- */
if (t.dataset.gfilter) { state.gameFilter = t.dataset.gfilter; render(); return; }
if (t.dataset.gtrash) { state.gameTrash = !state.gameTrash; render(); return; }
if (t.dataset.gameRestore) { restoreGame(Number(t.dataset.gameRestore)); return; }
if (t.dataset.gameEdit) { gameEditOpen(Number(t.dataset.gameEdit)); return; }
if (t.dataset.gameCancel) { gameFormReset(); return; }
if (t.dataset.gicon) { state.gameForm.icon = t.dataset.gicon; render(); return; }
if (t.dataset.gaccent) { state.gameForm.accent = t.dataset.gaccent; render(); return; }
if (t.dataset.accentMenu) { state.accentFor = state.accentFor === t.dataset.accentMenu ? '' : t.dataset.accentMenu; render(); return; }
if (t.dataset.setAccent) {
setMenuAccent(t.dataset.setItem, t.dataset.setAccent === '' ? null : t.dataset.setAccent);
return;
}
if (t.dataset.numreset) { resetOrderNumbering(); return; }
if (t.dataset.gameDel) { deleteGame(Number(t.dataset.gameDel)); return; }
if (t.dataset.gphoto) {
// обложка игры: тот же скрытый file-input, но с признаком «игра»
state.photoKind = 'game';
state.photoTarget = t.dataset.gphoto;
const inp = document.getElementById('photoInput');
if (inp) {
inp.value = '';
inp.click();
}
return;
}
if (t.dataset.del) {
const [kind, id] = t.dataset.del.split(':');
if (state.role === 'owner' && await uiConfirm({
title: kind === 'menu' ? 'Удалить позицию меню?' : 'Удалить вкус табака?',
text: kind === 'menu' ? 'Она скроется с сайта и из заказа.' : 'Он скроется с сайта.',
ok: 'Удалить',
})) {
deleteCatalogItem(kind, decodeURIComponent(id));
}
return;
}
if (t.dataset.purge) {
const [kind, id] = t.dataset.purge.split(':');
const what = kind === 'menu' ? 'позицию' : kind === 'tobacco' ? 'вкус' : 'микс';
const warn = kind === 'menu'
? 'Позиция будет стёрта из базы, восстановить её будет нельзя (в отличие от обычного удаления). История заказов сохранится.'
: kind === 'tobacco'
? 'Вкус будет стёрт из базы, восстановить его будет нельзя (в отличие от обычного удаления). Миксы с этим вкусом сохранятся.'
: 'Микс будет стёрт из базы, восстановить его будет нельзя (в отличие от обычного удаления). История заказов сохранится.';
if (await uiConfirm({
title: `Удалить ${what} навсегда?`,
text: warn,
ok: 'Удалить навсегда',
})) {
purgeCatalogItem(kind, id);
}
return;
}
if (t.dataset.restore) {
if (state.role === 'owner') {
const [kind, id] = t.dataset.restore.split(':');
restoreCatalogItem(kind, decodeURIComponent(id));
}
return;
}
if (t.dataset.unstop) {
if (state.role !== 'owner' && state.role !== 'admin') return;
const [kind, id] = t.dataset.unstop.split(':');
const decoded = decodeURIComponent(id);
if (kind === 'menu') toggleMenuStop(decoded, false);
else toggleTobaccoStop(decoded, false);
return;
}
});
document.addEventListener('change', (e) => {
if (e.target.dataset.menuStop) toggleMenuStop(e.target.dataset.menuStop, e.target.checked);
if (e.target.dataset.tobStop) toggleTobaccoStop(decodeURIComponent(e.target.dataset.tobStop), e.target.checked);
if (e.target.dataset.status) setOrderStatus(e.target.dataset.status, e.target.value);
if (e.target.id === 'repFrom') state.reportFrom = e.target.value;
if (e.target.id === 'repTo') state.reportTo = e.target.value;
if (e.target.id === 'menuSortSel') { state.menuSort = e.target.value; render(); }
if (e.target.id === 'tobSortSel') { state.tobaccoSort = e.target.value; render(); }
if (e.target.id === 'mixSortSel') { state.mixSort = e.target.value; render(); }
if (e.target.id === 'newGameKind') { state.gameForm.kind = e.target.value; render(); }
if (e.target.id === 'newGameDifficulty') state.gameForm.difficulty = Number(e.target.value);
if (e.target.id === 'photoInput') {
const file = e.target.files && e.target.files[0];
if (file && state.photoTarget && state.photoKind === 'game') uploadGameCover(state.photoTarget, file);
else if (file && state.photoTarget) uploadPhoto(state.photoTarget, file);
else if (file && !state.photoTarget) toast('Не удалось определить позицию для фото — выберите заново', 't-red');
e.target.value = '';
}
});
/* отмена диалога выбора фото — сбрасываем цель, чтобы следующий выбор
не ушёл в никуда */
document.addEventListener('cancel', (e) => {
if (e.target.id === 'photoInput') state.photoTarget = '';
});
document.addEventListener('submit', (e) => {
if (e.target.id === 'userForm') { e.preventDefault(); addUser(e.target); }
if (e.target.id === 'hallForm') { e.preventDefault(); addHall(); }
if (e.target.id === 'menuForm') { e.preventDefault(); state.menuEditId != null ? saveMenuItem(e.target) : addMenuItem(e.target); }
if (e.target.id === 'tobaccoForm') { e.preventDefault(); addTobacco(e.target); }
if (e.target.id === 'mixForm') {
e.preventDefault();
if (state.siteMixEditId != null) saveSiteMix();
else if (state.mixEditId != null) saveMix();
else addMix();
}
if (e.target.id === 'noteForm') { e.preventDefault(); noteSubmit(); }
if (e.target.id === 'boardForm') { e.preventDefault(); boardCreate(); }
if (e.target.id === 'gamesForm') { e.preventDefault(); state.gameEditId != null ? saveGame() : addGame(); }
});
document.addEventListener('input', (e) => {
if (e.target.id === 'menuSearch' || e.target.id === 'tobSearch' || e.target.id === 'noSearch' || e.target.id === 'mixSearch') {
const key = e.target.id === 'menuSearch' ? 'menuQuery'
: e.target.id === 'tobSearch' ? 'tobaccoQuery'
: e.target.id === 'mixSearch' ? 'mixQuery' : 'newOrderQuery';
state[key] = e.target.value;
const pos = e.target.selectionStart;
render();
const inp = document.getElementById(e.target.id);
if (inp) { inp.focus(); inp.setSelectionRange(pos, pos); }
}
/* комментарий заказа живёт в state — перерисовка не нужна */
if (e.target.id === 'noComment') state.newOrder.comment = e.target.value;
/* поля формы микса — в state, чтобы переживать перерисовку;
исправление поля снимает его ошибку валидации */
const MIX_ERROR_FIELDS = { newMixName: 'newMixName', newMixStrength: 'newMixStrength',
newMixCooling: 'newMixCooling', newMixPrice: 'newMixPrice' };
if (MIX_ERROR_FIELDS[e.target.id]) {
state.newMix[e.target.id === 'newMixName' ? 'name'
: e.target.id === 'newMixStrength' ? 'strength'
: e.target.id === 'newMixCooling' ? 'cooling' : 'price'] = e.target.value;
if (state.mixErrors[MIX_ERROR_FIELDS[e.target.id]]) {
delete state.mixErrors[MIX_ERROR_FIELDS[e.target.id]];
render();
}
return;
}
if (e.target.id === 'newMixNote') state.newMix.note = e.target.value;
/* поля формы заметки — тоже в state (переживают перерисовку опроса) */
if (e.target.id === 'ntTitle') state.noteForm.title = e.target.value;
if (e.target.id === 'ntBody') state.noteForm.body = e.target.value;
if (e.target.id === 'ntAssignee') state.noteForm.assignee = e.target.value;
if (e.target.id === 'ntDue') state.noteForm.due = e.target.value;
if (e.target.id === 'ntImportant') state.noteForm.important = e.target.checked;
});
document.getElementById('loginForm').addEventListener('submit', (e) => {
e.preventDefault();
const loginValue = document.getElementById('loginUser').value.trim();
const password = document.getElementById('loginPass').value;
if (!loginValue || !password) {
showLoginError('Введите логин и пароль');
return;
}
login(loginValue, password);
});
document.getElementById('logoutBtn').addEventListener('click', () => logout());
document.getElementById('passwordBtn').addEventListener('click', async () => {
const v = await uiPassword({ title: 'Сменить пароль' });
if (!v) return;
if (!v.current || !v.next) { toast('Заполните оба поля', 't-red'); return; }
if (v.next.length < 8) { toast('Новый пароль — минимум 8 символов', 't-red'); return; }
try {
await api('/api/session/password', {
method: 'PATCH',
body: JSON.stringify({ current_password: v.current, new_password: v.next }),
});
toast('Пароль изменён — все другие сессии выключены', 't-green');
} catch (e) {
if (e.message !== 'session expired') toast('Ошибка: ' + e.message, 't-red');
}
});
document.getElementById('offlineRetry').addEventListener('click', retryConnection);
document.getElementById('bellBtn').addEventListener('click', () => {
state.view = 'orders';
render();
});
function renderShiftActions() {
const el = document.getElementById('shiftActions');
if (!el || !state.role) return;
if (SHIFT_ROLES_JS.includes(state.role)) {
if (state.onShift) {
const mine = state.onShiftList.find((s) => s.login === state.login);
el.innerHTML = `<span class="badge b-done"><span class="b-dot"></span>На смене${mine ? ' с ' + fmtTime(mine.started_at) : ''}</span>
<button class="btn btn-ghost btn-sm" data-shift="out" type="button">Закончить смену</button>`;
} else if (clockInAllowed()) {
el.innerHTML = `<button class="btn btn-neon btn-sm" data-shift="in" type="button">Выйти на смену</button>`;
} else {
el.innerHTML = `<span class="chip shift-countdown" id="shiftCountdown">${countdownText()}</span>`;
}
return;
}
// владелец и администратор видят, кто вышел на смену
el.innerHTML = state.onShiftList.length
? `<span class="chip">На смене: ${state.onShiftList.map((s) => esc(s.name)).join(', ')}</span>`
: '';
}
/* ---------------- часы + смена: расписание зависит от дня недели (Пн — выходной) ---------------- */
const SHIFT_SCHEDULE = {
0: '19:00–01:00', // Вс
1: null, // Пн — выходной
2: '19:00–01:00', // Вт
3: '19:00–01:00', // Ср
4: '19:00–01:00', // Чт
5: '19:00–03:00', // Пт
6: '19:00–03:00', // Сб
};
const WEEKDAY_SHORT = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
function renderShift() {
const el = document.getElementById('shift');
if (!el) return;
// идущая смена (в т.ч. перетёкшая через полночь) важнее календарного дня:
// пока она не закончилась, расписание на следующий день не показываем
for (const off of [-1, 0]) {
const w = shiftWindow(off);
if (w && Date.now() <= w.end.getTime()) {
const day = w.start.getDay();
el.textContent = `Смена · ${WEEKDAY_SHORT[day]} · ${SHIFT_SCHEDULE[day]}`;
return;
}
}
const day = new Date().getDay();
const hours = SHIFT_SCHEDULE[day];
el.textContent = hours
? `Смена · ${WEEKDAY_SHORT[day]} · ${hours}`
: `Сегодня выходной · ${WEEKDAY_SHORT[day]}`;
}
/* окно смены (начало/конец) для дня со смещением; null — выходной */
function shiftWindow(dayOffset) {
const d = new Date();
d.setDate(d.getDate() + dayOffset);
const hours = SHIFT_SCHEDULE[d.getDay()];
if (!hours) return null;
const start = new Date(d);
start.setHours(19, 0, 0, 0);
// конец берём из расписания: «19:00–01:00» → 01:00 следующего дня
const endH = parseInt(hours.split('–')[1].split(':')[0], 10);
const end = new Date(d);
end.setHours(endH, 0, 0, 0);
if (end <= start) end.setDate(end.getDate() + 1); // смена переходит через полночь
return { start, end };
}
function clockInAllowed() {
const now = Date.now();
for (const off of [-1, 0]) {
const w = shiftWindow(off);
if (w && w.start.getTime() - 20 * 60000 <= now && now <= w.end.getTime()) return true;
}
return false;
}
function nextShiftStart() {
const now = Date.now();
for (let off = 0; off < 8; off++) {
const w = shiftWindow(off);
if (w && now < w.start.getTime()) return w.start;
}
return null;
}
function countdownText() {
const next = nextShiftStart();
if (!next) return 'Заведение закрыто';
const mins = Math.max(0, Math.round((next - Date.now()) / 60000));
const h = Math.floor(mins / 60);
const m = mins % 60;
return h ? `До смены ${h} ч ${m} мин` : `До смены ${m} мин`;
}
/* обратный отсчёт таймеров занятости: элементы с data-deadline (epoch ms) */
function tickTimers() {
const now = Date.now();
for (const el of document.querySelectorAll('[data-deadline]')) {
const left = Number(el.dataset.deadline) - now;
if (!Number.isFinite(left)) continue;
if (left <= 0) {
el.textContent = el.dataset.done || 'освобождается…';
continue; // сервер сам очистит истёкший таймер в очередном опросе
}
const s = Math.floor(left / 1000);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
el.textContent = h
? `${h}:${pad2(m)}:${pad2(s % 60)}`
: `${m}:${pad2(s % 60)}`;
}
}
function tickClock() {
const d = new Date();
document.getElementById('clock').textContent =
pad2(d.getHours()) + ':' + pad2(d.getMinutes());
if (d.getSeconds() === 0) renderShift(); // раз в минуту достаточно
// обратный отсчёт до смены у официанта
const cd = document.getElementById('shiftCountdown');
if (cd) {
if (clockInAllowed()) renderShiftActions(); // пора показать кнопку
else cd.textContent = countdownText();
}
tickTimers();
}
setInterval(tickClock, 1000);
tickClock();
renderShift();
/* ---------------- автопрокрутка (кнопки ▲/▼) ---------------- */
/* Длинные списки (меню, табаки, канбан) листаем плавно: кнопки в правом
нижнем углу, видны только когда страница действительно скроллится. */
const SCROLL_STEP = Math.round(window.innerHeight * 0.75);
function scrollPage(dir) {
window.scrollBy({ top: dir * SCROLL_STEP, behavior: 'smooth' });
}
function updateScrollNav() {
const nav = document.getElementById('scrollNav');
if (!nav || !state.role) return;
const scrollable = document.documentElement.scrollHeight - window.innerHeight > 120;
nav.classList.toggle('hidden', !scrollable);
const up = document.getElementById('scrollUp');
const down = document.getElementById('scrollDown');
up.classList.toggle('is-edge', window.scrollY <= 4);
down.classList.toggle('is-edge',
window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 4);
}
document.getElementById('scrollUp').addEventListener('click', () => scrollPage(-1));
document.getElementById('scrollDown').addEventListener('click', () => scrollPage(1));
window.addEventListener('scroll', updateScrollNav, { passive: true });
window.addEventListener('resize', updateScrollNav);
/* автологин по сохранённой сессии + опрос заказов */
(async function boot() {
if (!localStorage.getItem(TOKEN_KEY)) return;
try {
const me = await api('/api/session');
state.role = me.role;
state.login = me.login;
state.name = me.name;
state.view = SHIFT_ROLES_JS.includes(me.role) ? 'orders' : 'dashboard';
document.getElementById('loginScreen').classList.add('hidden');
document.getElementById('app').classList.remove('hidden');
await Promise.all([refreshOrders(), refreshCatalog(), refreshShifts(), refreshHalls()]);
state.lastOrderNumber = state.orders.reduce((m, o) => Math.max(m, o.id), 0);
render();
} catch {
logout(true);
}
})();
setInterval(pollOrders, 10_000);
setInterval(pollNotes, 12_000);
/* период отчёта по умолчанию: с начала месяца по сегодня */
(function initReportRange() {
const pad = (n) => String(n).padStart(2, '0');
const now = new Date();
const iso = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
state.reportTo = iso(now);
state.reportFrom = iso(new Date(now.getFullYear(), now.getMonth(), 1));
})();