/* ============================================================ НЕОН 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 */ 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 */ 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 — ввод одного нового пароля без текущего (сброс пароля сотрудника владельцем) */ 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 = ` ${o.text ? `` : ''} ${o.newPass ? `` : o.confirm || !o.passwords ? `` : ` `} `; 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) => ` `).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 `Свободен${resHint}`; if (st.busy_until) { return `Занят · ` + `${resHint}`; } return `Занят · без таймера${resHint}`; } 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) => `
№${o.number} ${esc(o.table)} · ${o.items.length} поз. · ${money(orderTotal(o))} ${STATUS[o.status].label}
`).join(''); const hallList = halls.map((h) => `
${esc(h.name)}
${esc(h.sub)}
${hallBadge(h.name)}
`).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) => `
⛔${esc(s.name)} ${esc(s.type)}
`).join(''); return `
${state.orders.filter(o => o.status === 'new').length}
Новых заказов
ожидают подтверждения
${active}
В работе
кальяны и кухня готовят
${done}
Выдано сегодня
выручка ${money(revenue)}
${stoppedMenu.length + stoppedTob.length}
Позиций в стопе
${stoppedMenu.length} ${plural(stoppedMenu.length, ['позиция', 'позиции', 'позиций'])} меню · ${stoppedTob.length} ${plural(stoppedTob.length, ['табак', 'табака', 'табаков'])}
${avgDur != null ? fmtDur(avgDur) : '—'}
Средняя подача
от принятия до выдачи
Последние заказы сейчас
${miniOrders}
${!SHIFT_ROLES_JS.includes(state.role) ? `
Сейчас на смене
${state.onShiftList.length ? `
${state.onShiftList.map((s) => `
${esc(s.name)} на смене с ${fmtTime(s.started_at)}
`).join('')}
` : '
Никто не на смене
'} ${(() => { const off = state.staffList.filter((s) => !s.on_shift); if (!off.length) return ''; return `
Записать на смену
${off.map((s) => ``).join('')}
`; })()}
` : ''}
Залы
${hallList}
Сейчас в стопе
${stopItems || '
Стоп-лист пуст — всё доступно
'}
`; } /* ---------------- сортировка списков (владелец) ---------------- */ 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) => ` `; 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 ? `` : ''; } return ` ${isOwner ? ` ` : ''}`; }; const stockBadge = (m) => (m.stock == null ? '' : `📦 ${m.stock} шт`); const accentPicker = (m) => { if (state.accentFor !== m.id) return ''; return `
Подсветка на сайте: ${NEON_ACCENTS.map((c) => ` `).join('')} ${m.accent ? `` : ''}
`; }; const rows = items.map((m) => ` `).join(''); return ` ${state.role === 'owner' ? `
${state.menuEditId ? 'Редактирование позиции' : 'Добавить позицию'} ${state.menuEditId ? 'изменения появятся на сайте сразу' : 'появится на сайте с пометкой «Новинка» на 7 дней'}
` : ''}
ℹ Переключите тумблер, чтобы отправить позицию в стоп — на сайте neon-bar86.ru она мгновенно помечается как «нет в наличии» и исчезает из онлайн-заказа. ${isOwner ? '📦 Штучный учёт: вбейте купленное количество банок — при выдаче заказа остаток уменьшится сам, а по нулю позиция уйдёт в стоп-лист.' : ''}
${cats.map((c) => ``).join('')}
${sortSelect('menuSortSel', MENU_SORTS, state.menuSort)}
`; } 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) => ` `).join(''); return ` ${state.role === 'owner' ? `
Добавить вкус появится на сайте с пометкой «Новинка» на 7 дней
` : ''}
ℹ Стоп-лист ведётся по отдельным вкусам, а не по маркам. Вкус в стопе нельзя выбрать в конструкторе кальяна на сайте — карточка вкуса помечается «Закончился». Используйте при окончании запаса конкретной позиции.
${brands.map((b) => ``).join('')}
${sortSelect('tobSortSel', TOBACCO_SORTS, state.tobaccoSort)}
`; } /* ---------------- миксы для сайта (владелец и кальянщик) ---------------- */ /* подписи крепости/холодка миксов — как на сайте (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] ? `` : ''); 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) => ` `).join(''); /* авторские миксы сайта (seed catalog.ts): название/описание/состав/цена правятся здесь и применяются на сайте через /api/catalog */ const siteRows = state.siteMixes.map((m) => ` `).join(''); return `
ℹ ${state.role === 'hookah' ? 'Миксы видны гостям в разделе «Миксы НЕОНа» на сайте: название и состав, крепость, холодок, цена. Теги подмешивают микс в автоподбор на сайте. Пропорции забивки гость не видит. Кнопка ✎ — изменить название или состав микса.' : 'Миксы видны гостям в разделе «Миксы НЕОНа» на сайте. Указывайте только сочетание вкусов — пропорции подбирает кальянщик, гость их не видит. Кнопка ✎ у микса — правка названия, состава и описания; в блоке «Авторские миксы сайта» ✎ правит витринный микс, «Вернуть оригинал» отменяет правку.'}
${state.siteMixEditId ? 'Правка витринного микса' : state.mixEditId ? 'Редактирование микса' : 'Добавить микс'} ${state.siteMixEditId ? 'изменения появятся на сайте сразу · очистите состав, чтобы вернуть исходный' : state.mixEditId ? 'изменения появятся на сайте сразу' : 'появится на сайте сразу · заметка не обязательна'}
${state.siteMixEditId || state.mixEditId ? '' : ''} ${fieldError('newMixName')}${fieldError('newMixStrength')}${fieldError('newMixCooling')}${fieldError('newMixPrice')}
${state.siteMixEditId && state.newMix.flavorIds.length === 0 ? '

Состав не выбран — на сайте останется исходный состав этого микса

' : ''}
Теги (для автоподбора): ${MIX_TAGS.map((t) => ` `).join('')}
${fieldError('tags')}
${chosen.length ? `Состав (${chosen.length}): ` + chosen.map((t) => ``).join('') : 'Выберите 2–5 вкусов из списка ниже'}
${fieldError('flavors')}
${brands.map((b) => ``).join('')}
${choices.map((t) => ` `).join('') || 'Ничего не найдено'}
Миксов: ${sortedMixes.length} ${sortSelect('mixSortSel', MIX_SORTS, state.mixSort)}
${state.siteMixes.length ? `
Авторские миксы сайта ${state.siteMixes.length} шт. — читаются из каталога сайта, здесь только просмотр
` : ''}`; } function orderCard(o, actions) { const items = o.items.map((i) => { // 💨 у кальянов (в каталоге их ровно два, оба начинаются со «Кальян»): // на планшете кальянщика свой ли это заказ видно без чтения списка const hookah = /^кальян/i.test(i.name); return ` ${hookah ? '💨 ' : ''}${i.qty}${esc(i.name)}${money(i.price * i.qty)}`; }).join(''); const dur = o.status === 'done' && o.duration_seconds != null ? `
⏱ принят → выдан за ${fmtDur(o.duration_seconds)}
` : ''; const guest = o.guest_name ? `
гость: ${esc(o.guest_name)}${o.guest_phone ? ' · ' + esc(o.guest_phone) : ''}
` : ''; const comment = o.comment ? `
💬 ${esc(o.comment)}
` : ''; const origin = o.source === 'site' ? 'заказ с сайта' : 'заказ из зала'; return `
Заказ №${o.number} ${esc(o.table)} ${fmtTime(o.created_at)}
    ${items}
${money(orderTotal(o))}
${o.waiter ? 'официант: ' + esc(o.waiter) : origin}
${guest} ${comment} ${dur}
${actions}
`; } /* выданные заказы исчезают из канбана официанта через час после выдачи (админ и владелец видят полный список в таблице заказов) */ 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) => `
` : c.key === 'active' ? (o) => `
` : () => ''; const cards = orders.map((o) => orderCard(o, action(o))).join(''); return `
${c.title}${orders.length}
${cards || '
Пусто
'}
`; }).join(''); return `
${blocked ? '🔒' : '💡'} ${blocked ? 'Смена закрыта — управление заблокировано. Кнопка «Выйти на смену» появится за 20 минут до начала, отсчёт — в шапке.' : 'Нажмите «Принять», когда взяли заказ в работу, затем «Выдать». Мгновенный заказ (напиток из холодильника) — «✓ Сразу выдать» в один тап. 💨 в составе — кальян. Ошибочный или отменённый гостем заказ — «Отменить».'}
${cols}
`; } 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 ``; }).join(''); const rows = orders.map((o) => `
№${o.number} ${fmtTime(o.created_at)} ${o.items.map((i) => `${i.qty}×${esc(i.name)}`).join(' · ')}${o.comment ? ` · 💬 ${esc(o.comment)}` : ''} ${esc(o.waiter || '—')} ${money(orderTotal(o))} ⏱ ${fmtDur(o.duration_seconds)}
`).join(''); return ` ${state.role === 'owner' ? `
` : ''}
${chips}
ЗаказВремяСоставОфициантСуммаВыполнен заСтатус
${rows || '
Нет заказов по фильтру
'}
`; } 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 `
Стоп-лист пуст — все позиции доступны 😉
`; } const row = (name, type, price) => ` `; return `
ℹ ${hookahOnly ? 'Эти вкусы табака сейчас нельзя забить в кальян — вкус помечен «Закончился» в конструкторе на сайте. Снять или вернуть стоп можно во вкладке «Табаки».' : 'Эти позиции нельзя заказать — сообщайте гостям при приёме заказа. Изменить стоп-лист может только администратор.'}
`; } /* ---------------- залы: занятость столов и зон + брони ---------------- */ /* «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 `
🔒 Бронь ${fmtTime(res.from)}–${fmtTime(res.until)}${res.guest ? ' · ' + esc(res.guest) : ''} ${ongoing ? `идёт · до ${fmtTime(res.until)}` : `через `} ${canManage ? `` : ''}
${more ? `
ещё брони: ${more}
` : ''}`; } 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 ? `
⏳
` : ''; const meta = h.busy ? `
занял: ${esc(h.busy_by || '—')}${h.busy_until ? ' · до ' + fmtTime(h.busy_until) : ' · без таймера'}
` : ''; const reserve = hallReserveBlock(h, canManage); const controls = !canManage ? '' : h.busy ? `
` : `
${isOwner ? `` : ''}
`; return `
${esc(h.name)}
${esc(h.sub || '')}
${h.busy ? 'Занят' : 'Свободен'}
${meta}${timer}${reserve}${controls}
`; }) .join(''); return `
ℹ ${canManage ? 'Помечайте столы и зоны занятыми — по желанию с таймером до освобождения. 🔒 Бронь: стол помечается на нужное время, отсчёт виден всем, а за 20 минут до прихода гостя панель напомнит. До начала брони стол остаётся свободным для гостей.' : 'Занятость и брони ведут администратор и владелец. Видно, какие столы заняты, сколько осталось до освобождения и когда ожидается гость с бронью.'}
${isOwner ? `
Добавить стол или зал сразу появится в залах CRM и в выборе столика на сайте
` : ''}
${cards || '
Загрузка состояния залов…
'}
`; } /* ---------------- владелец: сотрудники и отчёты ---------------- */ function viewUsers() { const roleBadge = (r) => r === 'owner' ? 'Владелец' : r === 'admin' ? 'Администратор' : r === 'hookah' ? 'Кальянщик' : 'Официант'; const shiftByLogin = {}; for (const sc of state.shiftCounts) shiftByLogin[sc.login] = sc.shifts; const rows = state.users.map((u) => ` `).join(''); return `
ℹ Владелец создаёт официантов и администраторов — имя, логин, пароль. Администратор управляет меню, табаками и заказами; официант — только заказами. Кнопка «Пароль» задаёт сотруднику новый пароль (его сессии закроются). Удалённый сотрудник теряет доступ сразу.
Добавить сотрудника
`; } function viewReports() { const r = state.report; return `
ℹ Отчёт собирается из базы заказов. Выручка — только по выданным заказам, отменённые показываются отдельно и в выручку не входят.
Период
${r ? reportBlock(r) : '
Выберите период и нажмите «Построить отчёт»
'}`; } 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) => `
${esc(d.day)}${d.orders}${money(d.revenue)}${d.cancelled}
`).join(''); const top = r.top_items.length ? r.top_items.map((i, idx) => `
${idx + 1}. ${esc(i.name)}${i.qty} шт${money(i.revenue)}
`).join('') : '
Нет проданных позиций за период
'; 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]) => `
${label}${s.orders}${money(s.revenue)}
`) .join(''); const sourcesTotal = (src.site || src.hall) ? `
Всего (сайт + зал)${totalOrders}${money(totalRevenue)}
` : ''; const tables = (r.top_tables || []).map((x) => `
${esc(x.table)}${x.orders}${money(x.revenue)}
`).join(''); const hours = (r.by_hour || []).filter((h) => h.orders || h.revenue).map((h) => `
${pad2(h.hour)}:00–${pad2((h.hour + 1) % 24)}:00${h.orders}${money(h.revenue)}
`).join(''); return `
${money(t.revenue)}
Выручка
выданные заказы
${t.orders}
Заказов всего
новых: ${t.new} · в работе: ${t.active}
${t.done}
Выдано
средний чек ${money(t.avg_check)}
${t.cancelled}
Отменено
${money(t.cancelled_amount)}
${dur}
Средняя подача
от принятия до выдачи
По дням
ДеньЗаказовВыручкаОтменено
${days || '
Нет заказов за период
'}
Топ позиций без отменённых
${top}
Источники заказов без отменённых
ИсточникЗаказовВыручка
${sources}${sourcesTotal}
Топ столов и зон
Стол / зонаЗаказовВыручка
${tables || '
Нет заказов за период
'}
По часам когда идёт поток — заказы без отменённых
${hours || '
Нет заказов за период
'}
${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) => `
${esc(u.name)} · ${esc(roleLabel[u.role] || u.role)} ${u.shifts} см · ${fmtDur(u.hours_seconds)} ${u.orders_total} / ${u.orders_done} / ${u.orders_cancelled} ${money(u.revenue)}${u.avg_check != null ? ' · ср. ' + money(u.avg_check) : ''} ${u.avg_duration_seconds != null ? fmtDur(u.avg_duration_seconds) : '—'} 🌐 ${u.site_orders} · 🏠 ${u.hall_orders}
`).join(''); return `
Сотрудники за период ${esc(s.period ? s.period.from + ' — ' + s.period.to : '')}
СотрудникСменыЗаказыВыручкаПодачаИсточники
${rows || '
Сотрудников нет
'}
`; } /* ---------------- владелец/персонал: ручной заказ из зала ---------------- */ 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 `
${esc(nameOf(id))} ${money(priceOf(id))} × ${qty} = ${money(priceOf(id) * qty)} ${qty}
`; }).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 ? `
🔒 Смена закрыта — создание заказов заблокировано. Выйдите на смену из шапки.
` : ''}
${isHookah ? `
Кальяны
${hookahs.map((h) => ``).join('')}
` : ''}
Меню
${cats.map((c) => ``).join('')}
${itemsError ? '' : ''}
Стол / зал
${halls.map((h) => { const busy = hallByName(h.name)?.busy; return ``; }).join('')}
${tableError ? '' : ''}
Ваш заказ ${esc(state.newOrder.table || 'стол не выбран')}
${orderRows || '
Добавьте позиции из меню
'}
Итого${money(total)}
`; } /* ---------------- рендер ---------------- */ /* Значения полей форм переживают перерисовку (опрос заказов рендерит каждые 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]) => ``).join(''); return `
${n.important ? '❗ ' : ''}${esc(n.title)} ${overdue ? 'просрочено' : ''} ${n.important ? 'важно' : ''}
${n.body ? `
${esc(n.body)}
` : ''}
👤 ${n.assignee ? esc(n.assignee.name) : 'исполнитель не назначен'}
${n.due_date ? '⏳ до ' + fmtDate(n.due_date) + ' · ' : ''}автор: ${esc(n.author.name)}
${moveBtns}
`; } function viewNotes() { const board = currentBoard(); if (!board) { /* доски ещё грузятся или выбранная только что удалена */ return '
Загрузка заметок…
'; } const isShared = board.kind === 'shared'; const members = board.members || []; /* селектор доски: официант видит одну «Общую» — это норма */ const boardChips = state.boards.map((b) => ` `).join(''); const ownerChips = state.role === 'owner' ? ` ${!isShared ? ` ` : ''}` : (state.role === 'admin' && !isShared ? '' : ''); const boardForm = state.boardFormOpen && state.role === 'owner' ? `
Новая приватная доска ровно двое: вы и администратор
` : ''; /* фильтр «все / мои» */ 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) => ` `).join(''); const editing = state.noteEditId != null; const editNote = editing ? state.notes.find((n) => n.id === state.noteEditId) : null; const f = state.noteForm; const noteForm = `
${editing ? 'Редактировать задачу' : 'Новая задача'} ${isShared ? 'видна всем сотрудникам' : 'видна только вам двоим'}
${editing ? ` ${editNote && canDeleteNote(editNote) ? `` : ''}` : ''}
`; const cols = NOTE_COLS.map((c) => { const notes = filtered.filter((n) => n.status === c.key); return `
${c.title}${notes.length}
${notes.map(noteCard).join('') || '
Пусто
'}
`; }).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 `
${esc(e.user_name)} ${esc(what + extra)} ${fmtTime(e.created_at)}
`; }).join(''); return `
🗒 Задачи не привязаны к смене — доступны всегда после входа. Перетащите карточку мышью или кнопками, чтобы сменить статус. Клик по карточке — редактирование.
${boardChips}${ownerChips}
${boardForm}
${filterChips}
${noteForm}
${cols}
Последние события журнал доски
${feed || '
Пока тихо
'}
`; } /* защита от двойного сабмита (двойной клик по кнопке создаёт дубль) */ 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 ? `` : `
${emoji}
`; const controls = g.deleted ? `` : ` ${g.kind === 'ps5' && canManage ? ` ` : ''} ${canManage ? ` ` : ''}`; return `
${cover}
${GAME_KIND_LABELS[g.kind]}
${esc(g.title)}
${g.note ? `
${esc(g.note)}
` : ''}
${controls}
`; } /* форма добавления/редактирования игры; у настолок — сложность и логотип */ function gameFormHtml() { const editing = state.gameEditId != null; const f = state.gameForm; const isBoard = f.kind === 'board'; return `
${editing ? 'Редактировать игру' : 'Добавить игру'} ${isBoard ? 'сложность и логотип — как на сайте' : ''}
${isBoard ? ` ` : ''} ${editing ? '' : ''}
${isBoard ? `
Логотип: ${GAME_ICONS.map((i) => ` `).join('')}
Цвет: ${NEON_ACCENTS.map((c) => ` `).join('')}
` : ''}
`; } 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 ``; }).join(''); const trashChip = canManage && state.games.some((g) => g.deleted) ? `` : ''; const filtered = pool.filter((g) => state.gameFilter === 'all' || g.kind === state.gameFilter); const cards = filtered.map((g) => gameCard(g, canManage)).join(''); return `
🎮 ${canManage ? 'Ведите библиотеку: игры PS5 — с обложкой (до 6 МБ), настолки — со сложностью и логотипом (цвет+значок, как на сайте). Кнопка ✎ — редактирование.' : 'Список игр бара: предлагайте гостям то, что свободно прямо сейчас. Библиотеку ведёт владелец.'}
${canManage ? gameFormHtml() : ''}
${kinds}${trashChip}
${cards || '
Игр пока нет — добавьте первую
'}
`; } 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 = `На смене${mine ? ' с ' + fmtTime(mine.started_at) : ''} `; } else if (clockInAllowed()) { el.innerHTML = ``; } else { el.innerHTML = `${countdownText()}`; } return; } // владелец и администратор видят, кто вышел на смену el.innerHTML = state.onShiftList.length ? `На смене: ${state.onShiftList.map((s) => esc(s.name)).join(', ')}` : ''; } /* ---------------- часы + смена: расписание зависит от дня недели (Пн — выходной) ---------------- */ 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)); })();