3871 lines
190 KiB
JavaScript
3871 lines
190 KiB
JavaScript
/* ============================================================
|
||
НЕОН 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) => (
|
||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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">через <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));
|
||
})();
|