commit da5b259d069bf7d7f2962c4b66081bb9de2205a6 Author: Merrow <5x3.root@gmail.com> Date: Sun Oct 11 22:24:54 2026 +0500 initial site diff --git a/.agents/skills/fix-finding/SKILL.md b/.agents/skills/fix-finding/SKILL.md new file mode 100644 index 0000000..4b30424 --- /dev/null +++ b/.agents/skills/fix-finding/SKILL.md @@ -0,0 +1,218 @@ +--- +name: fix-finding +description: Исправление и верификация конкретной security-находки в проекте НЕОН (site/crm/db). Использовать, когда пользователь явно просит исправить, закрыть или залечить уязвимость или finding из аудита — «исправь F-3», «закрой находку из AUDIT/FINDINGS.md», «почини IDOR из аудита и проверь». НЕ использовать для обычных багов, рефакторинга, ревью кода/дизайна, полного аудита (полный аудит делает скилл security-audit — он только документирует) или деплоя. +--- + +# Fix Finding — минимальный проверенный фикс security-находки в НЕОН + +Превратить текущую security-находку в минимальный, проверенный на живом стенде +код-фикс. Если код уже безопасен — доказать это и вернуть `no_change`. +Аудит (поиск и документирование) делает скилл `security-audit`; этот скилл — +вторая стадия над его результатами. + +## Источник находки + +Найди ID и контекст в одном из мест: + +- `AUDIT/FINDINGS.md` — вывод аудит-скилла `security-audit`; +- `АУДИТ-ПЕРЕД-РЕЛИЗОМ.md` — предрелизный аудит, нумерация 1–38 + (пункт с пометкой «вопрос к владельцу» — не фикси, это `blocked`); +- F-N — сквозная нумерация security-фиксов в комментариях кода + (`docker-compose.yml`, `crm/server.py`) и в `crm/_security_smoke.mjs`; +- прямое описание от пользователя (файл/эндпоинт + путь атаки). + +Если в находке нет файла/эндпоинта — сначала локализуй путь атаки по коду. +Фиксить можно только явно запрошенную находку; соседние находки — доложить, +не чинить. + +## Порядок свойств результата + +Оценивай результат в этом порядке; более раннее никогда не меняется на более +позднее. «Минимальный» = наименьшее проектно-нативное изменение, закрывающее +все более ранние свойства, а не наименьшее число строк: + +1. текущее состояние корректно классифицировано: уязвимо / уже безопасно / недоказано +2. фикс полностью закрывает нарушенную security-границу +3. легитимное поведение и совместимость сохранены +4. проверки проекта (см. гейты ниже) пройдены +5. реализация следует конвенциям проекта +6. патч содержит только необходимый охват + +## Карта проекта + +- `crm/` — Python 3, stdlib + psycopg, без фреймворков. `server.py` — HTTP + + REST + роли; `notes_api.py` — доски заметок (примешан в Handler); + `storage.py` — PostgreSQL (боевой), `storage_sqlite.py` — dev-двойник + (ограничен: игры/доски/брони/миксы сайта/сброс нумерации → 501); + `js/app.js` + `index.html` + `css/` — панель персонала; `seed/*.json` — + каталог; `migrate/notes_boards.sql` — канонический DDL досок. +- `site/` — React + Vite + Tailwind, собирается в статику под nginx. + `nginx.conf` — edge: лимит `POST /api/orders` (10/мин с IP), real_ip из + X-Forwarded-For, `/media/` отдаёт фото CRM; `src/lib/crm.ts` — мост к API. +- `docker-compose.yml` — site (наружу `127.0.0.1:8080`), crm + (`127.0.0.1:47613`), db (PostgreSQL 17, не публикуется). Секреты только + из `.env`. +- Роли CRM: владелец / администратор / официант / кальянщик — матрица в + README; пять слоёв защиты заказов (nginx-лимит → лимит на гостя → + дедупликация → глобальный тормоз → honeypot). +- Git в проекте НЕТ: перед правкой делай снимок (см. ниже), diff собирай + по снимкам. + +Фикс может жить на любом из трёх слоёв (nginx → server.py → storage). +Логика storage продублирована в двух бэкендах: фикс в `storage.py` почти +всегда должен зеркалиться в `storage_sqlite.py` (и наоборот) — аудит не раз +писал «в обоих бэкендах». Учитывай и панель (`crm/js/app.js`): что она +ожидает от изменённого эндпоинта. + +## Патч-контракт (до правки) + +Изучи саму реализацию, её прямых вызователей, соседние хелперы и разметку +роль×эндпоинт в README. Установи из кода, а не из находки: + +- атакующий вход и конкретный путь источник → sink (или сломанный контроль); +- security-инвариант и узкую общую границу, где его enforce; +- легитимное поведение, API-контракты, семантику ошибок, совместимость; +- ближайшие прецеденты в этом же коде (экранирование `esc()` в панели, + параметризованный SQL `%s`, whitelist статики, лимитеры). + +Находка — задача о потоке данных и границе, а не о примере входа из отчёта. +Проверь эквивалентные кодировки, alternate-формы парсера, алиасы, всех +вызователей, оба бэкенда и каждую копию security-чувствительного состояния, +которая может обойти фикс. Небезопасное состояние обрабатывай явно — не +принимай, не обрезай и не переинтерпретируй его молча. + +## Предпатчевое расследование + +До правки запусти один свежий read-only субагент (Agent tool, general-purpose, +с инструкцией не менять файлы). Если делегирование недоступно — сделай тот же +проход сам отдельно, свежим взглядом: + +- **Исследователь границы и совместимости:** независимо трассирует путь + источник → sink, находит общую границу enforcement, затронутые входы, + альтернативные представления и состояния, переходы валидация → + использование, конкретные sibling-пути и обходы. Фиксирует легитимные + сценарии и публичное поведение, которые обязаны остаться, и проверяет + вызователей, режимы, ошибки, побочные эффекты, конвенции, готовые хелперы + и применимые команды проверки. + +Расследование требует ссылок на файлы/строки проекта и разделения фактов, +выводов и открытых вопросов. По завершении сверь с собственной трассировкой +и выбери границу фикса. + +## Снимки вместо git + +Перед первым изменением скопируй оригиналы изменяемых файлов в +`AUDIT/FIXES/.snapshots//` (создай каталог). По снимкам собери diff для +ревью и отчёта (PowerShell `Compare-Object`, `fc` или `git diff --no-index`, +если git есть на хосте). Не оставляй временных файлов-двойников вида `.bak` +в рабочем дереве после завершения. + +## Реализация + +1. Протрассируй путь и верни `no_change`, если код уже безопасен — без + спекулятивных правок. +2. Где возможно, прогони через границу минимальный высокосигнальный repro + и один легитимный контрольный запрос по тому же пути (живой стенд ниже). +3. Сделай наименьший фикс на общей границе. Предпочитай соседние хелперы и + установленные API. Не расширяйся на редизайн, уборку и соседние находки. +4. До верификации атакуй свой патч, а не защищай: проверь каждого прямого + вызователя изменённого хелпера и обе ветки каждого изменённого условия. + Найди (а) один sibling-путь, представление или копию, которые всё ещё + доходят до sink, (б) один обычный/дефолтный вход, который патч начал + отвергать или переинтерпретировать. Если хоть одно есть — переделай. +5. Верифицируй по гейтам (порядок обязателен). + +## Гейты верификации + +1. **Синтаксис/сборка** — только изменённое: + - Python: `python -m py_compile crm\server.py crm\storage.py crm\storage_sqlite.py crm\notes_api.py` (в crm/); + - сайт (если тронут `site/`): из `site/` — `npx tsc --noEmit`, затем `npm run build`; + - движок миксов (если тронут `site/src` миксы/стоп-лист): `npm run test:engine`. +2. **Security-триггер** — repro находки на живом стенде больше не + воспроизводится + один альтернативный класс вредоносного входа. +3. **Легитимный контроль** — обычный сценарий по тому же пути работает; + соседние роли не сломаны (токены owner/admin/waiter — матрица в README). + +### Живой стенд + +- **Postgres-стек** (нужен для игр, досок, броней, миксов, сброса нумерации — + в sqlite это 501): `docker compose up -d --build`, CRM на + `127.0.0.1:47613`, сайт на `127.0.0.1:8080`. +- **SQLite-стенд** без Docker: `start-crm.bat` (`CRM_DB=sqlite`, + `CRM_ALLOW_DEFAULT_PASSWORDS=1`, порт 47613). +- Сайт-дев: в `site/` `npm run dev`; если docker уже держит 8080 — + `npm run dev -- --port 8090 --strictPort`. +- Перед стартом проверь, кто слушает порт (`netstat -ano | findstr :47613`): + 8080/47613 держит docker-стек, **8081 — чужой процесс, его не трогать**. +- Доступы стенда — из `.env` (SMOKE_* нет в .env — задавай при запуске скрипта). + +### Регрессионный артефакт + +Проект верифицирует фиксы smoke-скриптами по образцу `crm/_security_smoke.mjs`: +`fetch` против `127.0.0.1:47613`, PASS/FAIL, ненулевой exit при провале, +пароли через env (`SMOKE_OWNER_PW`, `SMOKE_WAITER_PW`). Добавь проверки +фикса в `_security_smoke.mjs` или создай сфокусированный `crm/_fix_.mjs` +по тому же образцу. Помни побочные эффекты: lockout-тест закрывает +`waiter.mark` на 15 минут (порядок проверок важен), тест смены пароля обязан +возвращать пароль обратно. + +## Ревью кандидата + +После реализации и фокусных проверок запусти одного свежего read-only +субагента (Agent tool; инструкции не редактировать и не делегировать). Дай +ему только находку, корень проекта, авторизованный объём, конвенции и текущий +diff кандидата — без обоснований, отчёта исследователя и заявлений, что +тесты прошли. Если делегирование недоступно — сделай этот проход сам, +отдельно, до финальной верификации: + +- **Ревьюер обходов и регрессий:** реконструирует инвариант и ищет конкретный + выживший маршрут через затронутые входы, эквивалентные представления, + границы парсеров, алиасы, оба бэкенда, слой nginx и щели + валидация → использование; трассирует изменённые условия и вызователей на + предмет конкретной поломки легитимных входов, контрактов, ошибок, + побочных эффектов и конвенций. + +Находки ревьюера — гипотезы: подтверждай их по коду или фокусным запуском, +правь только подтверждённые в рамках находки и совместимости. Затем +прогони затронутые гейты заново и убедись, что временных и посторонних +изменений не осталось. Только один цикл ревью. + +Верни `blocked`, если находка может быть реальной, но не хватает существенных +данных, инструментария, доступа или это продуктовое решение владельца +(пример: №38 «waiter вне смены видит телефоны» из предрелизного аудита) — +тогда безопасный фикс невозможно завершить или проверить. + +## Отчёт + +Напиши `AUDIT/FIXES/.md` и проставь в источнике находки (`AUDIT/FINDINGS.md` +или `АУДИТ-ПЕРЕД-РЕЛИЗОМ.md`) статус фикса со ссылкой на отчёт. В финальном +ответе в чате обязательно: + +- outcome: `fixed` / `no_change` / `blocked` +- конкретный уязвимый путь, security-инвариант и легитимное поведение, которое + обязано было сохраниться +- выбранная стратегия и почему это самый узкий полный вариант (либо — какое + решение владельца отсутствует при `blocked`) +- изменённые файлы и добавленные тесты/артефакты +- команды по гейтам с результатами pass/fail/unknown +- как показано, что исходная находка больше не воспроизводится +- как показано, что легитимное поведение сохранилось +- оставшиеся пробелы и пропущенные проверки, если есть + +## Жёсткие правила + +- Не докладывай `fixed`, пока не пройдены все гейты по порядку. Проверку + можно пропустить только с обоснованием по коду; недоступная существенная + проверка = `blocked` и явное указание, чего не хватило. +- Не полагайся только на чтение кода, когда возможен фокусный repro на стенде. +- Не расширяй патч на уборку, соседние находки и редизайн без доказательства, + что без этого граница не закрывается. +- Не удаляй и не затирай правки пользователя — git нет, снимки единственная + страховка; не трогай `crm/data/`, `crm/media/`, `.env` и `site/dist`. +- Не ослабляй авторизацию, ролевую матрицу, пять слоёв защиты заказов, + валидацию входа, fail-fast на дефолтных паролях и лимитеры ради зелёных + проверок. +- Не деплой на прод (`docker compose up -d --build site crm`) без явной + команды владельца — верификация идёт на локальном стенде. +- Не прячь пробелы доказательств: если среда не дала проверить — назови + команду и чего не хватает. diff --git a/.agents/skills/security-audit/SKILL.md b/.agents/skills/security-audit/SKILL.md new file mode 100644 index 0000000..0fcc474 --- /dev/null +++ b/.agents/skills/security-audit/SKILL.md @@ -0,0 +1,1537 @@ +--- +name: security-audit +description: Полномасштабный аудит безопасности проекта НЕОН (site/crm/db) — поиск, подтверждение и документирование уязвимостей без изменения кода. Использовать при любой просьбе проверить проект, сайт или CRM на уязвимости, провести security-аудит, red-team проход или предрелизную проверку защищённости, а также перепроверить конкретный участок кода на безопасность. НЕ использовать для исправления находок (это скилл fix-finding) и для обычного код-ревью без security-контекста. +--- + +# ROLE: PRINCIPAL APPLICATION SECURITY AUDITOR / RED TEAM CODE REVIEW ORCHESTRATOR + +Ты выполняешь **максимально глубокий, исчерпывающий аудит безопасности всего предоставленного проекта**. + +Это авторизованный security-аудит собственного веб-сайта и самописной CRM. + +Твоя задача — **НЕ исправлять код**, а обнаружить, подтвердить и документировать максимально возможное количество реальных уязвимостей. + +## КРИТИЧЕСКОЕ ПРАВИЛО + +Никогда не считай аудит завершённым только потому, что ты обнаружил несколько уязвимостей. + +Фраза: + +> «Я нашёл несколько проблем, вот всё, что удалось обнаружить» + +НЕ является приемлемым результатом. + +Ты обязан продолжать аудит до тех пор, пока не будет выполнено следующее: + +1. Проанализирован весь доступный исходный код. +2. Построена карта архитектуры приложения. +3. Проанализированы все точки входа и выхода данных. +4. Проанализированы все механизмы authentication/authorization. +5. Проанализированы все роли и уровни доступа. +6. Проанализированы все CRM/business-logic workflows. +7. Проанализированы все API/endpoints/actions/controllers. +8. Проанализированы database queries и ORM. +9. Проанализированы file upload/download/import/export-механизмы. +10. Проанализированы frontend и backend. +11. Проанализированы middleware, guards, filters, interceptors и аналогичные механизмы. +12. Проанализированы конфигурация, secrets и environment handling. +13. Проанализированы зависимости и их потенциально опасное использование. +14. Проанализированы background jobs, queues, cron jobs, webhooks и integrations. +15. Проанализированы cache/session/token механизмы. +16. Проанализированы multi-user и multi-tenant boundaries. +17. Проанализированы все места, где пользовательские данные пересекаются с SQL, HTML, JS, shell, filesystem, URLs, templates и другими interpreter/context boundaries. +18. Выполнены отдельные проверки бизнес-логики. +19. Каждый потенциальный finding независимо перепроверен отдельным security-субагентом. +20. Не осталось непроверенных участков кода и непроверенных гипотез. + +--- + +# 1. ГЛАВНАЯ ЦЕЛЬ + +Проведи аудит по принципу: + +> **Assume breach + assume malicious authenticated user + assume hostile input everywhere.** + +Рассматривай атакующего как пользователя, который: + +* может не иметь аккаунта; +* может иметь обычный аккаунт; +* может иметь аккаунт с минимальными правами; +* может иметь аккаунт другой роли; +* может обладать собственным tenant/account; +* может манипулировать HTTP requests; +* может менять ID, UUID, параметры, headers, cookies и tokens; +* может повторять запросы; +* может отправлять запросы напрямую, минуя frontend; +* может вызывать API напрямую; +* может изменять порядок операций; +* может создавать race conditions; +* может комбинировать несколько seemingly-safe функций; +* может анализировать ответы приложения; +* может использовать ошибки приложения для получения информации. + +Не доверяй frontend validation. + +Не доверяй скрытым кнопкам. + +Не доверяй disabled UI elements. + +Не доверяй значениям, пришедшим от клиента. + +Не считай endpoint безопасным только потому, что frontend его нормально использует. + +--- + +# 2. НЕ ИСПРАВЛЯЙ УЯЗВИМОСТИ + +Это принципиально важно. + +**НЕ вноси исправления в исходный код.** + +**НЕ переписывай файлы приложения.** + +**НЕ делай refactoring.** + +**НЕ устанавливай патчи автоматически.** + +**НЕ меняй security configuration проекта.** + +Твоя задача — только: + +> обнаружить → проверить → классифицировать → задокументировать. + +Все findings сохраняй в отдельный файл/каталог аудита. + +Исходный проект должен остаться неизменным. + +--- + +# 3. СНАЧАЛА СОЗДАЙ КАРТУ ПРОЕКТА + +До поиска уязвимостей сначала полностью разберись в приложении. + +Определи: + +* framework; +* язык; +* backend; +* frontend; +* ORM; +* database; +* authentication; +* authorization; +* session management; +* JWT/OAuth/etc.; +* storage; +* queues; +* cache; +* reverse proxy; +* web server; +* deployment; +* Docker/containerization; +* CI/CD; +* external services; +* third-party APIs; +* webhooks; +* email; +* SMS; +* payment integrations; +* file storage; +* search; +* logging; +* monitoring; +* background workers; +* cron; +* admin panel; +* CRM; +* roles; +* permissions; +* tenant model. + +Создай: + +`AUDIT/00_ARCHITECTURE.md` + +В нём должна быть максимально подробная карта приложения. + +--- + +# 4. СОЗДАЙ INVENTORY ВСЕГО ATTACK SURFACE + +Создай отдельный inventory: + +`AUDIT/01_ATTACK_SURFACE.md` + +Перечисли **каждую** найденную точку входа. + +Включая: + +* HTTP routes; +* REST API; +* GraphQL; +* RPC; +* WebSockets; +* forms; +* file uploads; +* imports; +* exports; +* webhooks; +* callbacks; +* OAuth callbacks; +* password reset; +* email verification; +* invitation flows; +* registration; +* login; +* logout; +* admin endpoints; +* internal endpoints; +* debug endpoints; +* health endpoints; +* metrics endpoints; +* cron endpoints; +* queue consumers; +* CLI commands; +* background jobs; +* server actions; +* server-side rendering; +* template rendering; +* dynamic redirects; +* external URL fetching; +* integrations. + +Для каждого endpoint/action зафиксируй: + +* HTTP method; +* URL/path; +* controller/handler; +* middleware; +* authentication requirement; +* authorization requirement; +* input parameters; +* body; +* headers; +* cookies; +* output; +* database interaction; +* filesystem interaction; +* external requests; +* relevant roles; +* relevant tenant boundary. + +--- + +# 5. ОБЯЗАТЕЛЬНО ПРОАНАЛИЗИРУЙ ВСЮ CRM BUSINESS LOGIC + +CRM является приоритетной зоной. + +Не ограничивайся классическими SQLi/XSS. + +Ищи логические уязвимости. + +Проверь: + +### Object-level authorization + +* IDOR; +* BOLA; +* доступ к чужим клиентам; +* доступ к чужим сделкам; +* доступ к чужим документам; +* доступ к чужим сообщениям; +* доступ к чужим задачам; +* доступ к чужим заметкам; +* доступ к чужим файлам; +* доступ к чужим экспортам. + +### Role escalation + +Проверь переходы: + +* guest → user; +* user → manager; +* manager → admin; +* employee → privileged employee; +* tenant A → tenant B. + +### Function-level authorization + +Проверь возможность напрямую вызвать функции, которые UI скрывает. + +Особенно: + +* delete; +* restore; +* export; +* import; +* impersonate; +* change role; +* invite; +* deactivate; +* approve; +* reject; +* refund; +* modify permissions; +* access audit logs; +* access sensitive CRM fields. + +### Business logic + +Ищи: + +* обход approval workflow; +* повторное выполнение операции; +* изменение состояния объекта в недопустимом порядке; +* использование устаревшего состояния; +* race conditions; +* TOCTOU; +* повторное использование токена; +* повторное использование invitation; +* повторное использование reset token; +* обход лимитов; +* negative values; +* integer overflow/underflow; +* quantity manipulation; +* price manipulation; +* скидки; +* начисления; +* quotas; +* rate limits; +* pagination bypass; +* bulk operation abuse; +* mass assignment; +* hidden fields; +* trust boundary violations. + +--- + +# 6. AUTHENTICATION AUDIT + +Полностью проверь: + +* registration; +* login; +* logout; +* password reset; +* email verification; +* MFA; +* session creation; +* session invalidation; +* remember-me; +* password change; +* account recovery; +* invitation; +* OAuth; +* SSO; +* API tokens; +* JWT; +* refresh tokens. + +Ищи: + +* authentication bypass; +* session fixation; +* session hijacking; +* token leakage; +* predictable tokens; +* weak token validation; +* algorithm confusion; +* missing signature verification; +* incorrect audience/issuer validation; +* refresh-token reuse; +* reset-token reuse; +* account enumeration; +* password reset poisoning; +* host-header issues; +* email verification bypass; +* MFA bypass; +* race conditions; +* login race conditions; +* inconsistent authentication between endpoints. + +--- + +# 7. AUTHORIZATION AUDIT + +Построй полноценную матрицу: + +`ROLE × RESOURCE × ACTION` + +Например: + +| Role | Resource | Read | Create | Update | Delete | Export | Admin | +| ------- | -------- | ---: | -----: | -----: | -----: | -----: | ----: | +| Guest | Customer | | | | | | | +| User | Customer | | | | | | | +| Manager | Customer | | | | | | | +| Admin | Customer | | | | | | | + +Не заполняй её предположениями. + +Определи фактическое поведение кода. + +Особое внимание: + +> authorization должен проверяться на сервере непосредственно перед выполнением защищённой операции. + +--- + +# 8. INPUT / INJECTION AUDIT + +Проверь все пользовательские данные во всех контекстах. + +### SQL + +Ищи: + +* SQL injection; +* unsafe raw queries; +* dynamic SQL; +* ORDER BY injection; +* LIMIT/OFFSET injection; +* filter injection; +* search injection; +* ORM escape bypass. + +### NoSQL + +* operator injection; +* query object injection; +* `$where`; +* unsafe dynamic filters. + +### Command injection + +Проверь: + +* shell commands; +* subprocess; +* exec; +* spawn; +* system; +* image processing; +* PDF generation; +* archive processing; +* conversion utilities. + +### Template injection + +Проверь: + +* SSTI; +* dynamic templates; +* user-controlled template names/content. + +### XSS + +Проверь: + +* reflected XSS; +* stored XSS; +* DOM XSS; +* HTML injection; +* attribute injection; +* JavaScript context; +* URL context; +* SVG; +* Markdown; +* rich text; +* CRM notes; +* imported data. + +### Other + +Проверь: + +* LDAP injection; +* XPath injection; +* XML injection; +* XXE; +* CSV injection; +* header injection; +* CRLF; +* email injection; +* log injection; +* path traversal. + +--- + +# 9. SSRF + +Найди **все места**, где сервер может обращаться к URL, который частично или полностью контролируется пользователем. + +Проверить: + +* HTTP clients; +* image fetchers; +* URL previews; +* webhooks; +* importers; +* integrations; +* PDF generators; +* screenshot services; +* metadata fetchers; +* avatar/image processing; +* redirect following. + +Проверяй не только очевидный: + +`http://...` + +но и обходы URL validation: + +* redirects; +* alternate IP notation; +* IPv6; +* DNS rebinding; +* localhost aliases; +* internal hostnames; +* encoded addresses; +* unusual schemes; +* parser discrepancies. + +--- + +# 10. FILE SECURITY + +Проверь: + +* upload; +* download; +* preview; +* extraction; +* import; +* export; +* conversion; +* image processing; +* PDF processing; +* archives. + +Ищи: + +* unrestricted upload; +* executable upload; +* MIME confusion; +* extension bypass; +* double extension; +* path traversal; +* Zip Slip; +* malicious archive; +* decompression bombs; +* symlink attacks; +* arbitrary file read; +* arbitrary file write; +* filename injection; +* stored XSS through uploaded files; +* access-control bypass; +* cross-tenant file access. + +--- + +# 11. API SECURITY + +Каждый endpoint проверяй независимо. + +Проверь: + +* authentication; +* authorization; +* input validation; +* output filtering; +* excessive data exposure; +* mass assignment; +* parameter pollution; +* pagination; +* rate limiting; +* bulk endpoints; +* error handling; +* HTTP method confusion; +* content-type confusion; +* CORS; +* CSRF; +* caching; +* idempotency. + +Особое внимание: + +> API должен быть безопасен даже если frontend полностью удалить. + +--- + +# 12. FRONTEND + +Не игнорируй frontend. + +Проверь: + +* DOM XSS; +* unsafe HTML; +* unsafe URL handling; +* postMessage; +* origin validation; +* token storage; +* sensitive data in localStorage/sessionStorage; +* source maps; +* exposed secrets; +* internal endpoints; +* hidden functionality; +* client-side authorization; +* frontend-only validation; +* prototype pollution; +* dangerous third-party libraries. + +Но помни: + +> frontend security controls никогда не считаются достаточной authorization boundary. + +--- + +# 13. CSRF / CORS / BROWSER SECURITY + +Проверь: + +* CSRF; +* SameSite; +* Secure; +* HttpOnly; +* CORS; +* origin validation; +* referer validation; +* clickjacking; +* CSP; +* security headers; +* MIME sniffing; +* open redirects; +* browser-side token leakage. + +--- + +# 14. SECRETS + +Ищи: + +* API keys; +* private keys; +* passwords; +* database credentials; +* JWT secrets; +* encryption keys; +* cloud credentials; +* service credentials; +* OAuth secrets; +* hardcoded tokens; +* secrets в frontend; +* secrets в source maps; +* secrets в config; +* secrets в comments; +* secrets в git history, если history доступна. + +Не публикуй реальные секреты в итоговом отчёте целиком. + +Маскируй их. + +--- + +# 15. CRYPTOGRAPHY + +Проверь: + +* password hashing; +* encryption; +* signing; +* random number generation; +* token generation; +* key storage; +* IV/nonce; +* algorithm selection; +* key reuse; +* weak randomness; +* plaintext sensitive data; +* custom cryptography. + +Особенно ищи случаи, когда разработчик самостоятельно реализовал криптографический механизм. + +--- + +# 16. DATABASE + +Проверь: + +* SQL injection; +* authorization at query level; +* tenant isolation; +* mass assignment; +* unsafe migrations; +* sensitive columns; +* soft-delete bypass; +* deleted-record recovery; +* raw queries; +* dynamic filters; +* dynamic sorting; +* joins exposing unauthorized records; +* backup/export exposure. + +--- + +# 17. MULTI-TENANCY + +Если система multi-tenant, проведи отдельный полноценный аудит. + +Попытайся концептуально доказать или опровергнуть: + +> Can tenant A access ANY data belonging to tenant B? + +Проверь это для: + +* users; +* customers; +* deals; +* files; +* messages; +* logs; +* reports; +* exports; +* search; +* analytics; +* notifications; +* background jobs; +* caches; +* object storage; +* database queries; +* IDs; +* UUIDs; +* webhooks. + +--- + +# 18. RACE CONDITIONS + +Отдельно ищи race conditions. + +Особенно в: + +* password reset; +* invitation; +* payment; +* permissions; +* role changes; +* quotas; +* counters; +* balance; +* coupon; +* resource creation; +* deletion; +* approval; +* state transitions; +* file processing; +* job processing. + +Не предполагай, что отсутствие очевидной race condition означает отсутствие проблемы. + +--- + +# 19. ERROR HANDLING / INFORMATION DISCLOSURE + +Проверь: + +* stack traces; +* SQL errors; +* filesystem paths; +* internal hostnames; +* framework versions; +* debug endpoints; +* source maps; +* exception messages; +* timing differences; +* user enumeration; +* object enumeration; +* internal IDs. + +--- + +# 20. DEPENDENCIES + +Проанализируй: + +* package manifests; +* lockfiles; +* dependency versions; +* transitive dependencies; +* deprecated libraries; +* dangerous packages; +* vulnerable packages; +* packages с dangerous APIs. + +Но не делай вывод: + +> «dependency имеет CVE = приложение уязвимо». + +Проверь, используется ли уязвимый функционал фактически. + +--- + +# 21. INFRASTRUCTURE / CONFIGURATION + +Если соответствующие файлы присутствуют, проверь: + +* Docker; +* Docker Compose; +* Kubernetes; +* nginx; +* Apache; +* reverse proxy; +* cloud configuration; +* CI/CD; +* environment configuration; +* CORS; +* TLS; +* headers; +* exposed ports; +* debug mode; +* production configuration; +* storage permissions; +* service accounts. + +--- + +# 22. АУДИТ GIT HISTORY + +Если git history доступна, отдельно проверь: + +* старые секреты; +* удалённые секреты; +* reverted vulnerabilities; +* TODO/FIXME security comments; +* временные bypasses; +* debug code; +* suspicious commits; +* commented-out authorization; +* disabled security checks. + +--- + +# 23. ПОИСК НЕ ТОЛЬКО ПО ПАТТЕРНАМ + +Не ограничивайся grep/SAST. + +Каждый потенциально опасный участок рассматривай в контексте всей data flow. + +Для каждого пользовательского input постарайся построить: + +`SOURCE → TRANSFORMATION → VALIDATION → STORAGE → SINK` + +И проверить каждый путь. + +--- + +# 24. DATA-FLOW AUDIT + +Для каждого sensitive input: + +1. Найди источник. +2. Проследи его через функции. +3. Проследи transformations. +4. Определи validation. +5. Определи authorization. +6. Определи storage. +7. Определи eventual sink. +8. Определи, можно ли изменить assumptions между этими этапами. + +Особое внимание: + +* validation before authorization; +* authorization before object lookup; +* canonicalization; +* encoding; +* parsing; +* type conversion; +* deserialization. + +--- + +# 25. DESERIALIZATION + +Проверь: + +* JSON; +* YAML; +* XML; +* serialized objects; +* cookies; +* session data; +* JWT; +* message queues; +* imported files. + +Ищи: + +* unsafe deserialization; +* prototype pollution; +* type confusion; +* object injection; +* arbitrary class/object instantiation. + +--- + +# 26. SECURITY TESTING PHILOSOPHY + +Каждую гипотезу классифицируй: + +### CONFIRMED + +Есть доказательство, что vulnerability реально существует. + +### PROBABLE + +Есть сильные технические основания, но полного подтверждения недостаточно. + +### POSSIBLE + +Подозрение требует дополнительной информации или runtime validation. + +Не выдавай `POSSIBLE` за confirmed. + +Не выдавай теоретическую проблему за exploitable vulnerability. + +--- + +# 27. ОБЯЗАТЕЛЬНАЯ НЕЗАВИСИМАЯ ВЕРИФИКАЦИЯ + +**КАЖДЫЙ finding должен быть проверен отдельным независимым security-субагентом.** + +Не один субагент на все findings. + +Для каждого finding создай отдельную задачу/делегацию. + +Субагент должен получить: + +* relevant code; +* context; +* найденную гипотезу; +* expected security property; +* необходимые зависимости; +* минимальный контекст, позволяющий провести независимую проверку. + +Он НЕ должен просто соглашаться с первоначальным аудитором. + +Его инструкция: + +> Assume the primary auditor may be wrong. Attempt to disprove the finding first. If you cannot disprove it, establish whether it is actually exploitable and determine the exact security impact. + +--- + +# 28. РОЛИ СУБАГЕНТОВ + +Если система позволяет использовать несколько субагентов, используй специализированные роли. + +Минимальный набор: + +### Agent A — Authentication Specialist + +Проверяет authentication/session/token/reset/MFA. + +### Agent B — Authorization Specialist + +Проверяет IDOR/BOLA/RBAC/ABAC/privilege escalation. + +### Agent C — Injection Specialist + +SQLi, NoSQLi, command injection, SSTI, XSS и т.д. + +### Agent D — Business Logic Specialist + +CRM workflows, state machines, race conditions, abuse cases. + +### Agent E — File/SSRF Specialist + +Uploads, downloads, parsing, SSRF, archives. + +### Agent F — API Specialist + +API-specific authorization, validation, mass assignment, excessive exposure. + +### Agent G — Infrastructure/Secrets Specialist + +Config, Docker, CI/CD, secrets, dependencies. + +### Agent H — Cryptography Specialist + +Crypto, password storage, tokens, randomness. + +### Agent I — Frontend Security Specialist + +DOM XSS, browser security, postMessage, token handling. + +### Agent J — Adversarial Reviewer + +Получает результаты всех остальных и пытается найти то, что они пропустили. + +--- + +# 29. ОСОБЕННО ВАЖНО: НЕ ДЕЛАЙ ВЕРИФИКАЦИЮ САМОПРОВЕРКОЙ + +Не делай: + +> Auditor finds vulnerability → same reasoning process confirms vulnerability. + +Это недостаточно. + +Верификация должна быть максимально независимой. + +Идеально использовать субагента более сильной reasoning-модели, например OPUS или выше, если такая модель доступна. + +Если указанная модель недоступна, используй наиболее сильную доступную модель и явно отметь это в audit metadata. + +--- + +# 30. RED TEAM PASS + +После завершения основного code audit проведи отдельный red-team проход. + +Представь злоумышленника: + +### Scenario 1 + +Anonymous attacker. + +### Scenario 2 + +Normal authenticated user. + +### Scenario 3 + +Low-privileged CRM employee. + +### Scenario 4 + +Manager. + +### Scenario 5 + +Compromised tenant. + +### Scenario 6 + +Malicious insider. + +Для каждого сценария ответь: + +* Что атакующий может получить? +* Какие security boundaries существуют? +* Где они enforced? +* Можно ли их обойти? +* Какие функции можно комбинировать для escalation? + +--- + +# 31. COMBINED EXPLOIT CHAINS + +Не рассматривай findings только изолированно. + +Ищи chains: + +`Low severity + Low severity = Critical impact` + +Например: + +* information disclosure → IDOR; +* SSRF → internal endpoint; +* XSS → privileged action; +* weak authorization → export → mass data leak; +* user enumeration → password reset weakness; +* file upload → parser vulnerability; +* low-privileged access → admin functionality; +* race condition → privilege escalation. + +Создай: + +`AUDIT/EXPLOIT_CHAINS.md` + +--- + +# 32. ПОИСК ПРОПУЩЕННЫХ УЯЗВИМОСТЕЙ + +После завершения всех специализированных проверок НЕ останавливайся. + +Проведи минимум 3 дополнительных независимых прохода. + +## PASS A — OWASP Coverage + +Сопоставь приложение с OWASP Top 10 и связанными категориями. + +## PASS B — CWE Coverage + +Проверь релевантные CWE. + +## PASS C — Attack Surface Re-review + +Начни с нуля от attack-surface inventory и проверь: + +> Для каждой точки входа существует ли хотя бы одна независимая проверка security properties? + +--- + +# 33. NEGATIVE TESTING + +Для каждого security control задай вопрос: + +> Что произойдёт, если этот control отсутствует? + +Проверь: + +* authentication removed; +* role changed; +* tenant ID changed; +* object ID changed; +* HTTP method changed; +* parameter omitted; +* parameter duplicated; +* parameter type changed; +* request reordered; +* request replayed; +* request sent directly; +* frontend bypassed. + +--- + +# 34. НИКАКИХ ПРЕЖДЕВРЕМЕННЫХ ВЫВОДОВ + +Нельзя писать: + +> «Других уязвимостей не найдено» + +если: + +* часть проекта не проанализирована; +* часть endpoint'ов неизвестна; +* часть dependencies не проверена; +* часть business logic не понята; +* runtime behavior неизвестно; +* субагенты не завершили verification; +* coverage matrix не заполнена. + +В таком случае явно напиши: + +> AUDIT INCOMPLETE + +и укажи, что осталось непроверенным. + +--- + +# 35. ДОКУМЕНТИРОВАНИЕ FINDINGS + +Создай: + +`AUDIT/FINDINGS.md` + +Для КАЖДОЙ уязвимости используй формат: + +```text +ID: +Название: + +Severity: +CVSS (если возможно): + +Confidence: +CONFIRMED / PROBABLE / POSSIBLE + +Affected component: + +Affected files: + +Affected functions: + +Affected endpoints: + +Required privileges: + +Attack preconditions: + +Security boundary violated: + +Root cause: + +Technical explanation: + +Attack path: + +Proof / evidence: + +Minimal reproduction: + +Expected secure behavior: + +Actual behavior: + +Impact: + +Affected users/data: + +Exploitability: + +Independent verification: + +Verifier verdict: + +Verifier reasoning: + +False-positive analysis: + +Related findings: + +Recommended remediation: +``` + +--- + +# 36. ДОКАЗАТЕЛЬСТВА + +Каждый finding должен содержать конкретное доказательство. + +Не пиши: + +> «Здесь, вероятно, есть IDOR». + +Пиши: + +> Endpoint X принимает object_id из user-controlled request, затем выполняет lookup Y без проверки ownership/tenant/permission. Следующий код выполняет операцию Z. + +Указывай: + +* файл; +* строку/диапазон строк, если доступен; +* функцию; +* flow; +* relevant condition. + +--- + +# 37. ПРОВЕРКА НА FALSE POSITIVE + +Каждая потенциальная уязвимость должна пройти вопрос: + +> Может ли другой security control сделать эту атаку невозможной? + +Проверь: + +* middleware; +* global guards; +* database policies; +* framework defaults; +* reverse proxy; +* validation layers; +* permission services; +* tenant middleware; +* CSRF middleware; +* CSP; +* deployment configuration. + +Нельзя объявлять vulnerability, не проверив эти compensating controls. + +--- + +# 38. SEVERITY + +Используй: + +* Critical +* High +* Medium +* Low +* Informational + +Severity должна основываться на реальном impact и exploitability. + +Особенно учитывай: + +* remote exploitation; +* authentication requirement; +* privileges; +* confidentiality; +* integrity; +* availability; +* cross-tenant impact; +* privilege escalation; +* mass exploitation; +* business impact. + +--- + +# 39. DUPLICATE FINDINGS + +Если одна root cause создаёт 50 похожих endpoint vulnerabilities: + +* укажи root cause; +* перечисли affected locations; +* не создавай бессмысленные дубликаты. + +Но если разные места имеют разные security implications — документируй их отдельно. + +--- + +# 40. ФИНАЛЬНЫЙ COVERAGE MATRIX + +Создай: + +`AUDIT/COVERAGE.md` + +Минимум: + +| Area | Reviewed | Files | Endpoints | Findings | Independent Verification | Status | +| --------------- | -------: | ----: | --------: | -------: | -----------------------: | ------ | +| Authentication | | | | | | | +| Authorization | | | | | | | +| CRM Logic | | | | | | | +| API | | | | | | | +| SQL | | | | | | | +| XSS | | | | | | | +| SSRF | | | | | | | +| Files | | | | | | | +| CSRF | | | | | | | +| Secrets | | | | | | | +| Crypto | | | | | | | +| Dependencies | | | | | | | +| Infrastructure | | | | | | | +| Multi-tenancy | | | | | | | +| Race Conditions | | | | | | | + +Не ставь `Reviewed = Yes`, пока соответствующая область действительно не проверена. + +--- + +# 41. FINAL QUALITY GATE + +Перед завершением аудита выполни следующие проверки: + +### Q1 + +Проанализирован ли каждый source file? + +### Q2 + +Проанализирован ли каждый route/controller/handler? + +### Q3 + +Проанализирован ли каждый privileged operation? + +### Q4 + +Проанализированы ли все роли? + +### Q5 + +Проанализированы ли все tenant boundaries? + +### Q6 + +Проанализированы ли все user-controlled inputs? + +### Q7 + +Проанализированы ли все external network requests? + +### Q8 + +Проанализированы ли все file operations? + +### Q9 + +Проанализированы ли все database operations? + +### Q10 + +Проверены ли все findings независимыми субагентами? + +### Q11 + +Был ли проведён adversarial second pass? + +### Q12 + +Были ли проверены exploit chains? + +### Q13 + +Есть ли участки проекта, которые остались непонятными? + +### Q14 + +Есть ли findings без достаточных доказательств? + +### Q15 + +Есть ли findings, которые могли быть false positives? + +Если хотя бы один важный вопрос имеет отрицательный ответ — аудит НЕ считать завершённым. + +--- + +# 42. ФИНАЛЬНЫЙ ОТЧЁТ + +Создай: + +`AUDIT/FINAL_REPORT.md` + +Структура: + +1. Executive Summary +2. Scope +3. Architecture +4. Attack Surface +5. Threat Model +6. Authentication Assessment +7. Authorization Assessment +8. CRM Business Logic Assessment +9. API Assessment +10. Injection Assessment +11. File/SSRF Assessment +12. Frontend Assessment +13. Infrastructure Assessment +14. Secrets Assessment +15. Dependency Assessment +16. Multi-tenancy Assessment +17. Race Conditions +18. Exploit Chains +19. Confirmed Vulnerabilities +20. Probable Vulnerabilities +21. Possible Vulnerabilities +22. False Positives Rejected +23. Coverage Matrix +24. Unreviewed Areas +25. Final Assessment + +--- + +# 43. ОСНОВНОЙ ПРИНЦИП + +Твоя цель не: + +> найти как можно больше текстовых совпадений с CWE. + +Твоя цель: + +> **найти реальные нарушения security invariants приложения.** + +Особенно ищи нарушения вида: + +```text +Attacker controls X + ↓ +Application trusts X + ↓ +Security boundary crossed + ↓ +Unauthorized action/data access +``` + +--- + +# 44. ПРИОРИТЕТ + +Приоритет поиска: + +1. Authentication bypass +2. Authorization bypass +3. Privilege escalation +4. Cross-tenant access +5. Remote code execution +6. SQL/NoSQL/command injection +7. SSRF +8. Arbitrary file read/write +9. Account takeover +10. Sensitive data disclosure +11. Business logic abuse +12. Stored XSS with meaningful impact +13. CSRF with meaningful impact +14. Race conditions +15. Cryptographic weaknesses +16. Secrets +17. Dependency vulnerabilities +18. Configuration issues +19. Information disclosure +20. Low-impact hardening issues + +Но НЕ прекращай аудит после нахождения Critical/High findings. + +--- + +# 45. ВАЖНОЕ ТРЕБОВАНИЕ К РАБОТЕ АГЕНТА + +Работай как настоящий Principal Application Security Engineer, а не как code-review assistant. + +Не спеши. + +Не оптимизируй количество найденных проблем. + +Оптимизируй: + +> **полноту покрытия + достоверность + доказуемость.** + +Если один участок кода выглядит безопасным — попытайся доказать, почему он безопасен. + +Если один участок выглядит уязвимым — попытайся доказать, почему это действительно exploitable. + +Если два разных механизма взаимодействуют — анализируй их вместе. + +Если бизнес-логика неочевидна — реконструируй её из кода, моделей, схем БД, frontend flows, tests и вызовов. + +--- + +# 46. КРИТЕРИЙ ЗАВЕРШЕНИЯ + +Аудит считается завершённым ТОЛЬКО если: + +```text +FULL CODE INVENTORY = COMPLETE +ATTACK SURFACE = MAPPED +AUTHENTICATION = REVIEWED +AUTHORIZATION = REVIEWED +BUSINESS LOGIC = REVIEWED +CRM = REVIEWED +API = REVIEWED +INPUT FLOWS = REVIEWED +DATABASE = REVIEWED +FILES = REVIEWED +SSRF = REVIEWED +FRONTEND = REVIEWED +INFRASTRUCTURE = REVIEWED +SECRETS = REVIEWED +DEPENDENCIES = REVIEWED +MULTI-TENANCY = REVIEWED +RACE CONDITIONS = REVIEWED +EXPLOIT CHAINS = REVIEWED +RED TEAM PASS = COMPLETE +ADVERSARIAL PASS = COMPLETE +ALL FINDINGS = INDEPENDENTLY VERIFIED +FALSE POSITIVES = REVIEWED +COVERAGE MATRIX = COMPLETE +UNREVIEWED AREAS = EXPLICITLY DOCUMENTED +``` + +Если хотя бы один пункт невозможно подтвердить — **не утверждай, что аудит полностью завершён**. + +--- + +# 47. ПОСЛЕДНЕЕ И САМОЕ ВАЖНОЕ ПРАВИЛО + +После формирования первоначального списка findings скажи себе: + +> **"What did we miss?"** + +Затем проведи новый независимый поиск с предположением, что предыдущие аудиторы что-то пропустили. + +После этого повтори: + +> **"What would a highly skilled attacker notice that our static analysis did not?"** + +И проведи ещё один проход. + +Только после этого формируй FINAL_REPORT.md. + +**Не исправляй найденные проблемы. Только документируй их.** +**Не изменяй production/application source code.** +**Все изменения должны находиться исключительно в AUDIT/.** +**Каждый finding обязан иметь независимую верификацию.** +**Нельзя объявлять аудит полным без evidence-based coverage.** diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1f177b2 --- /dev/null +++ b/.env.example @@ -0,0 +1,41 @@ +# ===== PostgreSQL ===== +# СЕКРЕТЫ: заполняйте своими значениями. Файл .env (с реальными паролями) +# не должен попадать в поставку/архив — только этот .env.example. +# ВНИМАНИЕ: если база уже создана, смена PG_PASSWORD в .env не действует — +# сначала ALTER USER в postgres, затем синхронизируйте .env. +PG_DB=neon_crm +PG_USER=neon +PG_PASSWORD=придумайте-длинный-случайный-пароль + +# ===== Учётки CRM (вход в панель) ===== +# Пароли применяются при ПЕРВОМ создании учётки (минимум 8 символов). +# На живой базе пароль каждому сотруднику меняется в панели: кнопка 🔑 +# рядом с именем. Старт с дефолтными паролями запрещён — сервер не поднимется, +# пока все учётки не получат собственные пароли (см. CRM_ALLOW_DEFAULT_PASSWORDS). +CRM_OWNER_LOGIN=owner +CRM_OWNER_PASSWORD=придумайте-случайный-пароль-1 +CRM_OWNER_NAME=Нурлан +CRM_ADMIN_LOGIN=admin +CRM_ADMIN_PASSWORD=придумайте-случайный-пароль-2 +CRM_ADMIN_NAME=Ирина +CRM_WAITER_LOGIN=waiter.mark +CRM_WAITER_PASSWORD=придумайте-случайный-пароль-3 +CRM_WAITER_NAME=Марк + +# ===== CRM ===== +# 1 — наполнить базу демо-заказами при первом старте (для показа заказчику). +# В бою держи 0. +CRM_SEED_DEMO_ORDERS=0 +# Часовой пояс бара: по нему считаются расписание смен, сутки в отчётах +# и «новинки». По умолчанию Asia/Yekaterinburg (Нягань, UTC+5). +#CRM_TZ=Asia/Yekaterinburg +# 1 — разрешить старт с дефолтными паролями. ТОЛЬКО для локального dev-стенда, +# который не смотрит в интернет; боевой сервер так запускать нельзя. +#CRM_ALLOW_DEFAULT_PASSWORDS=0 + +# ===== Сайт ===== +# На каком адресе и порте сайт смотрит наружу. +# За Caddy/nginx на хосте: 127.0.0.1:8080 (как есть). +# Голый VPS без реверс-прокси: SITE_BIND=0.0.0.0 и SITE_PORT=80. +SITE_BIND=127.0.0.1 +SITE_PORT=8080 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0ba429c --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Секреты и живые данные не должны попадать в поставку (аудит F-1/F-4) +.env +*.env.local + +# Локальные базы CRM: хэши паролей и сессионные токены (F-4) +crm/data/ +*.db +*.sqlite + +# Медиа владельца (фото позиций, обложки игр) +crm/media/ + +# Зависимости и сборки +node_modules/ +site/dist/ +__pycache__/ +*.pyc + +# Временные файлы +*.tmp +*.log +.DS_Store + +# Dev-мусор и бэкапы исходников (аудит NF-09/NF-19) +*.bak* +crm/*.mjs +crm/_*.py +crm/i + +# Результат make-distrib.ps1 +distrib/ diff --git a/crm/.dockerignore b/crm/.dockerignore new file mode 100644 index 0000000..eaef2ad --- /dev/null +++ b/crm/.dockerignore @@ -0,0 +1,13 @@ +__pycache__ +*.pyc +data/ +migrate/ +.dockerignore +Dockerfile +*.log + +# NF-09/NF-19: dev-бэкапы, dev-скрипты и секреты не должны попадать в образ, +# даже если Dockerfile когда-нибудь начнёт копировать каталог целиком +*.bak* +*.mjs +.env diff --git a/crm/Dockerfile b/crm/Dockerfile new file mode 100644 index 0000000..1c67a3a --- /dev/null +++ b/crm/Dockerfile @@ -0,0 +1,28 @@ +# NEON CRM — Python + PostgreSQL, без веб-фреймворков. +# NF-20: дайджест-пин базового образа (воспроизводимая сборка). +FROM python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1 + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY server.py storage.py storage_sqlite.py notes_api.py ./ +COPY seed/ ./seed/ +COPY index.html ./ +COPY css/ ./css/ +COPY js/ ./js/ + +RUN groupadd --system neon && useradd --system --gid neon --create-home neon \ + && chown -R neon:neon /app +USER neon + +EXPOSE 47613 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:47613/api/healthz', timeout=3).status == 200 else 1)" + +CMD ["python", "server.py"] diff --git a/crm/css/styles.css b/crm/css/styles.css new file mode 100644 index 0000000..505f3e6 --- /dev/null +++ b/crm/css/styles.css @@ -0,0 +1,1645 @@ +/* ============================================================ + НЕОН CRM — дизайн-система макета + Тема: тёмная, неон циан + фиолет (по мотивам neon-bar86.ru) + ============================================================ */ + +:root { + --bg: #0a0a0f; + --bg-soft: #0e0e16; + --panel: #12121c; + --panel-2: #171724; + --panel-3: #1c1c2c; + --line: rgba(255, 255, 255, 0.08); + --line-strong: rgba(255, 255, 255, 0.14); + + --text: #f2f3f8; + --muted: #9aa0b4; + --muted-2: #6b7186; + + --cyan: #00f0ff; + --cyan-dim: rgba(0, 240, 255, 0.14); + --purple: #b44dff; + --purple-dim: rgba(180, 77, 255, 0.16); + --green: #2ee6a8; + --green-dim: rgba(46, 230, 168, 0.14); + --red: #ff4d6d; + --red-dim: rgba(255, 77, 109, 0.14); + --amber: #ffc857; + + --radius: 16px; + --radius-sm: 10px; + + --font-head: 'Unbounded', system-ui, sans-serif; + --font-body: 'Manrope', system-ui, sans-serif; + + --shadow-neon-cyan: 0 0 24px rgba(0, 240, 255, 0.35); + --shadow-neon-purple: 0 0 24px rgba(180, 77, 255, 0.35); + + /* тёмная тема для нативных виджетов: выпадающий календарь у input[type=date], + списки select, полосы прокрутки */ + color-scheme: dark; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +html, body { height: 100%; } + +body { + font-family: var(--font-body); + background: var(--bg); + color: var(--text); + -webkit-font-smoothing: antialiased; + line-height: 1.5; + min-height: 100vh; +} + +body::before { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + background: + radial-gradient(900px 500px at 85% -10%, rgba(180, 77, 255, 0.10), transparent 60%), + radial-gradient(800px 500px at -10% 110%, rgba(0, 240, 255, 0.08), transparent 60%); + z-index: 0; +} + +.hidden { display: none !important; } + +h1, h2, h3, h4 { font-family: var(--font-head); font-weight: 600; letter-spacing: 0.01em; } +em { font-style: normal; } + +button { font-family: inherit; cursor: pointer; } +input, select { font-family: inherit; } + +/* ---------------------------------------------------------- + Логотип +---------------------------------------------------------- */ +.logo-neon { + flex: none; + display: grid; + place-items: center; + width: 44px; + height: 44px; + border-radius: 12px; + font-family: var(--font-head); + font-weight: 800; + font-size: 20px; + color: var(--cyan); + background: rgba(0, 240, 255, 0.06); + border: 1px solid rgba(0, 240, 255, 0.45); + box-shadow: var(--shadow-neon-cyan), inset 0 0 14px rgba(0, 240, 255, 0.18); + text-shadow: 0 0 10px rgba(0, 240, 255, 0.8); +} + +/* ---------------------------------------------------------- + Экран входа +---------------------------------------------------------- */ +.login { + position: relative; + z-index: 1; + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 22px; + padding: 24px; + overflow: hidden; +} + +.glow { + position: absolute; + border-radius: 50%; + filter: blur(120px); + opacity: 0.35; + pointer-events: none; +} +.glow-cyan { + width: 420px; + height: 420px; + background: rgba(0, 240, 255, 0.22); + top: -120px; + left: -100px; + animation: drift 9s ease-in-out infinite alternate; +} +.glow-purple { + width: 480px; + height: 480px; + background: rgba(180, 77, 255, 0.22); + bottom: -160px; + right: -120px; + animation: drift 11s ease-in-out infinite alternate-reverse; +} +@keyframes drift { + from { transform: translate(0, 0) scale(1); } + to { transform: translate(50px, 35px) scale(1.12); } +} + +.login-card { + position: relative; + width: min(440px, 100%); + background: linear-gradient(160deg, rgba(23, 23, 36, 0.92), rgba(14, 14, 22, 0.94)); + border: 1px solid var(--line-strong); + border-radius: 22px; + padding: 34px 32px 26px; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5); + backdrop-filter: blur(10px); +} + +.login-logo { + display: flex; + gap: 16px; + align-items: center; + margin-bottom: 26px; +} +.login-logo .logo-neon { width: 54px; height: 54px; font-size: 26px; border-radius: 14px; } +.login-logo h1 { font-size: 24px; line-height: 1.2; } +.login-logo h1 em { color: var(--cyan); text-shadow: 0 0 14px rgba(0, 240, 255, 0.55); } +.login-logo p { color: var(--muted); font-size: 13px; margin-top: 4px; } + +.role-picker { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; + background: var(--bg-soft); + border: 1px solid var(--line); + border-radius: 12px; + padding: 5px; + margin-bottom: 20px; +} +.role-pill { + border: none; + background: transparent; + color: var(--muted); + font-weight: 700; + font-size: 13.5px; + padding: 10px; + border-radius: 9px; + transition: all 0.2s; +} +.role-pill.active { + background: linear-gradient(135deg, rgba(0, 240, 255, 0.16), rgba(180, 77, 255, 0.16)); + color: var(--text); + box-shadow: inset 0 0 0 1px rgba(0, 240, 255, 0.35); +} + +.field { display: block; margin-bottom: 14px; } +.field span { + display: block; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); + margin-bottom: 7px; +} +.field input { + width: 100%; + background: var(--bg-soft); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + color: var(--text); + font-size: 15px; + padding: 12px 14px; + outline: none; + transition: border-color 0.2s, box-shadow 0.2s; +} +.field input:focus { + border-color: rgba(0, 240, 255, 0.6); + box-shadow: 0 0 0 3px rgba(0, 240, 255, 0.12); +} + +.login-foot { color: var(--muted-2); font-size: 12.5px; position: relative; } + +.login-demo { + margin-top: 22px; + padding-top: 18px; + border-top: 1px dashed var(--line-strong); + text-align: center; +} +.login-demo > span { display: block; color: var(--muted-2); font-size: 12.5px; margin-bottom: 10px; } +.login-demo-btns { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; } + +/* ---------------------------------------------------------- + Каркас приложения +---------------------------------------------------------- */ +.app { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: 264px 1fr; + min-height: 100vh; +} + +.sidebar { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 20px; + padding: 22px 18px; + background: rgba(14, 14, 22, 0.7); + border-right: 1px solid var(--line); + backdrop-filter: blur(10px); +} + +.brand { display: flex; align-items: center; gap: 12px; padding: 0 6px 20px; } +.brand-txt strong { font-family: var(--font-head); font-size: 16px; display: block; } +.brand-txt strong em { color: var(--cyan); } +.brand-txt small { color: var(--muted); font-size: 12px; } + +.nav { display: flex; flex-direction: column; gap: 4px; } +.nav-item { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + border: 1px solid transparent; + background: transparent; + color: var(--muted); + font-size: 14.5px; + font-weight: 600; + padding: 11px 12px; + border-radius: 12px; + transition: all 0.18s; + text-align: left; +} +.nav-item .ico { font-size: 17px; width: 22px; text-align: center; } +.nav-item:hover { color: var(--text); background: rgba(255, 255, 255, 0.04); } +.nav-item.active { + color: var(--text); + background: linear-gradient(90deg, rgba(0, 240, 255, 0.12), rgba(180, 77, 255, 0.10)); + border-color: rgba(0, 240, 255, 0.3); +} +.nav-item .nav-badge { + margin-left: auto; + min-width: 22px; + height: 22px; + padding: 0 6px; + display: grid; + place-items: center; + border-radius: 999px; + background: var(--red-dim); + color: var(--red); + font-size: 11.5px; + font-weight: 800; + border: 1px solid rgba(255, 77, 109, 0.35); +} + +.side-bottom { display: flex; flex-direction: column; gap: 10px; } +.user-card { + display: flex; + align-items: center; + gap: 12px; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 14px; + padding: 10px 12px; +} +.user-card strong { display: block; font-size: 14px; } +.user-card small { color: var(--muted); font-size: 12px; } +.user-card .icon-btn { margin-left: auto; } +.avatar { + flex: none; + width: 38px; + height: 38px; + border-radius: 10px; + display: grid; + place-items: center; + font-weight: 800; + font-size: 15px; + background: var(--purple-dim); + color: var(--purple); + border: 1px solid rgba(180, 77, 255, 0.4); +} + +.main { + display: flex; + flex-direction: column; + min-width: 0; + padding: 24px 28px 40px; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 24px; + flex-wrap: wrap; +} +.topbar h2 { font-size: 21px; } +.topbar h2::after { + content: ''; + display: block; + width: 46px; + height: 3px; + border-radius: 3px; + margin-top: 8px; + background: linear-gradient(90deg, var(--cyan), var(--purple)); + box-shadow: 0 0 12px rgba(0, 240, 255, 0.6); +} +.topbar p { color: var(--muted); font-size: 13.5px; margin-top: 2px; } + +.topbar-right { display: flex; align-items: center; gap: 12px; } +.shift { + font-size: 12.5px; + font-weight: 600; + color: var(--muted); + border: 1px solid var(--line); + border-radius: 999px; + padding: 7px 13px; + background: var(--panel); +} +.clock { + font-family: var(--font-head); + font-size: 17px; + color: var(--cyan); + text-shadow: 0 0 12px rgba(0, 240, 255, 0.55); + min-width: 64px; + text-align: right; +} +.icon-btn { + position: relative; + border: 1px solid var(--line); + background: var(--panel); + color: var(--text); + border-radius: 10px; + width: 38px; + height: 38px; + font-size: 15px; + transition: all 0.2s; +} +.icon-btn:hover { border-color: var(--line-strong); background: var(--panel-2); } +.bell { font-size: 16px; } +.bell-count { + position: absolute; + top: -7px; + right: -7px; + min-width: 19px; + height: 19px; + padding: 0 5px; + display: grid; + place-items: center; + border-radius: 999px; + background: var(--red); + color: #fff; + font-size: 11px; + font-weight: 800; + box-shadow: 0 0 10px rgba(255, 77, 109, 0.7); +} +.bell.has-new { border-color: rgba(255, 77, 109, 0.5); box-shadow: 0 0 14px rgba(255, 77, 109, 0.25); } + +/* ---------------------------------------------------------- + Кнопки +---------------------------------------------------------- */ +.btn { + border: 1px solid transparent; + border-radius: var(--radius-sm); + font-weight: 700; + font-size: 14px; + padding: 12px 18px; + transition: all 0.2s; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; +} +.btn:active { transform: scale(0.98); } + +.btn-neon { + background: linear-gradient(135deg, var(--cyan), #6a7bff); + color: #051018; + box-shadow: 0 6px 20px rgba(0, 240, 255, 0.28); +} +.btn-neon:hover { box-shadow: 0 6px 26px rgba(0, 240, 255, 0.45); } + +.btn-ghost { + background: transparent; + border-color: var(--line-strong); + color: var(--muted); +} +.btn-ghost:hover { color: var(--text); border-color: rgba(0, 240, 255, 0.45); } + +.btn-danger { + background: var(--red-dim); + color: var(--red); + border-color: rgba(255, 77, 109, 0.35); +} +.btn-danger:hover { background: rgba(255, 77, 109, 0.22); } + +.btn-green { + background: var(--green-dim); + color: var(--green); + border-color: rgba(46, 230, 168, 0.35); +} +.btn-green:hover { background: rgba(46, 230, 168, 0.22); } + +.btn-sm { padding: 8px 14px; font-size: 13px; } +.btn-block { width: 100%; } + +/* ---------------------------------------------------------- + Формы владельца (сотрудники, новые позиции) и отчёты +---------------------------------------------------------- */ +.user-form { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + gap: 10px; + align-items: center; +} +.user-form input, +.user-form select, +.report-controls input { + background-color: var(--panel-2); + border: 1px solid var(--line); + border-radius: 10px; + color: var(--text); + padding: 10px 12px; + font: inherit; + font-size: 14px; + min-width: 0; +} +/* под кастомную стрелку селекта (см. глобальное правило select) */ +.user-form select { padding-right: 32px; } + +/* выпадающие списки: у всех селектов кастомная неоновая стрелка вместо + нативной (та рисуется браузером и выбивается из тёмной темы). Сам + раскрытый список темнит color-scheme: dark на :root */ +select { + -webkit-appearance: none; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%239aa0b4' stroke-width='1.6' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + padding-right: 30px; + cursor: pointer; +} +select:hover { border-color: rgba(0, 240, 255, 0.35); } +select option { background: var(--panel-2); color: var(--text); } + +/* календарные поля (отчёты): тот же стиль коробки, курсор-рука и неоновый + hover; значок-календарик стилизован ниже (::calendar-picker-indicator) */ +input[type="date"] { + cursor: pointer; +} +input[type="date"]:hover { border-color: rgba(0, 240, 255, 0.35); } +.user-form input:focus, +.user-form select:focus, +.report-controls input:focus { + outline: none; + border-color: rgba(0, 240, 255, 0.5); + box-shadow: 0 0 0 3px rgba(0, 240, 255, 0.1); +} +/* ошибки валидации формы микса: подсветка поля и сообщение под ним */ +.user-form .field-error { + border-color: rgba(255, 77, 109, 0.65) !important; + box-shadow: 0 0 0 3px rgba(255, 77, 109, 0.12); +} +.form-error { + grid-column: 1 / -1; + color: var(--red); + font-size: 12px; + font-weight: 700; +} +.form-error:first-letter { text-transform: uppercase; } +.chips.field-error-block { + border: 1px solid rgba(255, 77, 109, 0.55); + border-radius: 10px; + padding: 8px 10px; + box-shadow: 0 0 0 3px rgba(255, 77, 109, 0.08); +} +/* ошибка на целой панели (ручной заказ без стола/позиций) */ +.panel.panel-error { + border-color: rgba(255, 77, 109, 0.6); + box-shadow: 0 0 0 3px rgba(255, 77, 109, 0.09); +} +/* сообщение об ошибке вне grid-формы (под панелью) */ +.dash-grid .form-error { grid-column: auto; } +.check-inline { + display: flex; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 13px; + white-space: nowrap; +} +.report-controls { + display: flex; + gap: 14px; + flex-wrap: wrap; + align-items: center; + color: var(--muted); + font-size: 13px; +} +.report-controls label { + display: flex; + align-items: center; + gap: 8px; +} +/* поля с календарём (input[type=date]): видимый значок-календарик на тёмном + фоне и неоновая подсветка при наведении; сам выпадающий календарь + затемняется через color-scheme: dark на :root */ +input[type="date"]::-webkit-calendar-picker-indicator { + filter: invert(0.75); + opacity: 0.55; + cursor: pointer; + transition: opacity 0.15s, filter 0.15s; +} +input[type="date"]::-webkit-calendar-picker-indicator:hover { + opacity: 1; + filter: invert(0.75) drop-shadow(0 0 5px rgba(0, 240, 255, 0.65)); +} +input[type="date"]::-webkit-datetime-edit { + color: var(--text); +} +input[type="date"]::-webkit-datetime-edit-fields-wrapper:hover, +input[type="date"]::-webkit-datetime-edit-year-field:hover, +input[type="date"]::-webkit-datetime-edit-month-field:hover, +input[type="date"]::-webkit-datetime-edit-day-field:hover { + background: rgba(0, 240, 255, 0.08); + border-radius: 4px; +} +.report-table { + display: flex; + flex-direction: column; + gap: 6px; +} +.report-row { + display: grid; + grid-template-columns: 1fr auto auto auto; + gap: 14px; + padding: 9px 12px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); + font-size: 13px; +} +.report-row.head { + background: transparent; + color: var(--muted-2); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; +} +.report-row.three { grid-template-columns: 1fr auto auto; } +.report-row.staff { grid-template-columns: 1.4fr auto auto auto auto auto; gap: 12px; } +.report-row.staff .ri-name { white-space: normal; } +.report-row.staff .ri-name small { color: var(--muted-2); font-weight: 400; } +.report-row .ri-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* селект сортировки списков (меню/табаки/миксы) — как поля отчёта */ +.sort-select { + background-color: var(--panel); + border: 1px solid var(--line); + border-radius: 10px; + color: var(--text); + padding: 9px 30px 9px 12px; + font: inherit; + font-size: 13px; + cursor: pointer; +} +.sort-select:focus { + outline: none; + border-color: rgba(0, 240, 255, 0.5); + box-shadow: 0 0 0 3px rgba(0, 240, 255, 0.1); +} + +.topbar-actions { + display: flex; + align-items: center; + gap: 8px; +} +.shift-countdown { + font-weight: 600; + white-space: nowrap; +} + +/* ---------------------------------------------------------- + Панели, сетки, статистика +---------------------------------------------------------- */ +.panel { + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 20px; +} + +.panel-title { + font-family: var(--font-head); + font-size: 14px; + font-weight: 600; + margin-bottom: 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.panel-title small { font-family: var(--font-body); color: var(--muted-2); font-weight: 500; } + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 14px; + margin-bottom: 22px; +} +.stat { + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 18px; + position: relative; + overflow: hidden; +} +.stat::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; +} +.stat.c-cyan::after { background: linear-gradient(90deg, var(--cyan), transparent); } +.stat.c-purple::after { background: linear-gradient(90deg, var(--purple), transparent); } +.stat.c-green::after { background: linear-gradient(90deg, var(--green), transparent); } +.stat.c-red::after { background: linear-gradient(90deg, var(--red), transparent); } + +.stat .num { + font-family: var(--font-head); + font-size: 30px; + font-weight: 700; + line-height: 1.15; +} +.stat.c-cyan .num { color: var(--cyan); text-shadow: 0 0 16px rgba(0, 240, 255, 0.45); } +.stat.c-purple .num { color: var(--purple); text-shadow: 0 0 16px rgba(180, 77, 255, 0.45); } +.stat.c-green .num { color: var(--green); text-shadow: 0 0 16px rgba(46, 230, 168, 0.45); } +.stat.c-red .num { color: var(--red); text-shadow: 0 0 16px rgba(255, 77, 109, 0.45); } +.stat .num.txt { + font-size: 19px; + line-height: 1.3; + padding-top: 5px; +} +.stat .lbl { color: var(--muted); font-size: 13px; font-weight: 600; margin-top: 4px; } +.stat .sub { color: var(--muted-2); font-size: 12px; margin-top: 8px; } + +.dash-grid { + display: grid; + grid-template-columns: 1.6fr 1fr; + gap: 16px; + align-items: start; +} + +/* ---------------------------------------------------------- + Чипсы-фильтры, поиск +---------------------------------------------------------- */ +.toolbar { + display: flex; + gap: 10px; + flex-wrap: wrap; + align-items: center; + margin-bottom: 18px; +} +.chips { display: flex; gap: 8px; flex-wrap: wrap; } +.chip { + border: 1px solid var(--line); + background: var(--panel); + color: var(--muted); + font-size: 13px; + font-weight: 700; + padding: 8px 14px; + border-radius: 999px; + transition: all 0.18s; +} +.chip:hover { color: var(--text); } +.chip.active { + color: var(--cyan); + border-color: rgba(0, 240, 255, 0.5); + background: var(--cyan-dim); + box-shadow: 0 0 12px rgba(0, 240, 255, 0.15); +} +.chip .cnt { opacity: 0.65; font-weight: 600; margin-left: 4px; } + +.search { + flex: 1; + min-width: 200px; + max-width: 340px; + position: relative; +} +.search::before { + content: '⌕'; + position: absolute; + left: 13px; + top: 50%; + transform: translateY(-56%); + font-size: 18px; + color: var(--muted-2); +} +.search input { + width: 100%; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 999px; + color: var(--text); + font-size: 14px; + padding: 9px 16px 9px 36px; + outline: none; + transition: border-color 0.2s; +} +.search input:focus { border-color: rgba(0, 240, 255, 0.5); } +.search input::placeholder { color: var(--muted-2); } + +/* ---------------------------------------------------------- + Бейджи +---------------------------------------------------------- */ +.badge { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11.5px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; + border-radius: 999px; + padding: 4px 11px; + border: 1px solid; + white-space: nowrap; +} +.b-new { background: var(--cyan-dim); color: var(--cyan); border-color: rgba(0, 240, 255, 0.4); } +.b-active { background: var(--purple-dim); color: var(--purple); border-color: rgba(180, 77, 255, 0.4); } +.b-done { background: var(--green-dim); color: var(--green); border-color: rgba(46, 230, 168, 0.4); } +.b-stop { background: var(--red-dim); color: var(--red); border-color: rgba(255, 77, 109, 0.4); } +.b-cancel { background: rgba(255, 255, 255, 0.06); color: var(--muted); border-color: rgba(255, 255, 255, 0.18); } +.menu-row.row-deleted { opacity: 0.55; } +.menu-row.row-deleted .m-name { text-decoration: line-through; } +.b-mut { background: rgba(255,255,255,0.05); color: var(--muted); border-color: var(--line-strong); } +.b-dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; box-shadow: 0 0 8px currentColor; } + +/* ---------------------------------------------------------- + Список меню (админ) +---------------------------------------------------------- */ +.menu-list { display: flex; flex-direction: column; gap: 8px; } + +.menu-row { + display: grid; + grid-template-columns: 1fr auto auto auto; + align-items: center; + gap: 14px; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 14px; + padding: 13px 16px; + transition: all 0.2s; +} +.menu-row:hover { border-color: var(--line-strong); } +.menu-row .m-name { font-weight: 700; font-size: 14.5px; } +.menu-row .m-cat { color: var(--muted-2); font-size: 12.5px; margin-top: 2px; } +.menu-row .m-price { font-weight: 800; font-size: 14.5px; color: var(--text); white-space: nowrap; } +.menu-row.stopped { + background: linear-gradient(90deg, rgba(255, 77, 109, 0.09), var(--panel) 55%); + border-color: rgba(255, 77, 109, 0.35); +} +.menu-row.stopped .m-name { color: var(--muted); text-decoration: line-through; text-decoration-color: rgba(255, 77, 109, 0.6); } + +/* ---------------------------------------------------------- + Тумблер стоп-листа +---------------------------------------------------------- */ +.switch { position: relative; display: inline-block; width: 48px; height: 27px; flex: none; } +.switch input { opacity: 0; width: 0; height: 0; } +.switch .track { + position: absolute; + inset: 0; + border-radius: 999px; + background: var(--green-dim); + border: 1px solid rgba(46, 230, 168, 0.45); + transition: all 0.22s; +} +.switch .track::before { + content: ''; + position: absolute; + width: 19px; + height: 19px; + border-radius: 50%; + top: 3px; + left: 4px; + background: var(--green); + box-shadow: 0 0 10px rgba(46, 230, 168, 0.7); + transition: all 0.22s; +} +.switch input:checked + .track { + background: var(--red-dim); + border-color: rgba(255, 77, 109, 0.55); +} +.switch input:checked + .track::before { + left: 24px; + background: var(--red); + box-shadow: 0 0 10px rgba(255, 77, 109, 0.7); +} +.switch-label { font-size: 12px; font-weight: 700; color: var(--muted); white-space: nowrap; } + +/* ---------------------------------------------------------- + Сетка табаков +---------------------------------------------------------- */ +.tobacco-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + gap: 14px; +} +.tob-card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 18px; + display: flex; + flex-direction: column; + gap: 12px; + transition: all 0.2s; +} +.tob-card:hover { border-color: var(--line-strong); transform: translateY(-2px); } +.tob-card .t-brand { font-family: var(--font-head); font-size: 15px; font-weight: 600; } +.tob-card .t-line { color: var(--muted); font-size: 13px; margin-top: 4px; } +.tob-card .t-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-top: auto; + padding-top: 12px; + border-top: 1px dashed var(--line); +} +.tob-card.stopped { + background: linear-gradient(160deg, rgba(255, 77, 109, 0.10), var(--panel) 70%); + border-color: rgba(255, 77, 109, 0.4); +} +.tob-card.stopped .t-brand { color: var(--muted); text-decoration: line-through; text-decoration-color: rgba(255, 77, 109, 0.6); } + +/* ---------------------------------------------------------- + Заказы — канбан (официант) и таблица (админ) +---------------------------------------------------------- */ +.kanban { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + align-items: start; +} +.kan-col { + background: rgba(255, 255, 255, 0.02); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 14px; + min-height: 220px; +} +.kan-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 2px 6px 12px; + font-weight: 800; + font-size: 13.5px; + text-transform: uppercase; + letter-spacing: 0.06em; +} +.kan-head .cnt { + font-size: 12px; + padding: 2px 10px; + border-radius: 999px; + border: 1px solid; +} +.kan-new .kan-head { color: var(--cyan); } +.kan-new .cnt { background: var(--cyan-dim); border-color: rgba(0, 240, 255, 0.4); } +.kan-active .kan-head { color: var(--purple); } +.kan-active .cnt { background: var(--purple-dim); border-color: rgba(180, 77, 255, 0.4); } +.kan-done .kan-head { color: var(--green); } +.kan-done .cnt { background: var(--green-dim); border-color: rgba(46, 230, 168, 0.4); } + +.kan-col .stack { display: flex; flex-direction: column; gap: 12px; } +.kan-empty { + border: 1px dashed var(--line); + border-radius: 12px; + color: var(--muted-2); + font-size: 13px; + text-align: center; + padding: 26px 10px; +} + +.order-card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 14px; + padding: 14px 16px; + transition: all 0.2s; +} +.order-card:hover { border-color: var(--line-strong); } +.order-card.is-new { border-color: rgba(0, 240, 255, 0.35); box-shadow: 0 0 18px rgba(0, 240, 255, 0.07); } +.order-card.is-cancelled { opacity: 0.55; border-color: rgba(255, 255, 255, 0.12); } +.order-card.is-cancelled:hover { border-color: rgba(255, 255, 255, 0.2); } +.order-card.is-new .o-num::before { + content: ''; + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--cyan); + box-shadow: 0 0 10px var(--cyan); + margin-right: 8px; + animation: pulse 1.6s ease-in-out infinite; + vertical-align: 1px; +} +@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } } + +.o-head { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; + flex-wrap: wrap; +} +.o-num { font-family: var(--font-head); font-weight: 700; font-size: 14px; } +.o-table { + font-size: 12px; + font-weight: 700; + color: var(--muted); + border: 1px solid var(--line-strong); + padding: 3px 9px; + border-radius: 999px; +} +.o-time { margin-left: auto; color: var(--muted-2); font-size: 12.5px; font-weight: 600; white-space: nowrap; } + +.o-items { list-style: none; margin: 0 0 12px; display: flex; flex-direction: column; gap: 5px; } +.o-items li { + display: flex; + justify-content: space-between; + gap: 10px; + font-size: 13.5px; + color: var(--muted); +} +.o-items .q { + color: var(--text); + font-weight: 700; + border: 1px solid var(--line-strong); + border-radius: 6px; + padding: 0 6px; + font-size: 12px; + margin-right: 8px; + display: inline-block; +} +.o-items .p { white-space: nowrap; color: var(--muted-2); } +/* длинное название переносится, а не распирает строку состава */ +.o-items li > span:first-child { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; } + +.o-foot { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; + border-top: 1px dashed var(--line); + padding-top: 11px; +} +/* левый блок (сумма, источник, гость) сжимаемым не даём: без этого + на узких экранах текст «заказ с сайта» ломается в вертикальный столбик */ +.o-foot > div:first-child { flex: 1 1 auto; min-width: 0; } +.o-total { font-weight: 800; font-size: 15px; white-space: nowrap; } +.o-waiter { color: var(--muted-2); font-size: 12.5px; overflow-wrap: anywhere; } +.o-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-left: auto; } +.o-actions .btn-sm { padding: 10px 14px; } + +/* Таблица заказов (админ) */ +.order-table { display: flex; flex-direction: column; gap: 8px; } +.ot-row { + display: grid; + grid-template-columns: 70px 80px 1fr 100px 90px 130px 130px; + gap: 12px; + align-items: center; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 12px; + padding: 12px 16px; + font-size: 14px; +} +.ot-row.head { + background: transparent; + border: none; + color: var(--muted-2); + font-size: 11.5px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.07em; + padding: 0 16px; +} +.ot-row:not(.head):hover { border-color: var(--line-strong); } +.ot-num { font-family: var(--font-head); font-weight: 700; } +.ot-time-cell { color: var(--muted); font-weight: 600; } +.ot-items-cell { color: var(--muted); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ot-total-cell { font-weight: 800; } +.ot-waiter-cell { color: var(--muted); } +.ot-dur-cell { color: var(--muted); font-weight: 600; font-size: 13px; white-space: nowrap; } + +/* Выпадающий статус (админ) */ +.status-select { + appearance: none; + -webkit-appearance: none; + border: 1px solid var(--line-strong); + background-color: var(--panel-2); + color: var(--text); + font-family: var(--font-body); + font-weight: 800; + font-size: 11.5px; + text-transform: uppercase; + letter-spacing: 0.04em; + border-radius: 999px; + padding: 8px 30px 8px 14px; + cursor: pointer; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%239aa0b4' stroke-width='1.6' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + transition: all 0.2s; + white-space: nowrap; +} +.status-select:hover { border-color: rgba(0, 240, 255, 0.5); } +.status-select:focus { outline: none; box-shadow: 0 0 0 3px rgba(0, 240, 255, 0.12); } +.status-select option { background: var(--panel-2); color: var(--text); } +.status-select.s-new { color: var(--cyan); border-color: rgba(0, 240, 255, 0.4); background-color: var(--cyan-dim); } +.status-select.s-active { color: var(--purple); border-color: rgba(180, 77, 255, 0.4); background-color: var(--purple-dim); } +.status-select.s-done { color: var(--green); border-color: rgba(46, 230, 168, 0.4); background-color: var(--green-dim); } +.status-select.s-cancelled { color: var(--muted); border-color: rgba(255, 255, 255, 0.2); background-color: rgba(255, 255, 255, 0.06); } + +/* Длительность выполнения в карточке заказа */ +.o-dur { color: var(--green); font-size: 12px; font-weight: 700; margin-top: 3px; } + +/* ---------------------------------------------------------- + Виджеты дашборда +---------------------------------------------------------- */ +.hall-list { display: flex; flex-direction: column; gap: 8px; } +.hall { + display: flex; + align-items: center; + gap: 12px; + background: var(--bg-soft); + border: 1px solid var(--line); + border-radius: 12px; + padding: 11px 14px; +} +.hall .h-name { font-weight: 700; font-size: 14px; } +.hall .h-sub { color: var(--muted-2); font-size: 12px; margin-top: 1px; } +.hall .badge { margin-left: auto; } + +.mini-orders { display: flex; flex-direction: column; gap: 8px; } +.mini-order { + display: flex; + align-items: center; + gap: 12px; + background: var(--bg-soft); + border: 1px solid var(--line); + border-radius: 12px; + padding: 11px 14px; + font-size: 13.5px; +} +.mini-order .mo-num { font-weight: 800; white-space: nowrap; } +.mini-order .mo-info { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.mini-order .badge { margin-left: auto; } + +.stop-now { display: flex; flex-direction: column; gap: 8px; } +.stop-item { + display: flex; + align-items: center; + gap: 10px; + background: rgba(255, 77, 109, 0.06); + border: 1px solid rgba(255, 77, 109, 0.25); + border-radius: 12px; + padding: 10px 13px; + font-size: 13.5px; + font-weight: 600; +} +.stop-item .si-ico { font-size: 15px; } +.stop-item .si-type { color: var(--muted-2); font-size: 12px; font-weight: 600; margin-left: auto; padding-right: 8px; } +.stop-item .btn { padding: 6px 11px; font-size: 12px; } +.stop-empty { + border: 1px dashed var(--line); + border-radius: 12px; + color: var(--muted-2); + font-size: 13px; + text-align: center; + padding: 20px 10px; +} + +.note { + display: flex; + gap: 10px; + align-items: flex-start; + background: var(--cyan-dim); + border: 1px solid rgba(0, 240, 255, 0.25); + border-radius: 12px; + padding: 12px 15px; + font-size: 13px; + color: var(--muted); + margin-bottom: 18px; +} +.note b { color: var(--text); } +.note .n-ico { font-size: 15px; } + +/* ---------------------------------------------------------- + Тосты +---------------------------------------------------------- */ +.toast-wrap { + position: fixed; + right: 20px; + bottom: 20px; + display: flex; + flex-direction: column; + gap: 10px; + z-index: 50; +} +.toast { + display: flex; + align-items: center; + gap: 10px; + background: var(--panel-2); + border: 1px solid var(--line-strong); + border-left: 3px solid var(--cyan); + border-radius: 12px; + padding: 13px 18px; + font-size: 14px; + font-weight: 600; + box-shadow: 0 12px 34px rgba(0, 0, 0, 0.45); + animation: toast-in 0.25s ease; + max-width: 360px; +} +.toast.t-red { border-left-color: var(--red); } +.toast.t-green { border-left-color: var(--green); } +.toast.out { opacity: 0; transform: translateX(20px); transition: all 0.3s; } +@keyframes toast-in { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ---------------------------------------------------------- + Адаптив +---------------------------------------------------------- */ +@media (max-width: 1100px) { + .kanban { grid-template-columns: 1fr; } + .dash-grid { grid-template-columns: 1fr; } + .ot-row { grid-template-columns: 60px 90px 1fr 90px 120px; } + .ot-row .ot-waiter-cell, .ot-row .ot-dur-cell, + .ot-row.head .h-waiter, .ot-row.head .h-dur { display: none; } +} + +@media (max-width: 860px) { + .app { grid-template-columns: 1fr; } + .sidebar { + flex-direction: row; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px 14px; + padding: 12px 16px; + border-right: none; + border-bottom: 1px solid var(--line); + } + .brand { padding: 0; } + .brand-txt small, .side-bottom .btn { display: none; } + .nav { flex-direction: row; flex-wrap: wrap; gap: 6px; } + .nav-item { padding: 8px 12px; font-size: 13px; width: auto; } + .nav-item .ico { width: auto; } + .side-top { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; flex: 1 1 100%; } + .side-bottom { flex: 1 1 auto; justify-content: flex-end; } + .user-card { padding: 6px 10px; gap: 8px; } + .user-card > div { display: none; } + .user-card .icon-btn { margin-left: 0; } + .main { padding: 18px 16px 32px; } + .menu-row { grid-template-columns: 1fr auto; row-gap: 10px; } + .menu-row .m-price { order: 3; } + .menu-row .m-stopcell { grid-column: 1 / -1; display: flex; align-items: center; gap: 12px; justify-content: flex-end; } + .menu-row .badge { grid-column: 1 / -1; justify-self: start; } + .clock { display: none; } + .shift { display: none; } + #simOrderBtn { display: none; } + .ot-row { grid-template-columns: 56px 1fr 90px 118px; } + .ot-row .ot-time-cell, .ot-row .ot-waiter-cell, .ot-row .ot-dur-cell, + .ot-row.head .h-waiter, .ot-row.head .h-dur, .ot-row.head span:nth-child(2) { display: none; } +} + +/* ---- продакшн-дополнения: ошибка входа и кнопка переподключения ---- */ +.login-error { + margin: 14px 0 0; + padding: 10px 14px; + border-radius: 10px; + border: 1px solid rgba(255, 82, 82, 0.4); + background: rgba(255, 82, 82, 0.08); + color: #ff8a8a; + font-size: 13px; + line-height: 1.4; +} +.login-error.hidden { display: none; } +.offline-retry { color: #ffb14d; border-color: rgba(255, 177, 77, 0.4); } + +/* ---- залы: занятость столов и зон ---- */ +.hall-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); + gap: 14px; +} +.hall-card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 16px; + display: flex; + flex-direction: column; + gap: 10px; +} +.hall-card.is-busy { border-color: rgba(255, 77, 109, 0.35); } +.hall-card .h-top { display: flex; align-items: flex-start; gap: 10px; } +.hall-card .h-top .badge { margin-left: auto; white-space: nowrap; } +.hall-card .h-name { font-weight: 800; font-size: 15px; } +.hall-card .h-sub { color: var(--muted-2); font-size: 12.5px; margin-top: 1px; } +.hall-card .h-busyby { color: var(--muted); font-size: 12.5px; } +.hall-card .h-timer { + display: flex; + align-items: center; + gap: 8px; + padding: 9px 12px; + border-radius: var(--radius-sm); + background: var(--red-dim); + border: 1px solid rgba(255, 77, 109, 0.3); +} +.hall-card .h-timer .busy-timer { + font-family: var(--font-head); + font-size: 17px; + font-weight: 700; + color: var(--red); + letter-spacing: 0.04em; +} +.hall-card .h-timer .busy-timer.expired { + color: var(--amber); + font-size: 13px; + letter-spacing: 0; +} +.hall-card .h-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: auto; } +.hall-busy { border-color: rgba(255, 77, 109, 0.35); } + +/* брони столов и зон: окно «со скольки до скольки» + отсчёт до прихода гостя */ +.h-reserve { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px 10px; + padding: 9px 12px; + border-radius: var(--radius-sm); + background: var(--purple-dim); + border: 1px solid rgba(180, 77, 255, 0.3); + font-size: 12.5px; + color: var(--muted); +} +.h-reserve.is-ongoing { border-color: rgba(180, 77, 255, 0.6); color: var(--text); } +.h-reserve-what { font-weight: 700; color: var(--text); } +.h-reserve-count { display: inline-flex; align-items: baseline; gap: 4px; } +.h-reserve .busy-timer { + font-family: var(--font-head); + font-weight: 700; + color: var(--purple); + font-variant-numeric: tabular-nums; + letter-spacing: 0.04em; +} +.h-reserve .icon-btn { margin-left: auto; flex: none; } +.h-reserve-more { color: var(--muted-2); font-size: 12px; margin-top: 5px; } + +/* ---- игры: библиотека PS5 и настолок ---- */ +.games-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 14px; +} +.game-card { + display: flex; + gap: 12px; + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 12px; +} +.game-card.is-deleted { opacity: 0.5; } +.game-card.is-deleted .g-title { text-decoration: line-through; } +.g-cover { flex: none; width: 86px; } +.g-cover-img { + width: 86px; + height: 118px; + object-fit: cover; + border-radius: var(--radius-sm); + border: 1px solid var(--line); + display: block; + background: var(--panel-2); +} +.g-cover-empty { + display: flex; + align-items: center; + justify-content: center; + font-size: 34px; + color: var(--muted-2); +} +.g-body { display: flex; flex-direction: column; gap: 6px; min-width: 0; flex: 1; } +.g-body .badge { align-self: flex-start; } +.g-title { font-weight: 800; font-size: 14.5px; line-height: 1.3; overflow-wrap: anywhere; } +.g-note { color: var(--muted); font-size: 12.5px; overflow-wrap: anywhere; } +.g-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: auto; padding-top: 4px; } +@media (max-width: 620px) { + .games-grid { grid-template-columns: 1fr; } + .game-card { padding: 10px; } + + /* --- телефон официанта: плотные отступы, канбан одной колонкой --- */ + .main { padding: 14px 12px 64px; } + .topbar { margin-bottom: 14px; gap: 10px; } + .topbar h2 { font-size: 18px; } + .kanban { gap: 10px; } + .kan-col { min-height: 0; padding: 12px 10px; } + .order-card { padding: 12px; } + + /* карточка заказа: сумма и источник — своей строкой, кнопки — ниже + равными рядами, в пределах карточки и под палец */ + .o-actions { flex: 1 1 100%; margin-left: 0; } + .o-actions .btn { flex: 1 1 auto; white-space: nowrap; } + .o-actions .btn-sm { padding: 12px 10px; font-size: 13.5px; } + .o-total { font-size: 16px; } + .n-move { padding: 10px 16px; } + + /* таблица заказов, если админ заглянул с телефона */ + .ot-row { grid-template-columns: 40px 1fr 78px 104px; gap: 8px; padding: 10px 12px; font-size: 13px; } + .ot-row.head { padding: 0 12px; } + .status-select { padding: 8px 24px 8px 10px; font-size: 10.5px; background-position: right 8px center; } + + /* тосты во всю ширину экрана, а не за правым краем */ + .toast-wrap { left: 16px; right: 16px; } + .toast { max-width: none; } +} +.accent-dot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + margin-right: 5px; + vertical-align: -1px; + box-shadow: 0 0 8px currentColor; +} +.menu-row .accent-palette { + grid-column: 1 / -1; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + padding: 8px 10px; + border-radius: var(--radius-sm); + background: var(--panel-2); + border: 1px solid var(--line); +} + +/* таймер в бейджах («Занят · 12:34») */ +.badge .busy-timer { + font-variant-numeric: tabular-nums; + font-weight: 800; +} + +/* стол занят — чип в выборе стола ручного заказа */ +.chip-busy { + border-color: rgba(255, 77, 109, 0.45); + color: #ff8fa3; +} + +/* комментарий к заказу: карточка и ячейка таблицы */ +/* строка кальяна в составе заказа — мгновенно цепляет глаз на планшете */ +.o-items .is-hookah { + color: var(--cyan); +} + +.o-comment { color: var(--amber); font-size: 12.5px; margin-top: 3px; overflow-wrap: anywhere; } +.order-comment { + width: 100%; + margin-top: 12px; + padding: 10px 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--line-strong); + background: var(--panel-2); + color: var(--text); + font-family: var(--font-body); + font-size: 13.5px; + resize: vertical; + min-height: 52px; +} +.order-comment:focus { outline: none; border-color: rgba(0, 240, 255, 0.5); box-shadow: 0 0 0 3px rgba(0, 240, 255, 0.12); } + +/* ---- заметки и задачи: канбан досок ---- */ +.note-card { cursor: pointer; } +.note-card.dragging { opacity: 0.45; transform: rotate(1.5deg); } +.note-card.is-done { opacity: 0.55; } +.note-card.is-done .o-num { text-decoration: line-through; text-decoration-color: rgba(46, 230, 168, 0.6); } +.note-card.is-important { border-color: rgba(255, 200, 87, 0.45); box-shadow: 0 0 16px rgba(255, 200, 87, 0.08); } +.note-card.is-overdue { border-color: rgba(255, 77, 109, 0.4); } +.n-body { + color: var(--muted); + font-size: 13px; + margin-top: 6px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.n-flag-imp, +.n-flag-over { + flex: none; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.03em; + padding: 3px 8px; + border-radius: 999px; + white-space: nowrap; +} +.n-flag-imp { background: rgba(255, 200, 87, 0.14); color: var(--amber); } +.n-flag-over { background: var(--red-dim); color: var(--red); } +.n-moves { display: flex; gap: 6px; flex: none; margin-left: auto; } +/* кнопки перемещения — только там, где drag&drop недоступен (телефон) */ +.n-move { display: none; } +@media (hover: none), (max-width: 860px) { + .n-move { display: inline-flex; } +} +.kan-col.drag-over { border-color: var(--cyan); box-shadow: var(--shadow-neon-cyan); } +.nt-imp { + display: flex; + align-items: center; + gap: 8px; + color: var(--amber); + font-size: 13.5px; + font-weight: 700; + white-space: nowrap; + user-select: none; +} +.nt-imp input { width: 17px; height: 17px; accent-color: var(--amber); } +.act-feed { display: flex; flex-direction: column; gap: 8px; } +.act-row { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 12px; + align-items: baseline; + font-size: 13px; + padding: 7px 0; + border-bottom: 1px solid var(--line); +} +.act-row:last-child { border-bottom: none; } +.act-who { font-weight: 700; white-space: nowrap; } +.act-what { color: var(--muted); overflow-wrap: anywhere; } +.act-when { color: var(--muted-2); font-variant-numeric: tabular-nums; } +@media (max-width: 860px) { + .act-row { grid-template-columns: 1fr auto; } + .act-what { grid-column: 1 / -1; order: 3; } +} + +/* Полная остановка анимаций для пользователей с вестибулярной + чувствительностью: бесконечные неон-анимации (kenburns, marquee, + floaty, pulse…) не должны крутиться, если система просит их убрать. */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +/* ---------------------------------------------------------- + Карточка аккаунта (имя, роль, выход) стоит в сайдбаре перед + списком вкладок — см. index.html; special-cases ниже. +---------------------------------------------------------- */ +.side-top { display: flex; flex-direction: column; gap: 14px; } +.side-top .brand { padding: 0 6px 0; } +.side-top .nav { margin-top: 4px; } + +/* ---------------------------------------------------------- + Кнопки автопрокрутки страницы (правый нижний угол) +---------------------------------------------------------- */ +.scroll-nav { + position: fixed; + right: 18px; + bottom: 18px; + z-index: 60; + display: flex; + flex-direction: column; + gap: 8px; +} +.scroll-btn { + width: 44px; + height: 44px; + border-radius: 12px; + border: 1px solid var(--line-strong); + background: rgba(14, 14, 22, 0.88); + color: var(--cyan); + font-size: 15px; + font-weight: 800; + box-shadow: 0 10px 26px rgba(0, 0, 0, 0.45), 0 0 14px rgba(0, 240, 255, 0.12); + transition: all 0.18s; +} +.scroll-btn:hover { + border-color: rgba(0, 240, 255, 0.55); + box-shadow: 0 10px 26px rgba(0, 0, 0, 0.45), 0 0 22px rgba(0, 240, 255, 0.35); + transform: translateY(0); +} +.scroll-btn.is-edge { opacity: 0.35; } +.scroll-btn.is-edge:hover { opacity: 1; } + +/* ---------------------------------------------------------- + Поштучный учёт: бейдж остатка в строке меню +---------------------------------------------------------- */ +.stock-badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 10px; + border-radius: 999px; + border: 1px solid rgba(0, 240, 255, 0.35); + background: var(--cyan-dim); + color: var(--cyan); + font-size: 12px; + font-weight: 800; + white-space: nowrap; +} +.stock-badge.is-zero { + border-color: rgba(255, 77, 109, 0.45); + background: var(--red-dim); + color: var(--red); +} +/* в ячейке управления бейдж соседствует с кнопками/тумблером */ +.menu-row .m-stopcell { display: inline-flex; align-items: center; gap: 10px; flex-wrap: wrap; justify-content: flex-end; } + +/* ---------------------------------------------------------- + Модальные окна подтверждения и ввода (uiConfirm / uiPrompt) +---------------------------------------------------------- */ +.modal-overlay { + position: fixed; + inset: 0; + z-index: 70; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: rgba(4, 5, 9, 0.7); + backdrop-filter: blur(6px); + animation: modal-fade 0.16s ease; +} +.modal { + width: 100%; + max-width: 440px; + background: var(--panel); + border: 1px solid var(--line-strong); + border-radius: var(--radius); + padding: 22px 22px 18px; + box-shadow: + 0 24px 70px rgba(0, 0, 0, 0.55), + 0 0 0 1px rgba(0, 240, 255, 0.05), + 0 0 36px rgba(0, 240, 255, 0.08); + animation: modal-pop 0.16s ease; +} +/* «опасные» подтверждения (удаление, отмена) — красная рамка и подсветка */ +.modal.danger { + border-color: rgba(255, 77, 109, 0.4); + box-shadow: + 0 24px 70px rgba(0, 0, 0, 0.55), + 0 0 36px rgba(255, 77, 109, 0.1); +} +.modal-title { + font-family: var(--font-head); + font-size: 14px; + font-weight: 700; + margin-bottom: 10px; +} +.modal.danger .modal-title { color: var(--red); } +.modal-text { + color: var(--muted); + font-size: 14px; + line-height: 1.55; + white-space: pre-line; + overflow-wrap: anywhere; +} +.modal-input { + width: 100%; + margin-top: 14px; + background: var(--panel-2); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + color: var(--text); + padding: 11px 13px; + font: inherit; + font-size: 14px; +} +.modal-input:focus { + outline: none; + border-color: rgba(0, 240, 255, 0.5); + box-shadow: 0 0 0 3px rgba(0, 240, 255, 0.1); +} +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 20px; +} +@keyframes modal-fade { from { opacity: 0; } } +@keyframes modal-pop { from { opacity: 0; transform: translateY(10px) scale(0.97); } } +@media (max-width: 620px) { + .modal-overlay { padding: 12px; } + .modal { padding: 18px 16px 14px; } + .modal-actions { flex-direction: column-reverse; } + .modal-actions .btn { width: 100%; } +} diff --git a/crm/index.html b/crm/index.html new file mode 100644 index 0000000..87c3b61 --- /dev/null +++ b/crm/index.html @@ -0,0 +1,118 @@ + + + + + + НЕОН CRM — панель управления + + + + + + + + + + + + + + + +
+ + + + + + + + + + diff --git a/crm/js/app.js b/crm/js/app.js new file mode 100644 index 0000000..92215de --- /dev/null +++ b/crm/js/app.js @@ -0,0 +1,3870 @@ +/* ============================================================ + НЕОН 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)); +})(); diff --git a/crm/migrate/notes_boards.sql b/crm/migrate/notes_boards.sql new file mode 100644 index 0000000..45778f9 --- /dev/null +++ b/crm/migrate/notes_boards.sql @@ -0,0 +1,84 @@ +-- -*- coding: utf-8 -*- +-- ============================================================================ +-- НЕОН CRM — доски заметок/задач (общая + приватные «владелец ↔ админ»). +-- +-- Идемпотентна: повторный запуск не падает и не меняет данные. +-- CRM применяет этот же DDL автоматически при старте (SCHEMA в storage.py, +-- сид общей доски — boards_seed_shared()); файл нужен для ручного применения +-- и как канонический список объектов. +-- +-- Применить вручную: +-- docker compose exec -T db psql -U neon -d neon_crm \ +-- -v ON_ERROR_STOP=1 < crm/migrate/notes_boards.sql +-- +-- Откат (теряются все заметки и история!): +-- docker compose exec -T db psql -U neon -d neon_crm \ +-- -c "DROP TABLE IF EXISTS note_activity, notes, board_members, boards CASCADE" +-- +-- Отступление от исходного ТЗ: пользователи в этой базе — crm_users с текстовым +-- PK login (целочисленного users.id не существует), поэтому все ссылки идут +-- на crm_users(login). +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS boards ( + id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('shared', 'private')), + title TEXT NOT NULL, + created_by_login TEXT NOT NULL REFERENCES crm_users(login), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- участники приватных досок; для kind='shared' не используется — +-- общая доска доступна всем ролям без проверки +CREATE TABLE IF NOT EXISTS board_members ( + board_id INTEGER NOT NULL REFERENCES boards(id) ON DELETE CASCADE, + user_login TEXT NOT NULL REFERENCES crm_users(login) ON DELETE CASCADE, + PRIMARY KEY (board_id, user_login) +); + +-- заметка = задача: заголовок, текст, исполнитель, срок, «важно», статус. +-- К сменам не привязана — доступна в любое время по факту логина. +-- Логины автора/исполнителя без FK (как orders.waiter в storage.py): +-- удаление сотрудника не должно ломать его задачи. +CREATE TABLE IF NOT EXISTS notes ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + board_id INTEGER NOT NULL REFERENCES boards(id) ON DELETE CASCADE, + author_login TEXT NOT NULL, + assignee_login TEXT, + title TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'in_progress', 'done')), + important BOOLEAN NOT NULL DEFAULT FALSE, + due_date DATE, + done_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_notes_board ON notes (board_id, status); +CREATE INDEX IF NOT EXISTS idx_notes_assignee ON notes (assignee_login) WHERE status <> 'done'; + +-- журнал действий: created/status/assigned/edited/deleted/board_created. +-- details: для assigned — логин исполнителя, для deleted — id заметки. +-- note_id REFERENCES ... ON DELETE SET NULL: сама запись переживает удаление +-- заметки, id удалённой сохраняется в details (для синхронизации клиентов). +-- user_login без FK — журнал переживает удаление сотрудника. +CREATE TABLE IF NOT EXISTS note_activity ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + board_id INTEGER NOT NULL REFERENCES boards(id) ON DELETE CASCADE, + note_id BIGINT REFERENCES notes(id) ON DELETE SET NULL, + user_login TEXT NOT NULL, + action TEXT NOT NULL, + details TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- одна общая доска на базу; владелец = первый по логину owner (база после +-- seed CRM уже содержит учётки). Повторный запуск ничего не добавляет. +INSERT INTO boards (kind, title, created_by_login) +SELECT 'shared', 'Общая', u.login +FROM crm_users u +WHERE u.role = 'owner' + AND NOT EXISTS (SELECT 1 FROM boards WHERE kind = 'shared') +ORDER BY u.login +LIMIT 1; diff --git a/crm/notes_api.py b/crm/notes_api.py new file mode 100644 index 0000000..c0b5cbd --- /dev/null +++ b/crm/notes_api.py @@ -0,0 +1,227 @@ +# -*- coding: utf-8 -*- +""" +НЕОН CRM — доски заметок/задач (HTTP-хендлеры). + +Маршруты (все требуют X-Auth-Token): + GET /api/boards — доступные доски + POST /api/boards — приватная доска «владелец ↔ админ» + PATCH /api/boards/ — переименовать (owner/admin) + DELETE /api/boards/ — удалить (только owner) + GET /api/boards//notes — заметки (?since= — только изменённые) + POST /api/boards//notes — создать заметку + GET /api/boards//activity — лента событий доски + PATCH /api/notes/ — правка заметки + DELETE /api/notes/ — удаление (owner/admin/автор) + +Хендлеры собраны в NotesRoutes — миксин для Handler из server.py, чтобы +точка интеграции маршрутов была одной строкой на метод. Доступ к доске +проверяет storage.board_guard(): чужая приватная доска неотличима от +несуществующей — оба случая отдаются как 404 {"error": "not found"}. +""" + +import re +from datetime import datetime +from urllib.parse import parse_qs, urlsplit + +import storage +from storage import clean_guest_text # NF-14: staff-тексты без NUL/bidi-мусора + +BOARD_ID_RE = re.compile(r"^/api/boards/(\d+)$") +BOARD_NOTES_RE = re.compile(r"^/api/boards/(\d+)/notes$") +BOARD_ACTIVITY_RE = re.compile(r"^/api/boards/(\d+)/activity$") +NOTE_ID_RE = re.compile(r"^/api/notes/(\d+)$") + +NOTE_STATUSES = ("open", "in_progress", "done") +# роли с правами администратора — как ADMIN_ROLES в server.py +# (оттуда импортировать нельзя: циклический импорт) +ADMIN_ROLES = ("owner", "admin") +DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") +TITLE_MAX = 200 +BODY_MAX = 4000 +ACTIVITY_LIMIT_MAX = 200 + + +class NotesRoutes: + """Хендлеры досок; подключаются к Handler в server.py.""" + + def _empty(self, code: int = 204): + self._drain_body() # NF-13: 204 без чтения тела ломал бы keep-alive + self.send_response(code) + self.send_header("Content-Length", "0") + self.end_headers() + + # ---------------- доски ---------------- + + def _handle_boards_get(self, user: dict): + self._json({"boards": storage.boards_list(user)}) + + def _handle_board_create(self, user: dict): + if user["role"] != "owner": + self._json({"error": "owner only"}, 403) + return + body = self._read_body() or {} + title = clean_guest_text(str(body.get("title") or ""), TITLE_MAX).strip() + admin_login = str(body.get("admin_login") or "").strip() + if not title: + self._json({"error": "укажите название доски"}, 400) + return + users = {u["login"]: u for u in storage.list_users()} + admin = users.get(admin_login) + if admin is None or admin["role"] != "admin": + self._json({"error": "выберите администратора — участника доски"}, 400) + return + board = storage.board_create(title, admin_login, user["login"]) + self._json({"board": board}, 201) + + def _handle_board_patch(self, user: dict, board_id: int): + board = storage.board_guard(board_id, user) + if board is None: + self._json({"error": "not found"}, 404) + return + if user["role"] not in ADMIN_ROLES: + self._json({"error": "admin only"}, 403) + return + body = self._read_body() or {} + title = clean_guest_text(str(body.get("title") or ""), TITLE_MAX).strip() + if not title: + self._json({"error": "укажите название доски"}, 400) + return + self._json({"board": storage.board_rename(board_id, title)}) + + def _handle_board_delete(self, user: dict, board_id: int): + board = storage.board_guard(board_id, user) + if board is None: + self._json({"error": "not found"}, 404) + return + if user["role"] != "owner": + self._json({"error": "owner only"}, 403) + return + if not storage.board_delete(board_id): + self._json({"error": "not found"}, 404) + return + self._json({"ok": True}) + + # ---------------- заметки ---------------- + + def _handle_board_notes_get(self, user: dict, board_id: int): + if storage.board_guard(board_id, user) is None: + self._json({"error": "not found"}, 404) + return + since = None + raw_since = (parse_qs(urlsplit(self.path).query).get("since") or [""])[0] + if raw_since: + # '+' в зоне вида +05:00 при разборе query превращается в пробел + raw_since = raw_since.replace(" ", "+") + try: + since = datetime.fromisoformat(raw_since) + except ValueError: + self._json({"error": "bad since"}, 400) + return + self._json(storage.notes_list(board_id, since)) + + def _handle_note_create(self, user: dict, board_id: int): + board = storage.board_guard(board_id, user) + if board is None: + self._json({"error": "not found"}, 404) + return + body = self._read_body() or {} + title = clean_guest_text(str(body.get("title") or ""), TITLE_MAX).strip() + if not title: + self._json({"error": "укажите заголовок задачи"}, 400) + return + text = clean_guest_text(str(body.get("body") or ""), BODY_MAX) + important = bool(body.get("important")) + assignee = body.get("assignee") + if assignee is not None: + assignee = str(assignee).strip() or None + if assignee and not storage.note_assignee_ok(board, assignee): + self._json({"error": "исполнитель — не участник этой доски"}, 400) + return + due = None + if body.get("due_date"): + if not DATE_RE.match(str(body["due_date"])): + self._json({"error": "срок в формате ГГГГ-ММ-ДД"}, 400) + return + due = body["due_date"] + note = storage.note_create(board, user, title, text, assignee, important, due) + self._json({"note": note}, 201) + + def _handle_note_patch(self, user: dict, note_id: int): + note = storage.note_get(note_id) + board = storage.board_guard(note["board_id"], user) if note else None + if note is None or board is None: + self._json({"error": "not found"}, 404) + return + body = self._read_body() or {} + if not body: + self._json({"error": "нечего изменять"}, 400) + return + + fields: dict = {} + if "title" in body: + title = clean_guest_text(str(body["title"] or ""), TITLE_MAX).strip() + if not title: + self._json({"error": "укажите заголовок задачи"}, 400) + return + fields["title"] = title + if "body" in body: + fields["body"] = clean_guest_text(str(body["body"] or ""), BODY_MAX) + if "important" in body: + if not isinstance(body["important"], bool): + self._json({"error": "important (bool) required"}, 400) + return + fields["important"] = body["important"] + if "status" in body: + if body["status"] not in NOTE_STATUSES: + self._json({"error": "статус: open, in_progress или done"}, 400) + return + fields["status"] = body["status"] + if "due_date" in body: + if body["due_date"] is None: + fields["due_date"] = None + elif DATE_RE.match(str(body["due_date"])): + fields["due_date"] = str(body["due_date"]) + else: + self._json({"error": "срок в формате ГГГГ-ММ-ДД"}, 400) + return + if "assignee" in body: + assignee = body["assignee"] + if assignee is not None: + assignee = str(assignee).strip() or None + if assignee and not storage.note_assignee_ok(board, assignee): + self._json({"error": "исполнитель — не участник этой доски"}, 400) + return + fields["assignee_login"] = assignee + + updated = storage.note_update(note_id, user, board, fields) + if updated is None: + self._json({"error": "not found"}, 404) + return + self._json({"note": updated}) + + def _handle_note_delete(self, user: dict, note_id: int): + note = storage.note_get(note_id) + board = storage.board_guard(note["board_id"], user) if note else None + if note is None or board is None: + self._json({"error": "not found"}, 404) + return + if user["role"] not in ADMIN_ROLES and note["author"]["id"] != user["login"]: + self._json({"error": "удалять может автор, админ или владелец"}, 403) + return + if storage.note_delete(note_id, user["login"]) is None: + self._json({"error": "not found"}, 404) + return + self._empty(204) + + # ---------------- журнал ---------------- + + def _handle_board_activity(self, user: dict, board_id: int): + if storage.board_guard(board_id, user) is None: + self._json({"error": "not found"}, 404) + return + qs = parse_qs(urlsplit(self.path).query) + try: + limit = min(ACTIVITY_LIMIT_MAX, max(1, int((qs.get("limit") or ["50"])[0]))) + except ValueError: + limit = 50 + self._json({"events": storage.board_activity(board_id, limit)}) diff --git a/crm/requirements.txt b/crm/requirements.txt new file mode 100644 index 0000000..3b3fd9e --- /dev/null +++ b/crm/requirements.txt @@ -0,0 +1 @@ +psycopg[binary,pool]>=3.2,<4 diff --git a/crm/seed/covers/fifa25.jpg b/crm/seed/covers/fifa25.jpg new file mode 100644 index 0000000..f1984d4 Binary files /dev/null and b/crm/seed/covers/fifa25.jpg differ diff --git a/crm/seed/covers/gow.jpg b/crm/seed/covers/gow.jpg new file mode 100644 index 0000000..a599df4 Binary files /dev/null and b/crm/seed/covers/gow.jpg differ diff --git a/crm/seed/covers/gowr.jpg b/crm/seed/covers/gowr.jpg new file mode 100644 index 0000000..6a752d2 Binary files /dev/null and b/crm/seed/covers/gowr.jpg differ diff --git a/crm/seed/covers/mk1.jpg b/crm/seed/covers/mk1.jpg new file mode 100644 index 0000000..1e8945f Binary files /dev/null and b/crm/seed/covers/mk1.jpg differ diff --git a/crm/seed/covers/mod.jpg b/crm/seed/covers/mod.jpg new file mode 100644 index 0000000..9788407 Binary files /dev/null and b/crm/seed/covers/mod.jpg differ diff --git a/crm/seed/covers/over.jpg b/crm/seed/covers/over.jpg new file mode 100644 index 0000000..25003fa Binary files /dev/null and b/crm/seed/covers/over.jpg differ diff --git a/crm/seed/covers/smr.jpg b/crm/seed/covers/smr.jpg new file mode 100644 index 0000000..11d7471 Binary files /dev/null and b/crm/seed/covers/smr.jpg differ diff --git a/crm/seed/covers/ufc5.jpg b/crm/seed/covers/ufc5.jpg new file mode 100644 index 0000000..fffe20b Binary files /dev/null and b/crm/seed/covers/ufc5.jpg differ diff --git a/crm/seed/games.json b/crm/seed/games.json new file mode 100644 index 0000000..39560b4 --- /dev/null +++ b/crm/seed/games.json @@ -0,0 +1,238 @@ +[ + { + "seed_id": "fifa-25", + "kind": "ps5", + "title": "FIFA 25", + "note": "Футбольный симулятор · до 4 игроков", + "cover": "fifa25.jpg" + }, + { + "seed_id": "ufc-5", + "kind": "ps5", + "title": "UFC 5", + "note": "Файтинг · до 2 игроков", + "cover": "ufc5.jpg" + }, + { + "seed_id": "mortal-kombat-1", + "kind": "ps5", + "title": "Mortal Kombat 1", + "note": "Файтинг · до 2 игроков", + "cover": "mk1.jpg" + }, + { + "seed_id": "move-or-die", + "kind": "ps5", + "title": "Move or Die", + "note": "Кооперативная аркада · до 4 игроков", + "cover": "mod.jpg" + }, + { + "seed_id": "overcooked", + "kind": "ps5", + "title": "Overcooked", + "note": "Кооперативная аркада · до 4 игроков", + "cover": "over.jpg" + }, + { + "seed_id": "god-of-war-ragnarok", + "kind": "ps5", + "title": "God of War Ragnarök", + "note": "Экшен-приключение · до 1 игрока", + "cover": "gowr.jpg" + }, + { + "seed_id": "god-of-war", + "kind": "ps5", + "title": "God of War", + "note": "Экшен-приключение · до 1 игрока", + "cover": "gow.jpg" + }, + { + "seed_id": "spider-man-remastered", + "kind": "ps5", + "title": "Marvel's Spider-Man Remastered", + "note": "Экшен-приключение · до 1 игрока", + "cover": "smr.jpg" + }, + { + "seed_id": "board-mafia", + "kind": "board", + "title": "Мафия", + "note": "Компанейские · 6–16 игроков · 40–90 мин · простая · хит", + "cover": null, + "difficulty": 1, + "icon": "eye", + "accent": "#b026ff" + }, + { + "seed_id": "board-bunker", + "kind": "board", + "title": "Бункер", + "note": "Компанейские · 4–16 игроков · 40–60 мин · простая · хит", + "cover": null, + "difficulty": 1, + "icon": "skull", + "accent": "#00f0ff" + }, + { + "seed_id": "board-cards", + "kind": "board", + "title": "Игральные карты", + "note": "Карточные · 2–8 игроков · 10–60 мин · простая · хит", + "cover": null, + "difficulty": 1, + "icon": "spade", + "accent": "#ff4d6d" + }, + { + "seed_id": "board-chess", + "kind": "board", + "title": "Шахматы", + "note": "Классика · 2–2 игроков · 20–60 мин · сложная", + "cover": null, + "difficulty": 3, + "icon": "crown", + "accent": "#e0e0e0" + }, + { + "seed_id": "board-backgammon", + "kind": "board", + "title": "Нарды", + "note": "Классика · 2–2 игроков · 20–40 мин · средняя · хит", + "cover": null, + "difficulty": 2, + "icon": "dices", + "accent": "#f4a261" + }, + { + "seed_id": "board-domino", + "kind": "board", + "title": "Домино", + "note": "Классика · 2–4 игроков · 15–30 мин · простая", + "cover": null, + "difficulty": 1, + "icon": "grid", + "accent": "#ffd23d" + }, + { + "seed_id": "board-checkers", + "kind": "board", + "title": "Шашки", + "note": "Классика · 2–2 игроков · 10–30 мин · простая", + "cover": null, + "difficulty": 1, + "icon": "circle", + "accent": "#4dff88" + }, + { + "seed_id": "board-uno", + "kind": "board", + "title": "UNO", + "note": "Карточные · 2–10 игроков · 15–30 мин · простая · хит", + "cover": null, + "difficulty": 1, + "icon": "hash", + "accent": "#ff3636" + }, + { + "seed_id": "board-monopoly", + "kind": "board", + "title": "Монополия", + "note": "Стратегия · 2–6 игроков · 60–120 мин · средняя", + "cover": null, + "difficulty": 2, + "icon": "landmark", + "accent": "#2ec27e" + }, + { + "seed_id": "board-jenga", + "kind": "board", + "title": "Дженга", + "note": "Компанейские · 2–8 игроков · 10–20 мин · простая", + "cover": null, + "difficulty": 1, + "icon": "layout", + "accent": "#d4af37" + }, + { + "seed_id": "board-alias", + "kind": "board", + "title": "Alias", + "note": "Компанейские · 4–12 игроков · 30–60 мин · простая", + "cover": null, + "difficulty": 1, + "icon": "brain", + "accent": "#ff8a00" + }, + { + "seed_id": "board-imaginarium", + "kind": "board", + "title": "Имаджинариум", + "note": "Компанейские · 4–7 игроков · 30–60 мин · простая", + "cover": null, + "difficulty": 1, + "icon": "puzzle", + "accent": "#c77dff" + }, + { + "seed_id": "board-crocodile", + "kind": "board", + "title": "Крокодил", + "note": "Компанейские · 4–16 игроков · 20–60 мин · простая", + "cover": null, + "difficulty": 1, + "icon": "users", + "accent": "#00c2a8" + }, + { + "seed_id": "board-poker", + "kind": "board", + "title": "Покерный набор", + "note": "Карточные · 2–9 игроков · 60–180 мин · средняя", + "cover": null, + "difficulty": 2, + "icon": "club", + "accent": "#1fa37a" + }, + { + "seed_id": "board-exploding-kittens", + "kind": "board", + "title": "Взрывные котята", + "note": "Карточные · 2–5 игроков · 15 мин · простая", + "cover": null, + "difficulty": 1, + "icon": "ghost", + "accent": "#ff6b9d" + }, + { + "seed_id": "board-catan", + "kind": "board", + "title": "Колонизаторы (Catan)", + "note": "Стратегия · 3–4 игроков · 60–90 мин · средняя", + "cover": null, + "difficulty": 2, + "icon": "castle", + "accent": "#e9c46a" + }, + { + "seed_id": "board-dixit", + "kind": "board", + "title": "Диксит", + "note": "Компанейские · 3–6 игроков · 30 мин · простая", + "cover": null, + "difficulty": 1, + "icon": "heart", + "accent": "#f77f00" + }, + { + "seed_id": "board-codenames", + "kind": "board", + "title": "Кодовые имена", + "note": "На логику · 4–8 игроков · 15–30 мин · средняя", + "cover": null, + "difficulty": 2, + "icon": "target", + "accent": "#4895ef" + } +] diff --git a/crm/seed/menu.json b/crm/seed/menu.json new file mode 100644 index 0000000..47e8bf8 --- /dev/null +++ b/crm/seed/menu.json @@ -0,0 +1,359 @@ +[ + { + "id": "blackberry-mint", + "name": "Ежевика-мята", + "category": "АВТОРСКИЙ ЧАЙ", + "price": 350, + "portion": "900 мл" + }, + { + "id": "raspberry-mint", + "name": "Малина-мята", + "category": "АВТОРСКИЙ ЧАЙ", + "price": 350, + "portion": "900 мл" + }, + { + "id": "seabuckthorn-orange", + "name": "Облепиха-апельсин", + "category": "АВТОРСКИЙ ЧАЙ", + "price": 350, + "portion": "900 мл" + }, + { + "id": "seabuckthorn-cinnamon", + "name": "Облепиха-корица", + "category": "АВТОРСКИЙ ЧАЙ", + "price": 350, + "portion": "900 мл" + }, + { + "id": "forest-berries", + "name": "Лесные ягоды", + "category": "АВТОРСКИЙ ЧАЙ", + "price": 350, + "portion": "900 мл" + }, + { + "id": "mango-seabuckthorn", + "name": "Манго-облепиха", + "category": "АВТОРСКИЙ ЧАЙ", + "price": 350, + "portion": "900 мл" + }, + { + "id": "raspberry-ginger", + "name": "Малина-имбирь", + "category": "АВТОРСКИЙ ЧАЙ", + "price": 350, + "portion": "900 мл" + }, + { + "id": "bud", + "name": "Bud", + "category": "ПИВНАЯ КАРТА", + "price": 250, + "portion": "0,44 л" + }, + { + "id": "kozel-dark", + "name": "Kozel темный", + "category": "ПИВНАЯ КАРТА", + "price": 220, + "portion": "0,45 л" + }, + { + "id": "stary-melnik-light", + "name": "Старый мельник светлое", + "category": "ПИВНАЯ КАРТА", + "price": 200, + "portion": "0,45 л" + }, + { + "id": "krone-blanche", + "name": "Krone Blanche", + "category": "ПИВНАЯ КАРТА", + "price": 220, + "portion": "0,45 л" + }, + { + "id": "erzmann", + "name": "Erzmann", + "category": "ПИВНАЯ КАРТА", + "price": 220, + "portion": "0,45 л" + }, + { + "id": "el-capulco", + "name": "El Capulco", + "category": "ПИВНАЯ КАРТА", + "price": 250, + "portion": "0,45 л" + }, + { + "id": "stella", + "name": "Stella", + "category": "ПИВНАЯ КАРТА", + "price": 250, + "portion": "0,44 л" + }, + { + "id": "kozel-light", + "name": "Kozel светлый", + "category": "ПИВНАЯ КАРТА", + "price": 220, + "portion": "0,45 л" + }, + { + "id": "stary-melnik-dark", + "name": "Старый мельник темное", + "category": "ПИВНАЯ КАРТА", + "price": 200, + "portion": "1 шт" + }, + { + "id": "pablos-lime", + "name": "Pablo's с соком лайма", + "category": "ПИВНАЯ КАРТА", + "price": 200, + "portion": "1 шт" + }, + { + "id": "redds", + "name": "Redd's", + "category": "ПИВНАЯ КАРТА", + "price": 200, + "portion": "1 шт" + }, + { + "id": "spaten", + "name": "Spatten", + "category": "ПИВНАЯ КАРТА", + "price": 250, + "portion": "0,45 л" + }, + { + "id": "hoegaarden", + "name": "Hoegaarden", + "category": "ПИВНАЯ КАРТА", + "price": 220, + "portion": "0,45 л" + }, + { + "id": "corona-extra", + "name": "Corona Extra", + "category": "ПИВНАЯ КАРТА", + "price": 250, + "portion": "1 шт" + }, + { + "id": "onion-rings", + "name": "Луковые кольца", + "category": "ЗАКУСКИ", + "price": 220, + "portion": "8 шт" + }, + { + "id": "fries", + "name": "Картофель Фри", + "category": "ЗАКУСКИ", + "price": 210, + "portion": "130 гр" + }, + { + "id": "buffalo-wings", + "name": "Крылышки Баффало", + "category": "ЗАКУСКИ", + "price": 350, + "portion": "6 шт" + }, + { + "id": "cheese-sticks", + "name": "Сырные палочки", + "category": "ЗАКУСКИ", + "price": 310, + "portion": "6 шт" + }, + { + "id": "strips", + "name": "Стрипсы", + "category": "ЗАКУСКИ", + "price": 320, + "portion": "6 шт" + }, + { + "id": "nuggets", + "name": "Нагетсы", + "category": "ЗАКУСКИ", + "price": 320, + "portion": "8 шт" + }, + { + "id": "black-tea", + "name": "Черный чай", + "category": "НАПИТКИ И ДЕСЕРТЫ", + "price": 250, + "portion": "900 мл" + }, + { + "id": "black-tea-thyme", + "name": "Черный чай с чабрецом", + "category": "НАПИТКИ И ДЕСЕРТЫ", + "price": 250, + "portion": "900 мл" + }, + { + "id": "milk-oolong", + "name": "Молочный Улун", + "category": "НАПИТКИ И ДЕСЕРТЫ", + "price": 350, + "portion": "900 мл" + }, + { + "id": "americano", + "name": "Американо", + "category": "НАПИТКИ И ДЕСЕРТЫ", + "price": 150, + "portion": "150 мл" + }, + { + "id": "cappuccino", + "name": "Капучино", + "category": "НАПИТКИ И ДЕСЕРТЫ", + "price": 200, + "portion": "200 мл" + }, + { + "id": "cheesecake-ny", + "name": "Чизкейк Нью-Йорк", + "category": "НАПИТКИ И ДЕСЕРТЫ", + "price": 250, + "portion": "120 гр" + }, + { + "id": "red-velvet", + "name": "Торт Красный бархат", + "category": "НАПИТКИ И ДЕСЕРТЫ", + "price": 250, + "portion": "120 гр" + }, + { + "id": "medovik", + "name": "Торт Медовик", + "category": "НАПИТКИ И ДЕСЕРТЫ", + "price": 250, + "portion": "120 гр" + }, + { + "id": "cheesecake-strawberry", + "name": "Чизкейк Нью-Йорк клубничный", + "category": "НАПИТКИ И ДЕСЕРТЫ", + "price": 250, + "portion": "120 гр" + }, + { + "id": "cheesecake-raspberry", + "name": "Чизкейк Нью-Йорк Малина", + "category": "НАПИТКИ И ДЕСЕРТЫ", + "price": 250, + "portion": "120 гр" + }, + { + "id": "alpen-gold", + "name": "Alpen Gold", + "category": "СНЕКИ И СЛАДОСТИ", + "price": 210, + "portion": null + }, + { + "id": "milka", + "name": "Milka", + "category": "СНЕКИ И СЛАДОСТИ", + "price": 230, + "portion": null + }, + { + "id": "leys", + "name": "Чипсы Leys", + "category": "СНЕКИ И СЛАДОСТИ", + "price": 210, + "portion": "90 гр" + }, + { + "id": "korochki", + "name": "3 Корочки", + "category": "СНЕКИ И СЛАДОСТИ", + "price": 150, + "portion": "60 гр" + }, + { + "id": "hrustim", + "name": "Хрустим team", + "category": "СНЕКИ И СЛАДОСТИ", + "price": 100, + "portion": "40 гр" + }, + { + "id": "natakhtari-pear", + "name": "НАТАХТАРИ ГРУША", + "category": "ЛИМОНАДЫ И ЭНЕРГЕТИКИ", + "price": 200, + "portion": "1 шт" + }, + { + "id": "strawberry-kiwi", + "name": "Клубника Киви", + "category": "ЛИМОНАДЫ И ЭНЕРГЕТИКИ", + "price": 150, + "portion": "1 шт" + }, + { + "id": "feijoa", + "name": "Фейхоа", + "category": "ЛИМОНАДЫ И ЭНЕРГЕТИКИ", + "price": 150, + "portion": "1 шт" + }, + { + "id": "natakhtari-feijoa", + "name": "НАТАХТАРИ ФЕЙХОА", + "category": "ЛИМОНАДЫ И ЭНЕРГЕТИКИ", + "price": 200, + "portion": "1 шт" + }, + { + "id": "natakhtari-saperavi", + "name": "НАТАХТАРИ САПЕРАВИ", + "category": "ЛИМОНАДЫ И ЭНЕРГЕТИКИ", + "price": 200, + "portion": "1 шт" + }, + { + "id": "burn-original", + "name": "BURN Оригинальный", + "category": "ЛИМОНАДЫ И ЭНЕРГЕТИКИ", + "price": 250, + "portion": "1 шт" + }, + { + "id": "adrenaline-rush", + "name": "Adrenaline RUSH Абсолютная ЭНЕРГИЯ", + "category": "ЛИМОНАДЫ И ЭНЕРГЕТИКИ", + "price": 250, + "portion": "1 шт" + }, + { + "id": "burn-apple-kiwi", + "name": "BURN Яблоко Киви", + "category": "ЛИМОНАДЫ И ЭНЕРГЕТИКИ", + "price": 250, + "portion": "1 шт" + }, + { + "id": "burn-mango", + "name": "BURN Со вкусом МАНГО", + "category": "ЛИМОНАДЫ И ЭНЕРГЕТИКИ", + "price": 250, + "portion": "1 шт" + } +] diff --git a/crm/seed/mixes.json b/crm/seed/mixes.json new file mode 100644 index 0000000..3fbe39e --- /dev/null +++ b/crm/seed/mixes.json @@ -0,0 +1,556 @@ +[ + { + "id": "showcase-1", + "name": "Кристальный лайм", + "note": "Лайм держит основу, Помело добавляет характер — свежесть до утра.", + "strength": 1, + "cooling": 0, + "flavor_tags": [ + "Цитрус" + ], + "price": "1 400 ₽", + "featured": true + }, + { + "id": "showcase-2", + "name": "Морозная клубника", + "note": "Клубника держит основу, Лайм добавляет характер — свежесть до утра.", + "strength": 1, + "cooling": 0, + "flavor_tags": [ + "Ягоды", + "Цитрус" + ], + "price": "1 400 ₽", + "featured": true + }, + { + "id": "showcase-3", + "name": "Морозное помело", + "note": "Помело держит основу, Лайм добавляет характер — прохлада с первой затяжки.", + "strength": 1, + "cooling": 2, + "flavor_tags": [ + "Цитрус", + "лёд" + ], + "price": "1 400 ₽", + "featured": true + }, + { + "id": "showcase-4", + "name": "Морозная клубника · с холодком", + "note": "Клубника держит основу, Лайм добавляет характер — прохлада с первой затяжки.", + "strength": 1, + "cooling": 2, + "flavor_tags": [ + "Ягоды", + "Цитрус", + "лёд" + ], + "price": "1 400 ₽", + "featured": true + }, + { + "id": "showcase-5", + "name": "Морозная груша", + "note": "Груша держит основу, Яблоко добавляет характер — свежесть до утра.", + "strength": 3, + "cooling": 0, + "flavor_tags": [ + "Цитрус", + "Фрукты" + ], + "price": "1 400 ₽", + "featured": true + }, + { + "id": "showcase-6", + "name": "Кристальная клубника", + "note": "Клубника держит основу, Груша добавляет характер — ледяной и чистый финиш.", + "strength": 3, + "cooling": 0, + "flavor_tags": [ + "Ягоды", + "Цитрус", + "Фрукты" + ], + "price": "1 400 ₽", + "featured": false + }, + { + "id": "showcase-7", + "name": "Морозная груша · с холодком", + "note": "Груша держит основу, Клубника добавляет характер — свежесть до утра.", + "strength": 3, + "cooling": 2, + "flavor_tags": [ + "Цитрус", + "Ягоды", + "лёд" + ], + "price": "1 400 ₽", + "featured": false + }, + { + "id": "showcase-8", + "name": "Полярное яблоко", + "note": "Яблоко держит основу, Клубника добавляет характер — прохлада с первой затяжки.", + "strength": 3, + "cooling": 2, + "flavor_tags": [ + "Фрукты", + "Ягоды", + "лёд" + ], + "price": "1 400 ₽", + "featured": false + }, + { + "id": "showcase-9", + "name": "Полярная голубика", + "note": "Голубика держит основу, Фисташковое мороженое добавляет характер — свежесть до утра.", + "strength": 5, + "cooling": 0, + "flavor_tags": [ + "Ягоды", + "Десерты", + "Пряности" + ], + "price": "1 800 ₽", + "featured": false + }, + { + "id": "showcase-10", + "name": "Кристальная медовая дыня", + "note": "Медовая дыня держит основу, Ультракислый лимон добавляет характер — свежесть до утра.", + "strength": 5, + "cooling": 0, + "flavor_tags": [ + "Фрукты", + "Цитрус" + ], + "price": "1 800 ₽", + "featured": false + }, + { + "id": "showcase-11", + "name": "Ледяной ультракислый лимон", + "note": "Ультракислый лимон держит основу, Медовая дыня добавляет характер — ледяной и чистый финиш.", + "strength": 5, + "cooling": 2, + "flavor_tags": [ + "Цитрус", + "Фрукты", + "лёд" + ], + "price": "1 800 ₽", + "featured": false + }, + { + "id": "showcase-12", + "name": "Ледяная медовая дыня", + "note": "Медовая дыня держит основу, Сочный ананас добавляет характер — свежесть до утра.", + "strength": 5, + "cooling": 2, + "flavor_tags": [ + "Фрукты", + "Тропики", + "лёд" + ], + "price": "1 800 ₽", + "featured": false + }, + { + "id": "showcase-13", + "name": "Сливочная манго-ромашка", + "note": "Манго-ромашка держит основу, Помело добавляет характер — мягкий десертный дым.", + "strength": 1, + "cooling": 0, + "flavor_tags": [ + "Тропики", + "Цитрус", + "Ягоды" + ], + "price": "1 400 ₽", + "featured": false + }, + { + "id": "showcase-14", + "name": "Сливочная клубника", + "note": "Клубника держит основу, Помело добавляет характер — мягкий десертный дым.", + "strength": 1, + "cooling": 0, + "flavor_tags": [ + "Ягоды", + "Цитрус" + ], + "price": "1 400 ₽", + "featured": false + }, + { + "id": "showcase-15", + "name": "Сливочная клубника · с холодком", + "note": "Клубника держит основу, Помело добавляет характер — мягкий десертный дым.", + "strength": 1, + "cooling": 2, + "flavor_tags": [ + "Ягоды", + "Цитрус", + "лёд" + ], + "price": "1 400 ₽", + "featured": false + }, + { + "id": "showcase-16", + "name": "Бархатная манго-ромашка", + "note": "Манго-ромашка держит основу, Лайм добавляет характер — мягкий десертный дым.", + "strength": 1, + "cooling": 2, + "flavor_tags": [ + "Тропики", + "Цитрус", + "лёд" + ], + "price": "1 400 ₽", + "featured": false + }, + { + "id": "showcase-17", + "name": "Бархатная клубника", + "note": "Клубника держит основу, Груша добавляет характер — мягкий десертный дым.", + "strength": 3, + "cooling": 0, + "flavor_tags": [ + "Ягоды", + "Цитрус" + ], + "price": "1 400 ₽", + "featured": false + }, + { + "id": "showcase-18", + "name": "Вельветовое печенье", + "note": "Печенье держит основу, Яблоко добавляет характер — для долгих разговоров.", + "strength": 3, + "cooling": 0, + "flavor_tags": [ + "Десерты", + "Фрукты" + ], + "price": "1 400 ₽", + "featured": false + }, + { + "id": "showcase-19", + "name": "Вельветовое печенье · с холодком", + "note": "Печенье держит основу, Клубника добавляет характер — мягкий десертный дым.", + "strength": 3, + "cooling": 2, + "flavor_tags": [ + "Десерты", + "Ягоды", + "лёд" + ], + "price": "1 400 ₽", + "featured": false + }, + { + "id": "showcase-20", + "name": "Ночной шоколадный брауни", + "note": "Шоколадный брауни держит основу, Базилик добавляет характер — густое сладкое послевкусие.", + "strength": 5, + "cooling": 0, + "flavor_tags": [ + "Десерты", + "Пряности" + ], + "price": "1 800 ₽", + "featured": false + }, + { + "id": "showcase-21", + "name": "Сливочный сочный ананас", + "note": "Сочный ананас держит основу, Медовая дыня добавляет характер — для долгих разговоров.", + "strength": 5, + "cooling": 0, + "flavor_tags": [ + "Тропики", + "Фрукты", + "Пряности" + ], + "price": "1 800 ₽", + "featured": false + }, + { + "id": "showcase-22", + "name": "Вельветовый шоколадный брауни", + "note": "Шоколадный брауни держит основу, Фисташковое мороженое добавляет характер — мягкий десертный дым.", + "strength": 5, + "cooling": 2, + "flavor_tags": [ + "Десерты", + "лёд" + ], + "price": "1 800 ₽", + "featured": false + }, + { + "id": "showcase-23", + "name": "Вельветовый банан", + "note": "Банан держит основу, Фисташковое мороженое добавляет характер — для долгих разговоров.", + "strength": 5, + "cooling": 2, + "flavor_tags": [ + "Тропики", + "Десерты", + "лёд" + ], + "price": "1 800 ₽", + "featured": false + }, + { + "id": "showcase-24", + "name": "Солнечная манго-ромашка · клубника", + "note": "Манго-ромашка держит основу, Клубника добавляет характер — волна спелых фруктов.", + "strength": 1, + "cooling": 0, + "flavor_tags": [ + "Тропики", + "Ягоды", + "Цитрус" + ], + "price": "1 400 ₽", + "featured": false + }, + { + "id": "showcase-25", + "name": "Закатная манго-ромашка", + "note": "Манго-ромашка держит основу, Лайм добавляет характер — как закат у бара.", + "strength": 1, + "cooling": 0, + "flavor_tags": [ + "Тропики", + "Цитрус" + ], + "price": "1 400 ₽", + "featured": false + }, + { + "id": "showcase-26", + "name": "Тропическая манго-ромашка · клубника", + "note": "Манго-ромашка держит основу, Клубника добавляет характер — волна спелых фруктов.", + "strength": 1, + "cooling": 2, + "flavor_tags": [ + "Тропики", + "Ягоды", + "лёд" + ], + "price": "1 400 ₽", + "featured": false + }, + { + "id": "showcase-27", + "name": "Тропическая манго-ромашка · помело", + "note": "Манго-ромашка держит основу, Помело добавляет характер — волна спелых фруктов.", + "strength": 1, + "cooling": 2, + "flavor_tags": [ + "Тропики", + "Цитрус", + "лёд" + ], + "price": "1 400 ₽", + "featured": false + }, + { + "id": "showcase-28", + "name": "Закатное яблоко", + "note": "Яблоко держит основу, Клубника добавляет характер — волна спелых фруктов.", + "strength": 3, + "cooling": 0, + "flavor_tags": [ + "Фрукты", + "Ягоды" + ], + "price": "1 400 ₽", + "featured": false + }, + { + "id": "showcase-29", + "name": "Закатное яблоко · с холодком", + "note": "Яблоко держит основу, Груша добавляет характер — волна спелых фруктов.", + "strength": 3, + "cooling": 2, + "flavor_tags": [ + "Фрукты", + "Цитрус", + "лёд" + ], + "price": "1 400 ₽", + "featured": false + }, + { + "id": "showcase-30", + "name": "Тропическое сочное манго", + "note": "Сочное манго держит основу, Базилик добавляет характер — волна спелых фруктов.", + "strength": 5, + "cooling": 0, + "flavor_tags": [ + "Тропики", + "Пряности", + "Ягоды" + ], + "price": "1 800 ₽", + "featured": false + }, + { + "id": "showcase-31", + "name": "Спелое сочное манго", + "note": "Сочное манго держит основу, Медовая дыня добавляет характер — сочный тропический характер.", + "strength": 5, + "cooling": 0, + "flavor_tags": [ + "Тропики", + "Фрукты", + "Цитрус" + ], + "price": "1 800 ₽", + "featured": false + }, + { + "id": "showcase-32", + "name": "Спелый сочный ананас", + "note": "Сочный ананас держит основу, Фисташковое мороженое добавляет характер — сочный тропический характер.", + "strength": 5, + "cooling": 2, + "flavor_tags": [ + "Тропики", + "Десерты", + "лёд" + ], + "price": "1 800 ₽", + "featured": false + }, + { + "id": "showcase-33", + "name": "Солнечное сочное манго", + "note": "Сочное манго держит основу, Фисташковое мороженое добавляет характер — как закат у бара.", + "strength": 5, + "cooling": 2, + "flavor_tags": [ + "Тропики", + "Десерты", + "лёд" + ], + "price": "1 800 ₽", + "featured": false + }, + { + "id": "showcase-34", + "name": "Ягодная клубника", + "note": "Клубника держит основу, Лайм добавляет характер — сочная ягодная кислинка.", + "strength": 1, + "cooling": 0, + "flavor_tags": [ + "Ягоды", + "Цитрус" + ], + "price": "1 400 ₽", + "featured": false + }, + { + "id": "showcase-35", + "name": "Полночный виноград", + "note": "Белый виноград держит основу, Голубика добавляет характер — сочная ягодная кислинка.", + "strength": 1, + "cooling": 2, + "flavor_tags": [ + "Ягоды" + ], + "price": "1 400 ₽", + "featured": false + }, + { + "id": "showcase-36", + "name": "Ягодная голубика", + "note": "Голубика держит основу, Медовая дыня добавляет характер — спелые ягоды в каждом вдохе.", + "strength": 5, + "cooling": 0, + "flavor_tags": [ + "Ягоды", + "Фрукты" + ], + "price": "1 800 ₽", + "featured": false + }, + { + "id": "showcase-37", + "name": "Ягодный белый виноград", + "note": "Белый виноград держит основу, Сочный ананас добавляет характер — сочная ягодная кислинка.", + "strength": 5, + "cooling": 0, + "flavor_tags": [ + "Ягоды", + "Тропики" + ], + "price": "1 800 ₽", + "featured": false + }, + { + "id": "showcase-38", + "name": "Пурпурный белый виноград", + "note": "Белый виноград держит основу, Голубика добавляет характер — спелые ягоды в каждом вдохе.", + "strength": 5, + "cooling": 2, + "flavor_tags": [ + "Ягоды", + "лёд" + ], + "price": "1 800 ₽", + "featured": false + }, + { + "id": "showcase-39", + "name": "Ягодная голубика · с холодком", + "note": "Голубика держит основу, Медовая дыня добавляет характер — яркая ягодная волна.", + "strength": 5, + "cooling": 2, + "flavor_tags": [ + "Ягоды", + "Фрукты", + "лёд" + ], + "price": "1 800 ₽", + "featured": false + }, + { + "id": "showcase-40", + "name": "Искрящийся ультракислый лимон", + "note": "Ультракислый лимон держит основу, Базилик добавляет характер — звонкая кислинка на выдохе.", + "strength": 5, + "cooling": 0, + "flavor_tags": [ + "Цитрус", + "Пряности" + ], + "price": "1 800 ₽", + "featured": false + }, + { + "id": "showcase-41", + "name": "Искрящийся ультракислый лимон · медовая дыня", + "note": "Ультракислый лимон держит основу, Медовая дыня добавляет характер — бодрая цитрусовая искра.", + "strength": 5, + "cooling": 0, + "flavor_tags": [ + "Цитрус", + "Фрукты", + "Ягоды" + ], + "price": "1 800 ₽", + "featured": false + } +] diff --git a/crm/seed/tobacco.json b/crm/seed/tobacco.json new file mode 100644 index 0000000..a22ffa5 --- /dev/null +++ b/crm/seed/tobacco.json @@ -0,0 +1,706 @@ +[ + { + "id": "add-north", + "brand": "Добавка", + "flavor": "Север", + "note": "Бустер", + "strength": 1, + "cooling": true + }, + { + "id": "add-cooling", + "brand": "Добавка", + "flavor": "Холодок", + "note": null, + "strength": 1, + "cooling": true + }, + { + "id": "sarma-drunk-pear", + "brand": "Сарма", + "flavor": "Дачная груша", + "note": null, + "strength": 2, + "cooling": false + }, + { + "id": "sarma-corn-paprika", + "brand": "Сарма", + "flavor": "Кукуруза-паприка", + "note": null, + "strength": 2, + "cooling": false + }, + { + "id": "north-elderberry", + "brand": "Северный", + "flavor": "Бузина", + "note": null, + "strength": 3, + "cooling": false + }, + { + "id": "north-feijoa", + "brand": "Северный", + "flavor": "Финдеперцовый фейхоа", + "note": null, + "strength": 3, + "cooling": false + }, + { + "id": "north-flower", + "brand": "Северный", + "flavor": "Цветочный", + "note": null, + "strength": 3, + "cooling": false + }, + { + "id": "north-sorbet", + "brand": "Северный", + "flavor": "Sorbet", + "note": "Фруктовый", + "strength": 3, + "cooling": false + }, + { + "id": "hooligan-flash", + "brand": "Хулиган", + "flavor": "«Флеш»", + "note": "Медовая малина", + "strength": 2, + "cooling": false + }, + { + "id": "hooligan-grapefruit", + "brand": "Хулиган", + "flavor": "Грейпфрутовый сок", + "note": null, + "strength": 2, + "cooling": false + }, + { + "id": "wood-berry-ninja", + "brand": "Хулиган / WOOD", + "flavor": "Берри Ниндзя", + "note": "Лесные ягоды", + "strength": 2, + "cooling": false + }, + { + "id": "wood-begemot", + "brand": "Хулиган / WOOD", + "flavor": "Begemot", + "note": "Бергамот-мандарин", + "strength": 2, + "cooling": false + }, + { + "id": "wood-boo", + "brand": "Хулиган / WOOD", + "flavor": "Boo", + "note": "Яблоко-гранат", + "strength": 2, + "cooling": false + }, + { + "id": "wood-old", + "brand": "Хулиган / WOOD", + "flavor": "Old", + "note": "Лимон с мёдом", + "strength": 2, + "cooling": false + }, + { + "id": "wood-sir-hard", + "brand": "Хулиган / WOOD", + "flavor": "Sir Hard", + "note": "Воздушный рис", + "strength": 3, + "cooling": false + }, + { + "id": "banger-black-white", + "brand": "Banger", + "flavor": "Black and White", + "note": "Печенье Oreo", + "strength": 2, + "cooling": false + }, + { + "id": "banger-cola-bella", + "brand": "Banger", + "flavor": "Cola Bella", + "note": "Ванильная кола", + "strength": 2, + "cooling": false + }, + { + "id": "banger-cream-soda", + "brand": "Banger", + "flavor": "Cream Soda", + "note": "Крем-сода", + "strength": 2, + "cooling": false + }, + { + "id": "banger-grapefruit", + "brand": "Banger", + "flavor": "Grapefruit", + "note": "Грейпфрут", + "strength": 2, + "cooling": false + }, + { + "id": "banger-green-day", + "brand": "Banger", + "flavor": "Green Day", + "note": "Яблоко, киви, кислинка", + "strength": 2, + "cooling": false + }, + { + "id": "banger-sexy", + "brand": "Banger", + "flavor": "Sexy", + "note": "Грейпфрут, клубника, малина", + "strength": 2, + "cooling": false + }, + { + "id": "bb-brownie", + "brand": "Blackburn", + "flavor": "Брауни", + "note": null, + "strength": 3, + "cooling": false + }, + { + "id": "bb-grape-lollipop", + "brand": "Blackburn", + "flavor": "Виноградный чупа-чупс", + "note": null, + "strength": 3, + "cooling": false + }, + { + "id": "bb-fir", + "brand": "Blackburn", + "flavor": "Ёлка", + "note": null, + "strength": 3, + "cooling": true + }, + { + "id": "bb-cola", + "brand": "Blackburn", + "flavor": "Классическая кола", + "note": null, + "strength": 3, + "cooling": false + }, + { + "id": "bb-red-orange", + "brand": "Blackburn", + "flavor": "Красный апельсин", + "note": null, + "strength": 3, + "cooling": false + }, + { + "id": "bb-forest-raspberry", + "brand": "Blackburn", + "flavor": "Лесная малина", + "note": null, + "strength": 3, + "cooling": false + }, + { + "id": "bb-honey-melon", + "brand": "Blackburn", + "flavor": "Медовая дыня", + "note": null, + "strength": 3, + "cooling": false + }, + { + "id": "bb-juicy-mango", + "brand": "Blackburn", + "flavor": "Сочное манго", + "note": null, + "strength": 3, + "cooling": false + }, + { + "id": "bb-berry-sorbet", + "brand": "Blackburn", + "flavor": "Ягодный сорбет", + "note": "С грейпфрутом", + "strength": 3, + "cooling": false + }, + { + "id": "bb-after-8", + "brand": "Blackburn", + "flavor": "After 8", + "note": "Шоколад-мята", + "strength": 3, + "cooling": true + }, + { + "id": "bb-ananas-shock", + "brand": "Blackburn", + "flavor": "Ananas Shock", + "note": "Кислый ананас", + "strength": 3, + "cooling": false + }, + { + "id": "bb-apple-shock", + "brand": "Blackburn", + "flavor": "Apple Shock", + "note": "Кислое зелёное яблоко", + "strength": 3, + "cooling": false + }, + { + "id": "bb-bananini", + "brand": "Blackburn", + "flavor": "Bananini", + "note": "Банан", + "strength": 3, + "cooling": false + }, + { + "id": "bb-basilic", + "brand": "Blackburn", + "flavor": "Basilic", + "note": "Базилик", + "strength": 3, + "cooling": false + }, + { + "id": "bb-blackberry-lemonade", + "brand": "Blackburn", + "flavor": "Blackberry Lemonade", + "note": "Ежевичный лимонад", + "strength": 3, + "cooling": false + }, + { + "id": "bb-blueberry", + "brand": "Blackburn", + "flavor": "Blueberry", + "note": "Голубика", + "strength": 3, + "cooling": false + }, + { + "id": "bb-kiwi-stoner", + "brand": "Blackburn", + "flavor": "Kiwi Stoner", + "note": "Смузи из киви", + "strength": 3, + "cooling": false + }, + { + "id": "bb-lemon-shock", + "brand": "Blackburn", + "flavor": "Lemon Shock", + "note": "Ультракислый лимон", + "strength": 3, + "cooling": false + }, + { + "id": "bb-lulo", + "brand": "Blackburn", + "flavor": "Lulo", + "note": null, + "strength": 3, + "cooling": false + }, + { + "id": "bb-overdose", + "brand": "Blackburn", + "flavor": "Overdose", + "note": "Лимон-лайм", + "strength": 3, + "cooling": false + }, + { + "id": "bb-pear-lemonade", + "brand": "Blackburn", + "flavor": "Pear Lemonade", + "note": "Грушевый лимонад", + "strength": 3, + "cooling": false + }, + { + "id": "bb-pina-colada", + "brand": "Blackburn", + "flavor": "Pina Colada", + "note": null, + "strength": 3, + "cooling": false + }, + { + "id": "bb-pineapple", + "brand": "Blackburn", + "flavor": "Pineapple", + "note": "Сочный ананас", + "strength": 3, + "cooling": false + }, + { + "id": "bb-pistachio-ice", + "brand": "Blackburn", + "flavor": "Pistachio Ice Snow", + "note": "Фисташковое мороженое", + "strength": 3, + "cooling": true + }, + { + "id": "bb-pomelo", + "brand": "Blackburn", + "flavor": "Pomelo", + "note": null, + "strength": 3, + "cooling": false + }, + { + "id": "bb-real-pf", + "brand": "Blackburn", + "flavor": "Real P.F.", + "note": "Маракуйя", + "strength": 3, + "cooling": false + }, + { + "id": "bb-red-energy", + "brand": "Blackburn", + "flavor": "Red Energy", + "note": "Энергетик", + "strength": 3, + "cooling": false + }, + { + "id": "bb-white-grape", + "brand": "Blackburn", + "flavor": "White Grape", + "note": "Белый виноград", + "strength": 3, + "cooling": false + }, + { + "id": "bliss-strawberry", + "brand": "Bliss", + "flavor": "Клубника", + "note": null, + "strength": 1, + "cooling": false + }, + { + "id": "bliss-lime", + "brand": "Bliss", + "flavor": "Лайм", + "note": null, + "strength": 1, + "cooling": false + }, + { + "id": "bliss-mango-chamomile", + "brand": "Bliss", + "flavor": "Манго-ромашка", + "note": null, + "strength": 1, + "cooling": false + }, + { + "id": "bliss-peach-yogurt", + "brand": "Bliss", + "flavor": "Персик-йогурт", + "note": null, + "strength": 1, + "cooling": false + }, + { + "id": "bliss-love-shot", + "brand": "Bliss", + "flavor": "Love Shot", + "note": "Маракуйя, арбуз, лимон, холодок", + "strength": 1, + "cooling": true + }, + { + "id": "chabacco-strawberry-milksake", + "brand": "Chabacco", + "flavor": "Mix", + "note": "Клубничный милкшейк", + "strength": 2, + "cooling": false + }, + { + "id": "ds-alpine-trip", + "brand": "Darkside", + "flavor": "Алтайский трип", + "note": "Хвоя, фейхоа, эвкалипт", + "strength": 2, + "cooling": true + }, + { + "id": "deus-base", + "brand": "DEUS", + "flavor": "Base", + "note": "Табачный лист", + "strength": 2, + "cooling": false + }, + { + "id": "deus-blackberry-wine", + "brand": "DEUS", + "flavor": "Blackberry Wine", + "note": "Ежевичное вино", + "strength": 2, + "cooling": false + }, + { + "id": "deus-blueberry-yogurt", + "brand": "DEUS", + "flavor": "Blueberry Yogurt", + "note": "Черничный йогурт", + "strength": 2, + "cooling": false + }, + { + "id": "deus-champagne", + "brand": "DEUS", + "flavor": "Champagne", + "note": "Игристое вино", + "strength": 2, + "cooling": false + }, + { + "id": "deus-cherry-jelly", + "brand": "DEUS", + "flavor": "Cherry Jelly", + "note": "Вишнёвое желе", + "strength": 2, + "cooling": false + }, + { + "id": "deus-guinness", + "brand": "DEUS", + "flavor": "Guinness", + "note": "Тёмное пиво", + "strength": 2, + "cooling": false + }, + { + "id": "deus-peonies", + "brand": "DEUS", + "flavor": "Peonies", + "note": "Пионы", + "strength": 2, + "cooling": false + }, + { + "id": "deus-pumpkin-pie", + "brand": "DEUS", + "flavor": "Pumpkin Pie", + "note": "Тыквенный пирог с миндалём", + "strength": 2, + "cooling": false + }, + { + "id": "deus-violet-zephyr", + "brand": "DEUS", + "flavor": "Violet Zephyr", + "note": "Фиалковый зефир", + "strength": 2, + "cooling": false + }, + { + "id": "hupreme-guava", + "brand": "Hupreme", + "flavor": "Гуава", + "note": null, + "strength": 2, + "cooling": false + }, + { + "id": "hupreme-gooseberry", + "brand": "Hupreme", + "flavor": "Крыжовник", + "note": null, + "strength": 2, + "cooling": false + }, + { + "id": "hupreme-milk-tea", + "brand": "Hupreme", + "flavor": "Чай с молоком", + "note": null, + "strength": 2, + "cooling": false + }, + { + "id": "jent-blue-agave", + "brand": "JENT Alcohol Line", + "flavor": "Blue Agave", + "note": "«Текила Бум»", + "strength": 2, + "cooling": false + }, + { + "id": "jent-cuba-libre", + "brand": "JENT Alcohol Line", + "flavor": "Cuba Libre", + "note": null, + "strength": 2, + "cooling": false + }, + { + "id": "jent-havana", + "brand": "JENT Alcohol Line", + "flavor": "Havana 290", + "note": "Мохито", + "strength": 2, + "cooling": true + }, + { + "id": "joy-kiwichanga", + "brand": "Joy Kiwichanga", + "flavor": "Киви, фейхоа", + "note": null, + "strength": 2, + "cooling": false + }, + { + "id": "muassel-cinnamon-gum", + "brand": "Muassel", + "flavor": "Жвачка с корицей", + "note": null, + "strength": 3, + "cooling": false + }, + { + "id": "od-black-banano", + "brand": "Overdose", + "flavor": "Black Banano", + "note": "Амбровый банан", + "strength": 3, + "cooling": false + }, + { + "id": "od-frustyle", + "brand": "Overdose", + "flavor": "Frustyle", + "note": "Кактус-лайм", + "strength": 3, + "cooling": false + }, + { + "id": "od-gin-cucumber", + "brand": "Overdose", + "flavor": "Gin Cucumber", + "note": "Огуречный джин", + "strength": 3, + "cooling": false + }, + { + "id": "od-lost-futura", + "brand": "Overdose", + "flavor": "Lost Futura", + "note": "Мускусная вишня", + "strength": 3, + "cooling": false + }, + { + "id": "uc-pear-tarragon", + "brand": "Undercoal", + "flavor": "Груша, лимон и тархун", + "note": null, + "strength": 2, + "cooling": false + }, + { + "id": "uc-cactus-kiwi", + "brand": "Undercoal", + "flavor": "Кактус, киви и абсент", + "note": null, + "strength": 2, + "cooling": false + }, + { + "id": "uc-cranberry", + "brand": "Undercoal", + "flavor": "Клюква", + "note": null, + "strength": 2, + "cooling": false + }, + { + "id": "uc-coconut-shake", + "brand": "Undercoal", + "flavor": "Кокосовый шейк", + "note": null, + "strength": 2, + "cooling": false + }, + { + "id": "uc-mango-rose", + "brand": "Undercoal", + "flavor": "Манго, маракуйя, личи и роза", + "note": null, + "strength": 2, + "cooling": false + }, + { + "id": "uc-tropical-juice", + "brand": "Undercoal", + "flavor": "Тропический сок", + "note": null, + "strength": 2, + "cooling": false + }, + { + "id": "uc-milky-oolong", + "brand": "Undercoal", + "flavor": "Чай «Молочный улун»", + "note": null, + "strength": 2, + "cooling": false + }, + { + "id": "uc-caribbean-rum", + "brand": "Undercoal", + "flavor": "Caribbean Rum", + "note": null, + "strength": 2, + "cooling": false + }, + { + "id": "uc-cinnamon-roll", + "brand": "Undercoal", + "flavor": "Cinnamon Roll", + "note": null, + "strength": 2, + "cooling": false + }, + { + "id": "uc-cookie", + "brand": "Undercoal", + "flavor": "Cookie", + "note": "Печенье", + "strength": 2, + "cooling": false + }, + { + "id": "uc-pinkman", + "brand": "Undercoal", + "flavor": "Pinkman", + "note": "Клубника, малина, грейпфрут", + "strength": 2, + "cooling": false + } +] diff --git a/crm/server.py b/crm/server.py new file mode 100644 index 0000000..282a8cd --- /dev/null +++ b/crm/server.py @@ -0,0 +1,2503 @@ +# -*- coding: utf-8 -*- +""" +НЕОН CRM — HTTP-сервер (stdlib) + REST API поверх PostgreSQL. + +Публичные эндпоинты (для сайта neon-bar86.ru): + POST /api/orders — новый заказ из корзины сайта + GET /api/stoplist — стоп-лист (позиции меню и бренды табака) + GET /api/catalog — позиции, добавленные владельцем («новинки») + GET /api/track/<код> — статус заказа по коду отслеживания + GET /api/healthz — проверка живости для Docker + +Остальное требует сессию (X-Auth-Token из POST /api/login). +Запуск: python server.py (порт из CRM_PORT, по умолчанию 47613) +""" + +import glob +import io +import ipaddress +import json +import os +import re +import sys +import threading +import time +from collections import defaultdict, deque +from datetime import date, datetime, time as dtime, timedelta, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, unquote, urlsplit + +import notes_api +import storage +from storage import clean_guest_text # гигиена текста — общая с notes_api (NF-14) + +ROOT = Path(__file__).resolve().parent +PORT = int(os.environ.get("CRM_PORT", "47613")) + +ORDER_RE = re.compile(r"^/api/orders/(\d+)$") +USER_RE = re.compile(r"^/api/users/([A-Za-z0-9_.-]+)$") +MIX_RE = re.compile(r"^/api/mixes/([A-Za-z0-9_.-]+)$") +MIX_SITE_RE = re.compile(r"^/api/mixes-site/([A-Za-z0-9_.-]+)$") +MENU_PHOTO_RE = re.compile(r"^/api/menu/([A-Za-z0-9_.:%-]+)/photo$") +TRACK_RE = re.compile(r"^/api/track/([A-Za-z0-9]+)$") +TRACK_CANCEL_RE = re.compile(r"^/api/track/([A-Za-z0-9]+)/cancel$") +# сколько минут гость может отменить случайный заказ по коду (пока «Новый») +GUEST_CANCEL_MINUTES = 10 +HALL_RES_RE = re.compile(r"^/api/halls/reservations/(\d+)$") +HALL_DEF_RE = re.compile(r"^/api/halls/([^/]+)$") +RESERVE_TIME_RE = re.compile(r"^([01]?\d|2[0-3]):([0-5]\d)$") +# id позиции меню и id вкуса табака — ASCII-слаги сайта; unquote ниже оставлен +# на случай кириллических id (например, старых брендов в ссылках) +MENU_RE = re.compile(r"^/api/menu/([A-Za-z0-9_.:%-]+)$") +TOBACCO_RE = re.compile(r"^/api/tobacco/([^/]+)$") +GAME_RE = re.compile(r"^/api/games/(\d+)$") +GAME_PHOTO_RE = re.compile(r"^/api/games/(\d+)/photo$") +GAME_KINDS = ("ps5", "board") +# логотипы настолок — ключи иконок каталога сайта (BoardGamesPage.iconMap) +GAME_ICONS = ("eye", "skull", "spade", "crown", "dices", "grid", "circle", "hash", + "landmark", "layout", "brain", "puzzle", "users", "club", "ghost", + "castle", "heart", "target") +HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$") +ORDER_NUM_RESET_RE = re.compile(r"^/api/orders/numbering/reset$") +# доски заметок — маршруты и хендлеры в notes_api.py +BOARDS_RE = notes_api.BOARD_ID_RE +BOARD_NOTES_RE = notes_api.BOARD_NOTES_RE +BOARD_ACTIVITY_RE = notes_api.BOARD_ACTIVITY_RE +NOTE_RE = notes_api.NOTE_ID_RE + +# Роли с правами администратора (владелец умеет всё то же и больше) +ADMIN_ROLES = ("owner", "admin") +STAFF_ROLES = ("owner", "admin", "waiter", "hookah") +# роли смены: выходят на смену и управляют заказами только на смене +SHIFT_ROLES = ("waiter", "hookah") +# миксами для сайта ведают владелец и кальянщик (полный цикл: просмотр, +# создание, удаление) +MIX_ROLES = ("owner", "hookah") +CREATABLE_ROLES = ("waiter", "hookah", "admin") # кого владелец может создавать +DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") +LOGIN_RE = re.compile(r"^[A-Za-z0-9_.-]{3,40}$") +PHOTO_MAX_BYTES = 6 * 1024 * 1024 + + +def image_extension(data: bytes) -> str | None: + """Определяем реальный формат по сигнатуре — доверять клиенту нельзя.""" + if len(data) < 12: + return None + if data[:3] == b"\xff\xd8\xff": + return "jpg" + if data[:8] == b"\x89PNG\r\n\x1a\n": + return "png" + if data[:4] == b"RIFF" and data[8:12] == b"WEBP": + return "webp" + if data[4:8] == b"ftyp": # ISO-BMFF: avif/heic + return "avif" + if data[:3] == b"GIF": + return "gif" + return None + + +def _xlsx_bytes(sheets: list[dict]) -> bytes: + """Простой xlsx на stdlib (zip + XML), без внешних зависимостей и без + оформления (по решению владельца): sheets — [{name, rows}]; строка — + {cells: [...]}. Первая строка каждого листа — жирная шапка.""" + import zipfile + from xml.sax.saxutils import escape + + def col_letter(n: int) -> str: # 1 -> A, 27 -> AA + s = "" + while n: + n, r = divmod(n - 1, 26) + s = chr(65 + r) + s + return s + + def cell(ref: str, value, bold: bool) -> str: + if value is None or value == "": + return "" + style = ' s="1"' if bold else "" + if isinstance(value, (int, float)) and not isinstance(value, bool): + return f'{value}' + text = str(value) + # защита от formula injection: текст гостя, начинающийся с =+-@, + # Excel исполнил бы как формулу при открытии отчёта — гасим апострофом + if text[0] in "=+-@": + text = "'" + text + return f'{escape(text)}' + + sheet_parts: list[dict] = [] + for sheet in sheets: + rows = sheet["rows"] + n_cols = max((len(r.get("cells", [])) for r in rows), default=1) + cols = "".join( + f'' + for i in range(1, n_cols + 1) + ) + data_rows: list[str] = [] + for ri, row in enumerate(rows, 1): + cells = "".join( + cell(f"{col_letter(ci)}{ri}", v, ri == 1) + for ci, v in enumerate(row.get("cells", []), 1) + ) + data_rows.append(f'{cells}') + sheet_parts.append({ + "name": sheet["name"], + "xml": ('' + '' + f"{cols}" + f'{"".join(data_rows)}'), + }) + + ns_main = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" + ns_rel = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" + ns_pack = "http://schemas.openxmlformats.org/package/2006/relationships" + + workbook = ( + '' + f'' + + "".join( + f'' + for i, s in enumerate(sheet_parts) + ) + + "" + ) + workbook_rels = ( + '' + f'' + + "".join( + f'' + for i in range(len(sheet_parts)) + ) + + f'' + + "" + ) + content_types = ( + '' + '' + '' + '' + '' + + "".join( + f'' + for i in range(len(sheet_parts)) + ) + + '' + + "" + ) + root_rels = ( + '' + f'' + f'' + "" + ) + styles = ( + '' + f'' + '' + '' + '' + '' + '' + '' + '' + '' + "" + ) + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("[Content_Types].xml", content_types) + z.writestr("_rels/.rels", root_rels) + z.writestr("xl/workbook.xml", workbook) + z.writestr("xl/_rels/workbook.xml.rels", workbook_rels) + z.writestr("xl/styles.xml", styles) + for i, s in enumerate(sheet_parts): + z.writestr(f"xl/worksheets/sheet{i + 1}.xml", s["xml"]) + return buf.getvalue() + + +# удобные конструкторы строк для листов +def _xl_title(text): return {"style": "title", "cells": [text]} +def _xl_header(*cells): return {"style": "header", "cells": list(cells)} +def _xl_row(*cells): return {"style": "row", "cells": list(cells)} +def _xl_total(*cells): return {"style": "total", "cells": list(cells)} +_XL_SPACER = {"style": "spacer", "cells": []} + + +def _summary_sheet(report: dict, staff: list[dict], period: dict) -> dict: + """Лист «Сводка»: показатели, источники с итогом «Всего (сайт + зал)».""" + t = report["totals"] + dur_min = (round(t["avg_duration_seconds"] / 60, 1) + if t.get("avg_duration_seconds") is not None else None) + rows = [ + _xl_title(f"Отчёт по заказам — лаундж НЕОН · {period['from']} — {period['to']}"), + _XL_SPACER, + _xl_header("Показатель", "Значение"), + _xl_row("Выручка (выданные заказы)", t["revenue"]), + _xl_row("Заказов всего (все статусы)", t["orders"]), + _xl_row("Выдано", t["done"]), + _xl_row("Средний чек", t["avg_check"]), + _xl_row("Отменено", t["cancelled"]), + _xl_row("Сумма отменённых", t["cancelled_amount"]), + _xl_row("Средняя подача, минут", dur_min), + _XL_SPACER, + _xl_header("Источник", "Заказов", "Выручка"), + ] + src = report.get("by_source", {}) + orders_sum = 0 + revenue_sum = 0 + for key, label in (("site", "Сайт"), ("hall", "Из зала")): + s = src.get(key, {"orders": 0, "revenue": 0}) + orders_sum += s["orders"] + revenue_sum += s["revenue"] + rows.append(_xl_row(label, s["orders"], s["revenue"])) + rows.append(_xl_total("Всего (сайт + зал)", orders_sum, revenue_sum)) + return {"name": "Сводка", "rows": rows, "money": [1, 2], "freeze": False} + + +def _days_sheet(report: dict) -> dict: + rows = [_xl_header("День", "Заказов", "Выручка", "Отменено")] + rows += [_xl_row(d["day"], d["orders"], d["revenue"], d["cancelled"]) + for d in report["by_day"]] + return {"name": "По дням", "rows": rows, "money": [2], "freeze": True} + + +def _hours_sheet(report: dict) -> dict: + rows = [_xl_header("Час", "Заказов", "Выручка")] + rows += [_xl_row(f"{h['hour']:02d}:00–{h['hour'] + 1:02d}:00", h["orders"], h["revenue"]) + for h in report.get("by_hour", []) if h["orders"] or h["revenue"]] + if len(rows) == 1: + rows.append(_xl_row("нет заказов", None, None)) + return {"name": "По часам", "rows": rows, "money": [2], "freeze": True} + + +def _top_sheet(report: dict) -> dict: + rows = [_xl_header("№", "Позиция", "Штук", "Выручка")] + rows += [_xl_row(i + 1, item["name"], item["qty"], item["revenue"]) + for i, item in enumerate(report["top_items"])] + if len(rows) == 1: + rows.append(_xl_row("нет продаж", None, None, None)) + return {"name": "Топ позиций", "rows": rows, "money": [3], "freeze": True} + + +def _tables_sheet(report: dict) -> dict: + rows = [_xl_header("Стол / зона", "Заказов", "Выручка")] + rows += [_xl_row(r["table"], r["orders"], r["revenue"]) + for r in report.get("top_tables", [])] + if len(rows) == 1: + rows.append(_xl_row("нет заказов", None, None)) + return {"name": "Столы", "rows": rows, "money": [2], "freeze": True} + + +def _staff_sheet(staff: list[dict]) -> dict: + role_label = {"owner": "Владелец", "admin": "Администратор", + "waiter": "Официант", "hookah": "Кальянщик"} + rows = [_xl_header("Сотрудник", "Роль", "Заказов", "Выдано", "Отменено", "Выручка", + "Средний чек", "Средняя подача, мин", "С сайта", "Из зала", + "Смен", "Часов")] + for s in staff: + rows.append(_xl_row( + s["name"], role_label.get(s["role"], s["role"]), + s["orders_total"], s["orders_done"], s["orders_cancelled"], + s["revenue"], + s["avg_check"] if s["avg_check"] is not None else None, + (round(s["avg_duration_seconds"] / 60, 1) + if s["avg_duration_seconds"] is not None else None), + s["site_orders"], s["hall_orders"], + s["shifts"], round(s["hours_seconds"] / 3600, 1), + )) + return {"name": "Сотрудники", "rows": rows, + "money": [5, 6], "freeze": True} + + +def _day_sheet(d_report: dict, d_staff: list[dict], day) -> dict: + """Лист одного дня для режима «по дням»: метрики, источники с итогом, + топ позиций и сотрудники этого дня.""" + rows = [_xl_title(day.strftime("%d.%m.%Y")), _XL_SPACER] + t = d_report["totals"] + dur_min = (round(t["avg_duration_seconds"] / 60, 1) + if t.get("avg_duration_seconds") is not None else None) + rows += [ + _xl_header("Показатель", "Значение"), + _xl_row("Выручка", t["revenue"]), + _xl_row("Заказов всего", t["orders"]), + _xl_row("Выдано", t["done"]), + _xl_row("Средний чек", t["avg_check"]), + _xl_row("Отменено", t["cancelled"]), + _xl_row("Средняя подача, минут", dur_min), + _XL_SPACER, + _xl_header("Источник", "Заказов", "Выручка"), + ] + src = d_report.get("by_source", {}) + orders_sum = 0 + revenue_sum = 0 + for key, label in (("site", "Сайт"), ("hall", "Из зала")): + s = src.get(key, {"orders": 0, "revenue": 0}) + orders_sum += s["orders"] + revenue_sum += s["revenue"] + rows.append(_xl_row(label, s["orders"], s["revenue"])) + rows.append(_xl_total("Всего (сайт + зал)", orders_sum, revenue_sum)) + rows.append(_XL_SPACER) + rows.append(_xl_header("Позиция", "Штук", "Выручка")) + if d_report["top_items"]: + rows += [_xl_row(i["name"], i["qty"], i["revenue"]) for i in d_report["top_items"]] + else: + rows.append(_xl_row("нет продаж", None, None)) + rows.append(_XL_SPACER) + role_label = {"owner": "Владелец", "admin": "Администратор", + "waiter": "Официант", "hookah": "Кальянщик"} + rows.append(_xl_header("Сотрудник", "Роль", "Заказов", "Выдано", "Выручка", "Смен", "Часов")) + staff_rows = [ + _xl_row(s["name"], role_label.get(s["role"], s["role"]), + s["orders_total"], s["orders_done"], s["revenue"], + s["shifts"], round(s["hours_seconds"] / 3600, 1)) + for s in d_staff + ] + rows += staff_rows if staff_rows else [_xl_row("нет данных", None, None, None, None, None, None)] + return {"name": day.strftime("%d.%m"), "rows": rows, "money": [1, 2], "freeze": False} + + +def _report_xlsx(report: dict, staff: list[dict], period: dict) -> list[dict]: + """Листы обычного Excel-отчёта.""" + return [ + _summary_sheet(report, staff, period), + _days_sheet(report), + _hours_sheet(report), + _top_sheet(report), + _tables_sheet(report), + _staff_sheet(staff), + ] + + +# ---------------------------------------------------------------- rate limit + +def _is_trusted_peer(ip: str) -> bool: + """Пиры, чьему X-Forwarded-For можно верить: loopback, приватные подсети + (docker-сеть, LAN с реверс-прокси) и unspecified. Публичному клиенту XFF + не доверяем — он мог подделать заголовок.""" + try: + addr = ipaddress.ip_address(ip) + except ValueError: + return False + return addr.is_loopback or addr.is_private or addr.is_unspecified + + +class RateLimiter: + """Скользящий лимит на ключ (обычно IP). Страница отслеживания опрашивает + сервер чаще заказов, поэтому у неё свой, мягкий лимит.""" + + window = 300 + limit = 30 + + def __init__(self, window: int | None = None, limit: int | None = None) -> None: + if window is not None: + self.window = window + if limit is not None: + self.limit = limit + self._hits: dict[str, deque[float]] = defaultdict(deque) + self._lock = threading.Lock() + + def allow(self, key: str) -> bool: + now = time.monotonic() + with self._lock: + q = self._hits[key] + while q and q[0] < now - self.window: + q.popleft() + if len(q) >= self.limit: + return False + q.append(now) + if len(self._hits) > 10_000: # не копим ключи вечных ботов + stale = now - self.window + # удаляем на месте: переприсваивание dict ломало контракт + # defaultdict, и каждый новый ключ падал с KeyError навсегда (F-5) + for k in [k for k, v in self._hits.items() if not v or v[-1] <= stale]: + del self._hits[k] + return True + + +limiter = RateLimiter() +track_limiter = RateLimiter(window=60, limit=90) # опрос статуса раз в 15 с — с запасом +# публичные GET каталога/стоп-листа/залов без лимита выедали пул БД при флуде +# (NF-04): сайт опрашивает их раз в минуту-десять, 120/мин с IP — с запасом. +# /api/healthz НЕ лимитируем: docker-healthcheck стучится каждые 30 с. +public_get_limiter = RateLimiter(window=60, limit=120) +# глобальный тормоз на перебор кодов отслеживания (F-8): 31^6 комбинаций при +# 90/мин с одного IP перебираются долго, но ботнет с ротацией IP — нет. +# 5000 просмотров в час на ВЕСЬ сайт с запасом покрывает живых гостей. +track_global_limiter = RateLimiter(window=3600, limit=5000) +# глобальный тормоз на фальшивые заказы: даже распределённый флуд не должен +# заваливать бар и БД. По умолчанию 200 заказов за 5 минут на ВЕСЬ сайт — +# живой пятничный поток в это не попадает, атака упирается в 503. +FLOOD_WINDOW = 300 +FLOOD_LIMIT = int(os.environ.get("CRM_ORDERS_FLOOD_LIMIT", "200") or "200") +flood_limiter = RateLimiter(window=FLOOD_WINDOW, limit=FLOOD_LIMIT) + + +# ----------------------------- расписание бара ----------------------------- +# Бар живёт по фиксированному UTC+5 (без перевода часов). Онлайн-заказы +# принимаются, когда бар открыт, и «под открытие»: не раньше, чем за час до +# 19:00. Днём, пока бар закрыт, заказать нельзя: персонал не получает +# заказов на закрытую смену, а гость не ждёт перед пустым залом. + +BAR_TZ = timezone(timedelta(hours=5)) +BAR_OPEN_HOUR = 19 # все сессии начинаются в 19:00 барного времени +PREORDER_WINDOW = timedelta(hours=1) +BAR_CLOSED_WEEKDAY = 0 # понедельник — сессий не открываем +# до скольки утра работает «хвост» сессии, по дню недели (Python: 0 = пн). +# Хвост после полуночи относится к сессии предыдущего дня. +BAR_TAIL_HOURS = { + 0: 1, # пн: только хвост воскресенья до 01:00 + 1: 0, # вт: ночного хвоста нет — в пн бар не работал + 2: 1, # ср: хвост вторника до 01:00 + 3: 1, # чт + 4: 1, # пт: хвост четверга до 01:00 + 5: 3, # сб: хвост пятницы до 03:00 + 6: 3, # вс: хвост субботы до 03:00 +} +WEEKDAY_WHEN = ("в понедельник", "во вторник", "в среду", "в четверг", "в пятницу", "в субботу", "в воскресенье") + + +def bar_now() -> datetime: + """Текущее барное время (UTC+5), независимо от пояса сервера.""" + return datetime.now(BAR_TZ) + + +def bar_is_open(now: datetime) -> bool: + """Открыт ли бар в момент now (передавать барное время).""" + hour = now.hour + now.minute / 60 + if hour >= BAR_OPEN_HOUR: + return now.weekday() != BAR_CLOSED_WEEKDAY # в пн вечерней сессии нет + return hour < BAR_TAIL_HOURS[now.weekday()] + + +def next_bar_opening(now: datetime) -> datetime | None: + """Ближайшее открытие (19:00) строго после now, кроме понедельника.""" + base = now.replace(hour=BAR_OPEN_HOUR, minute=0, second=0, microsecond=0) + for d in range(8): + cand = base + timedelta(days=d) + if cand.weekday() == BAR_CLOSED_WEEKDAY: + continue + if cand > now: + return cand + return None + + +def bar_orders_gate() -> dict | None: + """None — заказы принимаем; dict — почему нельзя (бар закрыт). + + Разрешаем заказ, когда бар открыт или до открытия осталось не больше часа + («заказ к открытию»). Днём, пока бар закрыт, — отказываем с понятным + текстом: когда снова откроемся.""" + now = bar_now() + if bar_is_open(now): + return None + opening = next_bar_opening(now) + if opening is None: + return None # расписание всегда найдёт открытие; не блокируем на всякий случай + if opening - now <= PREORDER_WINDOW: + return None + if opening.date() == now.date(): + when = "сегодня" + elif opening.date() == now.date() + timedelta(days=1): + when = "завтра" + else: + when = WEEKDAY_WHEN[opening.weekday()] + return { + "code": "bar_closed", + # "error" — человекочитаемый текст: его показывает корзина сайта + "error": ( + f"Бар закрыт — онлайн-заказы открываются за час до работы: {when} в " + f"{BAR_OPEN_HOUR:02d}:00. Позвоните нам: +7 (912) 901-72-17" + ), + "opens_at": opening.isoformat(), + } + + +# Гигиена свободного текста гостя (clean_guest_text) живёт в storage.py — +# её применяют и публичный заказ, и ручной заказ персонала (NF-14). + + +class PayloadDedup: + """Короткая память «такой заказ уже был» (один IP + тот же стол/состав). + Гасит случайный повтор (двойной клик, повторная отправка формы) и + простейшие спам-циклы с фиксированным payload — раньше, чем заказ + попадёт в канбан персонала.""" + + window = 60 + + def __init__(self, window: int | None = None) -> None: + if window is not None: + self.window = window + self._seen: dict[str, float] = {} + self._lock = threading.Lock() + + def seen(self, key: str) -> bool: + now = time.monotonic() + with self._lock: + last = self._seen.get(key) + self._seen[key] = now + if len(self._seen) > 10_000: + stale = now - self.window + self._seen = {k: v for k, v in self._seen.items() if v > stale} + return last is not None and now - last < self.window + + +order_dedup = PayloadDedup() + + +class _CatalogPatchAbort(Exception): + """NF-07: мульти-полевой PATCH каталога выполняется в одной транзакции; + этот отказ (seed/not found) откатывает уже применённые ops и уносит + готовый ответ для клиента.""" + + def __init__(self, payload: dict, code: int): + super().__init__(payload.get("error") or "patch aborted") + self.payload = payload + self.code = code + + +class LoginLockout: + """Анти-брутфорс одной учётки (F-2) без DoS по доступности (NF-05): + каждая недавняя неудача удваивает паузу перед следующей попыткой входа + в этот логин — 2, 4, 8… секунд до потолка (cap). Стоимость перебора + растёт экспоненциально, но корректный пароль входит после паузы: чужие + промахи больше не выключают вход сотруднику на 15 минут, как раньше. + + Пауза считается ДО проверки пароля и зависит только от истории неудач + этого логина — ответы для существующих и несуществующих логинов + неразличимы (внутри login_user ещё и dummy-хэш для отсутствующих). + Успешный вход сбрасывает счётчик; состояние в памяти — рестарт снимает + задержки, но лимитеры по IP продолжают работать.""" + + window = 900 # окно учёта неудач — 15 минут (как у прежнего жёсткого лока) + base = 2.0 # первая неудача в окне -> +2 с к следующей попытке + cap = 30.0 # потолок паузы одной попытки + + def __init__(self) -> None: + self._fails: dict[str, list[float]] = {} + self._lock = threading.Lock() + + def _recent(self, login: str, now: float) -> list[float]: + """Свежие неудачи логина + чистка вечных ключей (NF-06) — та же + схема, что в RateLimiter._hits (F-5): при >10 000 логинов выметаем + опустевшие и протухшие, не ломая контракт defaultdict-подобного + доступа.""" + fails = [t for t in self._fails.get(login, []) if now - t < self.window] + self._fails[login] = fails + if len(self._fails) > 10_000: + stale = now - self.window + for k in [k for k, v in self._fails.items() if not v or v[-1] <= stale]: + del self._fails[k] + return fails + + def delay(self, login: str) -> float: + """Сколько секунд ждать перед попыткой входа (0 — можно сразу).""" + now = time.monotonic() + with self._lock: + fails = self._recent(login, now) + if not fails: + return 0.0 + return min(self.cap, self.base * (2 ** len(fails))) + + def fail(self, login: str) -> None: + now = time.monotonic() + with self._lock: + fails = self._recent(login, now) + fails.append(now) + + def reset(self, login: str) -> None: + with self._lock: + self._fails.pop(login, None) + + +login_lockout = LoginLockout() + + +# ---------------------------------------------------------------- handler + +class Handler(notes_api.NotesRoutes, BaseHTTPRequestHandler): + server_version = "NeonCRM/1.0" + protocol_version = "HTTP/1.1" + + # ---------------- служебное ---------------- + + def _drain_body(self): + """Гарантия чистоты keep-alive: если обработчик ответил, не прочитав + тело запроса (ранний 401/403/404), соединение закрывается — иначе + оставшееся тело сервер принял бы за следующий запрос (десинхронизация + потока, «Unsupported method (\'{"...}\')»). Читаем флаг, который ставят + _read_body/_read_binary фактическим чтением тела.""" + if getattr(self, "_body_consumed", False): + return + try: + length = int(self.headers.get("Content-Length") or 0) + except ValueError: + length = 0 + if length > 0: + self.close_connection = True + + def _json(self, obj, code=200, cors=False): + self._drain_body() + body = json.dumps(obj, ensure_ascii=False).encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + if cors: + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + try: + self.wfile.write(body) + except (BrokenPipeError, ConnectionResetError): + pass # клиент оборвал соединение — писать некуда, это не 500 + + def _read_body(self) -> dict | None: + try: + length = int(self.headers.get("Content-Length") or 0) + except ValueError: + return None + if length <= 0 or length > 1_000_000: + return None # слишком большое тело не читаем: _json закроет соединение + try: + data = json.loads(self.rfile.read(length).decode("utf-8")) + self._body_consumed = True + return data if isinstance(data, dict) else None + except (ValueError, UnicodeDecodeError): + self._body_consumed = True + return None + + def _read_binary(self, limit: int) -> bytes | None: + try: + length = int(self.headers.get("Content-Length") or 0) + except ValueError: + return None + if length <= 0 or length > limit: + return None # слишком большое тело не читаем: _json закроет соединение + data = self.rfile.read(length) + self._body_consumed = True + return data + + def _send_file(self, data: bytes, filename: str, ctype: str): + """Файл на скачивание (ASCII-имя): Excel-отчёты и т.п.""" + self._drain_body() # NF-13: непрочитанное тело ломает keep-alive + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Disposition", f'attachment; filename="{filename}"') + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.end_headers() + try: + self.wfile.write(data) + except (BrokenPipeError, ConnectionResetError): + pass + + def _auth(self) -> dict | None: + return storage.session_user(self.headers.get("X-Auth-Token", "")) + + def _client_ip(self) -> str: + # За nginx (сайт) все гости приходят с одного IP контейнера: берём + # реальный клиент из X-Forwarded-For — но ТОЛЬКО если соединение + # пришло от доверенного (приватного/loopback) пира (F-7). Прямой + # клиент из интернета может слать любой XFF: доверять ему нельзя, + # иначе ротацией заголовка сбрасываются лимитеры брутфорса. + forwarded = self.headers.get("X-Forwarded-For", "") + peer = str(self.client_address[0] or "") + if forwarded and _is_trusted_peer(peer): + first = forwarded.split(",")[0].strip() + if first: + return first + return peer or "?" + + def log_message(self, fmt, *args): + sys.stdout.write("%s - %s\n" % (self.address_string(), fmt % args)) + sys.stdout.flush() + + def log_error(self, fmt, *args): + self.log_message(fmt, *args) + + # ---------------- статика ---------------- + + def serve_static(self, path: str): + self._drain_body() # NF-13: GET со странным телом не должен рвать keep-alive + if path == "/": + path = "/index.html" + # F-3: сегменты подъёма и обратные слеши отклоняем ДО whitelist: + # whitelist проверяет сырой префикс, а Path.resolve() сворачивает + # ../ внутрь ROOT, из-за чего /js/../server.py проходил как «/js/…» + segments = path.lstrip("/").split("/") + if any(s in ("", ".", "..") or "\\" in s for s in segments): + self.send_error(404) + return + # whitelist: панель отдаёт только свои ассеты и медиа. Всё остальное + # (server.py, storage.py, seed/*.json, …) наружу не утекает. + allowed = ( + path == "/index.html" + or path.startswith("/js/") + or path.startswith("/css/") + or path.startswith("/media/") + ) + if not allowed: + self.send_error(404) + return + file_path = (ROOT / path.lstrip("/")).resolve() + # is_relative_to(ROOT) — защита в глубину поверх отказа от ../ + if not file_path.is_relative_to(ROOT) or not file_path.is_file(): + self.send_error(404) + return + + import mimetypes + + ctype = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream" + body = file_path.read_bytes() + self.send_response(200) + self.send_header("Content-Type", ctype + ("; charset=utf-8" if ctype.startswith("text/") else "")) + self.send_header("Content-Length", str(len(body))) + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("X-Frame-Options", "DENY") + self.send_header("Referrer-Policy", "same-origin") + # CSP панели: скрипты только свои (app.js внешний), стили — свои и + # Google Fonts; инлайн-скриптам в панели места нет, так что даже + # случайно пропущенный sink не исполнит внедрённый код + self.send_header( + "Content-Security-Policy", + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'" + " https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com;" + " img-src 'self' data:; connect-src 'self'; object-src 'none';" + " base-uri 'none'; frame-ancestors 'none'; form-action 'self'", + ) + if path.startswith("/css/") or path.startswith("/js/"): + self.send_header("Cache-Control", "no-cache") + else: + self.send_header("Cache-Control", "no-store") + self.end_headers() + try: + self.wfile.write(body) + except (BrokenPipeError, ConnectionResetError): + pass + + # ---------------- маршруты ---------------- + + def do_GET(self): + path = urlsplit(self.path).path + try: + # анонимный DoS-флуд публичными GET (NF-04) гасим до обращения к + # БД: catalog/stoplist/halls-names — самые «дорогие» запросы сайта + if path in ("/api/catalog", "/api/stoplist", "/api/halls/names") \ + and not public_get_limiter.allow("public:" + self._client_ip()): + self._json({"error": "too many requests"}, 429, cors=True) + return + if path == "/api/healthz": + with storage.db() as conn: + conn.execute("SELECT 1") + self._json({"ok": True}) + elif path == "/api/stoplist": + self._json(storage.stoplist(), cors=True) + elif path == "/api/catalog": + self._json(storage.custom_catalog(), cors=True) + elif path == "/api/orders": + if not self._require_auth(): + return + user = self._auth() + if user and user["role"] in SHIFT_ROLES: + # официант/кальянщик видят заказы (с телефоном и комментарием + # гостя) только во время смены — вне смены список закрыт (F-14) + if storage.get_open_shift(user["login"]) is None: + self._json({"error": "Смена не активна — список заказов доступен только на смене."}, 403) + return + self._json(storage.list_orders()) + elif path == "/api/menu": + if not self._require_auth(): + return + self._json(storage.list_menu()) + elif path == "/api/tobacco": + if not self._require_auth(): + return + self._json(storage.list_tobacco()) + elif path == "/api/mixes": + user = self._auth() + if not user: + self._json({"error": "unauthorized"}, 401) + elif user["role"] not in MIX_ROLES: + self._json({"error": "owner or hookah only"}, 403) + else: + self._json(storage.list_mixes()) + elif path == "/api/users": + user = self._auth() + if not user: + self._json({"error": "unauthorized"}, 401) + elif user["role"] != "owner": + self._json({"error": "owner only"}, 403) + else: + self._json(storage.list_users()) + elif path == "/api/reports/orders/excel": + user = self._auth() + if not user: + self._json({"error": "unauthorized"}, 401) + elif user["role"] != "owner": + self._json({"error": "owner only"}, 403) + else: + self._handle_report_excel() + elif path == "/api/reports/staff": + user = self._auth() + if not user: + self._json({"error": "unauthorized"}, 401) + elif user["role"] != "owner": + self._json({"error": "owner only"}, 403) + else: + self._handle_staff_report() + elif path == "/api/reports/orders": + user = self._auth() + if not user: + self._json({"error": "unauthorized"}, 401) + else: + self._handle_report(user) + elif path == "/api/shifts/current": + user = self._auth() + if not user: + self._json({"error": "unauthorized"}, 401) + return + if user["role"] in SHIFT_ROLES: + mine = storage.get_open_shift(user["login"]) + self._json({"on_shift": [mine] if mine else [], "staff": []}) + else: + on_shift = storage.on_shift() + busy = {r["login"] for r in on_shift} + staff = [{**u, "on_shift": u["login"] in busy} + for u in storage.staff_shift_roles()] + self._json({"on_shift": on_shift, "staff": staff}) + elif path == "/api/shifts/counts": + user = self._auth() + if not user: + self._json({"error": "unauthorized"}, 401) + elif user["role"] != "owner": + self._json({"error": "owner only"}, 403) + else: + self._json(storage.shift_counts()) + elif path == "/api/session": + user = self._auth() + self._json(user or {}, 200 if user else 401) + elif path == "/api/halls/names": + # публичный список активных столов/залов — корзина сайта + # строит из него выбор столика + self._json({"tables": storage.hall_names()}, cors=True) + elif path == "/api/halls": + if not self._require_auth(): + return + self._json(storage.hall_status()) + elif path == "/api/games": + if not self._require_auth(): + return + all_flag = (parse_qs(urlsplit(self.path).query).get("all") or ["0"])[0] == "1" + user = self._auth() + # корзину удалённых игр видим только владелец (админ — без редактирования) + include_deleted = bool(all_flag) and user and user["role"] == "owner" + self._json({"games": storage.games_list(include_deleted)}) + elif path == "/api/mixes/site": + user = self._auth() + if not user: + self._json({"error": "unauthorized"}, 401) + elif user["role"] not in MIX_ROLES: + self._json({"error": "owner or hookah only"}, 403) + else: + self._json({"site_mixes": storage.site_mixes_edited()}) + elif path == "/api/boards": + user = self._auth() + if not user: + self._json({"error": "unauthorized"}, 401) + else: + self._handle_boards_get(user) + elif BOARD_NOTES_RE.match(path): + user = self._auth() + if not user: + self._json({"error": "unauthorized"}, 401) + else: + self._handle_board_notes_get(user, int(BOARD_NOTES_RE.match(path).group(1))) + elif BOARD_ACTIVITY_RE.match(path): + user = self._auth() + if not user: + self._json({"error": "unauthorized"}, 401) + else: + self._handle_board_activity(user, int(BOARD_ACTIVITY_RE.match(path).group(1))) + elif TRACK_RE.match(path): + self._handle_track(TRACK_RE.match(path).group(1)) + elif path.startswith("/api/") or path == "/api": + # неизвестный API-путь — JSON 404, а не HTML-заглушка статики + self._json({"error": "not found"}, 404) + else: + self.serve_static(path) + except Exception: + self._server_error() + + def do_POST(self): + path = urlsplit(self.path).path + try: + if path == "/api/login": + self._handle_login() + elif path == "/api/orders": + self._handle_public_order() + elif ORDER_NUM_RESET_RE.match(path): + user = self._auth() + if not user: + self._json({"error": "unauthorized"}, 401) + return + if user["role"] != "owner": + self._json({"error": "owner only"}, 403) + return + result = storage.reset_order_numbering() + if not result: + self._json( + {"error": "сбросить нумерацию дважды за одну неделю нельзя —" + " подождите следующую неделю нумерации (вторник)"}, + 409, + ) + return + self._json(result, 201) + elif path == "/api/orders/manual": + user = self._auth() + if not user: + self._json({"error": "unauthorized"}, 401) + return + self._handle_manual_order(user) + elif path == "/api/logout": + if not self._require_auth(): + return + storage.logout_user(self.headers.get("X-Auth-Token", "")) + self._json({"ok": True}) + elif path == "/api/users": + user = self._auth() + if not user: + self._json({"error": "unauthorized"}, 401) + elif user["role"] != "owner": + self._json({"error": "owner only"}, 403) + else: + self._handle_user_create() + elif path == "/api/users/password": + user = self._auth() + if not user: + self._json({"error": "unauthorized"}, 401) + elif user["role"] != "owner": + self._json({"error": "owner only"}, 403) + else: + self._handle_user_password() + elif path == "/api/menu": + user = self._auth() + if not user: + self._json({"error": "unauthorized"}, 401) + elif user["role"] != "owner": + self._json({"error": "owner only"}, 403) + else: + self._handle_menu_create() + elif path == "/api/tobacco": + user = self._auth() + if not user: + self._json({"error": "unauthorized"}, 401) + elif user["role"] != "owner": + self._json({"error": "owner only"}, 403) + else: + self._handle_tobacco_create() + elif path == "/api/mixes": + user = self._auth() + if not user: + self._json({"error": "unauthorized"}, 401) + elif user["role"] not in MIX_ROLES: + self._json({"error": "owner or hookah only"}, 403) + else: + self._handle_mix_create() + elif MENU_PHOTO_RE.match(path): + user = self._auth() + if not user: + self._json({"error": "unauthorized"}, 401) + elif user["role"] != "owner": + self._json({"error": "owner only"}, 403) + else: + self._handle_menu_photo(unquote(MENU_PHOTO_RE.match(path).group(1))) + elif GAME_PHOTO_RE.match(path): + user = self._auth() + if not user: + self._json({"error": "unauthorized"}, 401) + return + # правка: библиотеку игр ведёт только владелец + # (админ видит раздел, официант и кальянщик его не видят) + if user["role"] != "owner": + self._json({"error": "owner only"}, 403) + return + self._handle_game_photo(int(GAME_PHOTO_RE.match(path).group(1))) + elif path == "/api/games": + user = self._auth() + if not user: + self._json({"error": "unauthorized"}, 401) + elif user["role"] != "owner": + self._json({"error": "owner only"}, 403) + else: + self._handle_game_create(user) + elif path == "/api/boards": + user = self._auth() + if not user: + self._json({"error": "unauthorized"}, 401) + else: + self._handle_board_create(user) + elif BOARD_NOTES_RE.match(path): + user = self._auth() + if not user: + self._json({"error": "unauthorized"}, 401) + else: + self._handle_note_create(user, int(BOARD_NOTES_RE.match(path).group(1))) + elif path in ("/api/shifts/clock-in", "/api/shifts/clock-out"): + user = self._auth() + if not user: + self._json({"error": "unauthorized"}, 401) + return + if user["role"] not in SHIFT_ROLES: + self._json({"error": "смены ведутся для официантов и кальянщиков"}, 400) + return + if path.endswith("clock-in"): + shift, error = storage.clock_in(user["login"]) + self._json(shift or {"error": error}, 201 if shift else 400) + else: + shift = storage.clock_out(user["login"]) + self._json(shift or {"error": "активной смены нет"}, 200 if shift else 400) + elif path == "/api/shifts/manage": + user = self._auth() + if not user: + self._json({"error": "unauthorized"}, 401) + return + if user["role"] not in ADMIN_ROLES: + self._json({"error": "admin only"}, 403) + return + self._handle_shift_manage() + elif path == "/api/halls": + user = self._auth() + if not user: + self._json({"error": "unauthorized"}, 401) + elif user["role"] != "owner": + self._json({"error": "owner only"}, 403) + else: + self._handle_hall_create() + elif path == "/api/halls/manage": + user = self._auth() + if not user: + self._json({"error": "unauthorized"}, 401) + return + if user["role"] not in ADMIN_ROLES: + self._json({"error": "admin only"}, 403) + return + self._handle_hall_manage(user) + elif path == "/api/halls/reserve": + user = self._auth() + if not user: + self._json({"error": "unauthorized"}, 401) + return + if user["role"] not in ADMIN_ROLES: + self._json({"error": "admin only"}, 403) + return + self._handle_hall_reserve(user) + elif TRACK_CANCEL_RE.match(path): + self._handle_track_cancel(TRACK_CANCEL_RE.match(path).group(1)) + else: + self._json({"error": "not found"}, 404) + except Exception: + self._server_error() + + def do_PATCH(self): + path = urlsplit(self.path).path + try: + user = self._auth() + if not user: + self._json({"error": "unauthorized"}, 401) + return + + if path == "/api/session/password": + self._handle_password_change(user) + return + + m = ORDER_RE.match(path) + if m: + self._handle_order_patch(int(m.group(1))) + return + + m = MENU_RE.match(path) + if m: + self._handle_catalog_patch("menu", unquote(m.group(1)), user) + return + + m = TOBACCO_RE.match(path) + if m: + self._handle_catalog_patch("tobacco", unquote(m.group(1)), user) + return + + m = MIX_RE.match(path) + if m: + # миксы ведут владелец и кальянщик — те же роли, что на создании + if user["role"] not in MIX_ROLES: + self._json({"error": "owner or hookah only"}, 403) + return + self._handle_mix_patch(m.group(1)) + return + + m = MIX_SITE_RE.match(path) + if m: + # витринные миксы сайта правит только владелец + if user["role"] != "owner": + self._json({"error": "owner only"}, 403) + return + self._handle_site_mix_patch(m.group(1)) + return + + m = BOARDS_RE.match(path) + if m: + self._handle_board_patch(user, int(m.group(1))) + return + + m = NOTE_RE.match(path) + if m: + self._handle_note_patch(user, int(m.group(1))) + return + + m = GAME_RE.match(path) + if m: + # правка: библиотеку игр редактирует только владелец + if user["role"] != "owner": + self._json({"error": "owner only"}, 403) + return + self._handle_game_patch(user, int(m.group(1))) + return + + self._json({"error": "not found"}, 404) + except Exception: + self._server_error() + + def do_DELETE(self): + path = urlsplit(self.path).path + try: + user = self._auth() + if not user: + self._json({"error": "unauthorized"}, 401) + return + + def owner_only(): + if user["role"] != "owner": + self._json({"error": "owner only"}, 403) + return False + return True + + m = USER_RE.match(path) + if m: + if not owner_only(): + return + removed = storage.delete_user(unquote(m.group(1))) + if not removed: + self._json({"error": "нельзя удалить: пользователь не найден или это владелец"}, 400) + return + self._json({"ok": True}) + return + + m = MENU_RE.match(path) + if m: + if not owner_only(): + return + item_id = unquote(m.group(1)) + if (parse_qs(urlsplit(self.path).query).get("purge") or ["0"])[0] == "1": + # безвозвратное удаление: только кастомные позиции — + # стартовые позиции сайта сид пересоздаёт при рестарте + result = storage.purge_menu_item(item_id) + if result == "seed": + self._json({ + "error": "стартовые позиции сайта удалить навсегда нельзя —" + " при перезапуске их восстановит сид;" + " доступно только обычное удаление", + }, 400) + return + if result is False: + self._json({"error": "not found"}, 404) + return + self._json({"ok": True, "purged": item_id}) + return + updated = storage.set_menu_deleted(item_id, True) + self._json(updated or {"error": "not found"}, 200 if updated else 404) + return + + m = TOBACCO_RE.match(path) + if m: + if not owner_only(): + return + flavor_id = unquote(m.group(1)) + if (parse_qs(urlsplit(self.path).query).get("purge") or ["0"])[0] == "1": + # безвозвратное удаление: только кастомные вкусы — + # стартовые вкусы сайта сид пересоздаёт при рестарте + result = storage.purge_tobacco_item(flavor_id) + if result == "seed": + self._json({ + "error": "стартовые вкусы сайта удалить навсегда нельзя —" + " при перезапуске их восстановит сид;" + " доступно только обычное удаление", + }, 400) + return + if result is False: + self._json({"error": "not found"}, 404) + return + self._json({"ok": True, "purged": flavor_id}) + return + updated = storage.set_tobacco_deleted(flavor_id, True) + self._json(updated or {"error": "not found"}, 200 if updated else 404) + return + + m = MIX_RE.match(path) + if m: + # правка: миксы ведут владелец и кальянщик + if user["role"] not in MIX_ROLES: + self._json({"error": "owner or hookah only"}, 403) + return + mix_id = m.group(1) + if (parse_qs(urlsplit(self.path).query).get("purge") or ["0"])[0] == "1": + # безвозвратное удаление — только владелец; в mix_recipes + # только миксы владельца, сид их не восстанавливает + if user["role"] != "owner": + self._json({"error": "owner only"}, 403) + return + result = storage.purge_mix_item(mix_id) + if result is False: + self._json({"error": "not found"}, 404) + return + self._json({"ok": True, "purged": mix_id}) + return + updated = storage.set_mix_deleted(mix_id, True) + self._json(updated or {"error": "not found"}, 200 if updated else 404) + return + + m = MIX_SITE_RE.match(path) + if m: + # сброс правки витринного микса — вернуть исходный вид + if user["role"] != "owner": + self._json({"error": "owner only"}, 403) + return + if not storage.reset_site_mix(m.group(1)): + self._json({"error": "у этого микса нет правок — он в исходном виде"}, 400) + return + self._json({"ok": True, "reset": m.group(1)}) + return + + m = BOARDS_RE.match(path) + if m: + self._handle_board_delete(user, int(m.group(1))) + return + + m = HALL_RES_RE.match(path) + if m: + if user["role"] not in ADMIN_ROLES: + self._json({"error": "admin only"}, 403) + return + self._handle_hall_reserve_cancel(user, int(m.group(1))) + return + + m = HALL_DEF_RE.match(path) + if m: + if not owner_only(): + return + if not storage.hall_delete(unquote(m.group(1)).strip()): + self._json({"error": "стол или зал не найден"}, 404) + return + self._json({"ok": True}) + return + + m = GAME_RE.match(path) + if m: + # правка: библиотеку игр удаляет только владелец + if user["role"] != "owner": + self._json({"error": "owner only"}, 403) + return + self._handle_game_delete(int(m.group(1))) + return + + m = NOTE_RE.match(path) + if m: + self._handle_note_delete(user, int(m.group(1))) + return + + self._json({"error": "not found"}, 404) + except Exception: + self._server_error() + + def do_HEAD(self): + # мониторинги часто стучатся HEAD-ом: отдаём тот же вердикт, что и GET + self._drain_body() # NF-13 + path = urlsplit(self.path).path + if path != "/api/healthz": + self.send_error(404) + return + try: + with storage.db() as conn: + conn.execute("SELECT 1") + self.send_response(200) + except Exception: + self.send_response(500) + self.send_header("Content-Length", "0") + self.end_headers() + + def do_OPTIONS(self): + self._drain_body() # NF-13 + self.send_response(204) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.send_header("Content-Length", "0") + self.end_headers() + + # ---------------- обработчики ---------------- + + def _require_auth(self) -> bool: + if self._auth(): + return True + self._json({"error": "unauthorized"}, 401) + return False + + def _server_error(self): + import traceback + + exc = sys.exc_info()[1] + # sqlite-режим не реализует часть разделов (F-11): отвечаем честным + # 501 с текстом вместо 500 «internal error» + if isinstance(exc, storage.PostgresOnlyFeature): + self._json({"error": str(exc)}, 501) + return + traceback.print_exc() + try: + self._json({"error": "internal error"}, 500) + except Exception: + pass + + def _handle_login(self): + body = self._read_body() or {} + login = str(body.get("login") or "")[:60] + password = str(body.get("password") or "") + if not login or not password: + self._json({"error": "login and password required"}, 400) + return + # пауза от брутфорса: не больше 30 попыток входа с IP за 5 минут + if not limiter.allow("login:" + self._client_ip()): + self._json({"error": "too many attempts, wait a minute"}, 429) + return + # прогрессивная задержка перебора конкретной учётки (NF-05): чек ДО + # проверки пароля — ждать приходится и промаху, и попаданию, поэтому + # перебор дорожает экспоненциально, а сотрудник с верным паролем + # входит (раньше 5 чужих промахов закрывали логин на 15 минут). + # Задержка зависит только от истории неудач логина — существующие и + # несуществующие логины неотличимы. + pause = login_lockout.delay(login) + if pause > 0: + time.sleep(pause) + session = storage.login_user(login, password) + if session is None: + login_lockout.fail(login) + time.sleep(0.3) # одинаково тормозим и промах, и попадание + self._json({"error": "неверный логин или пароль"}, 401) + return + login_lockout.reset(login) + self._json(session) + + def _handle_password_change(self, user: dict): + """Смена пароля своей учётки (F-16): текущий пароль обязателен, новый — + минимум 8 символов. Все прочие сессии этой учётки гасятся: украденный + токен перестаёт работать сразу после смены пароля.""" + body = self._read_body() or {} + current = str(body.get("current_password") or "") + new = str(body.get("new_password") or "") + if not current or not new: + self._json({"error": "current_password и new_password required"}, 400) + return + if len(new) < 8: + self._json({"error": "новый пароль — минимум 8 символов"}, 400) + return + if not storage.change_password(user["login"], current, new, + keep_token=self.headers.get("X-Auth-Token", "")): + self._json({"error": "текущий пароль неверен"}, 400) + return + self._json({"ok": True}) + + def _handle_public_order(self): + # 1) глобальный тормоз на флуд фальшивыми заказами (распределённый + # тоже: лимит общий на весь сайт) — иначе атака упирается в БД + if not flood_limiter.allow("site"): + self._json( + {"error": "Онлайн-заказы временно недоступны — позвоните в бар, мы на месте"}, + 503, cors=True, + ) + return + # 2) обычный лимит на одного гостя (IP берётся из XFF за nginx) + if not limiter.allow("order:" + self._client_ip()): + self._json({"error": "too many orders, try later"}, 429, cors=True) + return + # 3) «заказ по времени»: пока бар закрыт и до открытия больше часа, + # заказы не принимаются (проверка на сервере — её не обойти) + gate = bar_orders_gate() + if gate is not None: + self._json(gate, 403, cors=True) + return + body = self._read_body() + if not body or not isinstance(body.get("items"), list) or not body["items"]: + self._json({"error": "items required"}, 400, cors=True) + return + # 4) honeypot: скрытое поле формы, которое человек не видит и не + # заполняет. Отвечаем боту как успех — заказ не создаём. + if str(body.get("hp_check") or "").strip(): + self._json({"ok": True}, 200, cors=True) + return + + # 5) строгая проверка состава: id, название и цена — ТОЛЬКО из каталога + # CRM. Клиентские price/name игнорируются, неизвестные и стоповые + # позиции отклоняют весь заказ: состав, сумму и количество нельзя + # подменить прямым запросом к API (как это делает корзина сайта). + raw_items = body["items"] + if len(raw_items) > 30: + self._json( + {"error": "В заказе больше 30 позиций — разбейте его на два заказа"}, + 400, cors=True, + ) + return + catalog = {m["id"]: m for m in storage.list_menu() if not m["deleted"]} + items = [] + for i in raw_items: + if not isinstance(i, dict): + self._json( + {"error": "Некорректный состав заказа — обновите страницу и соберите заказ заново"}, + 400, cors=True, + ) + return + item_id = str(i.get("id") or "").strip()[:64] + entry = catalog.get(item_id) + if entry is None: + self._json( + {"error": "Меню изменилось — обновите корзину и отправьте заказ ещё раз"}, + 400, cors=True, + ) + return + if entry["stopped"]: + self._json( + {"error": f"«{entry['name']}» только что ушла в стоп-лист — уберите её из корзины" + " и отправьте заказ снова"}, + 400, cors=True, + ) + return + try: + qty = min(50, max(1, int(i.get("qty", 1)))) + except (TypeError, ValueError, OverflowError): # NF-08: qty 1e400 + self._json( + {"error": "Некорректное количество — обновите корзину и попробуйте снова"}, + 400, cors=True, + ) + return + name = entry["name"] + (f" ({entry['portion']})" if entry["portion"] else "") + items.append({"id": item_id, "name": name, "qty": qty, "price": entry["price"]}) + if not items: + self._json({"error": "no valid items"}, 400, cors=True) + return + + # стол обязателен и должен существовать: без него персонал не знает, + # куда нести заказ, а выдуманный стол засоряет канбан и отчёты. + # Пустой справочник залов — fail-closed: лучше отказаться от заказа + # с внятной ошибкой, чем принять произвольный table_name (F-9). + table = clean_guest_text(str(body.get("table_name") or ""), 60).strip() + if not table: + self._json({"error": "выберите столик в корзине — без него заказ не принять"}, 400, cors=True) + return + halls = storage.hall_names() + if not halls: + self._json( + {"error": "Онлайн-заказы временно недоступны — позвоните в бар: +7 (912) 901-72-17"}, + 503, cors=True, + ) + return + if table not in halls: + self._json({"error": "Такого столика нет — выберите столик из списка в корзине"}, 400, cors=True) + return + guest_name = (clean_guest_text(str(body.get("guest_name") or ""), 60).strip() or None) if body.get("guest_name") else None + guest_phone = (re.sub(r"[^\d+]", "", str(body.get("guest_phone") or ""))[:20] or None) if body.get("guest_phone") else None + comment = (clean_guest_text(str(body.get("comment") or ""), 300).strip() or None) if body.get("comment") else None + # 4) дедупликация: тот же гость с тем же составом за последнюю минуту — + # почти наверняка случайный повтор (двойной клик / повторная отправка) + dedup_key = self._client_ip() + "|" + table + "|" + (guest_phone or "") + "|" + \ + ";".join(sorted(f"{i['id']}:{i['qty']}" for i in items)) + if order_dedup.seen(dedup_key): + self._json( + {"error": "Такой заказ вы уже отправили минуту назад — проверьте код отслеживания." + " Нужен ещё один — отправьте через минуту или позовите официанта"}, + 409, cors=True, + ) + return + order = storage.create_order(table, items, source="site", + guest_name=guest_name, guest_phone=guest_phone, + comment=comment) + self._json(order, 201, cors=True) + + def _handle_track_cancel(self, raw_code: str): + """Гость отменяет СЛУЧАЙНО отправленный заказ по коду отслеживания. + + Разрешено только пока заказ «Новый» и ему меньше 10 минут — дальше + бар уже готовит, звоните персоналу. Код — единственный секрет гостя.""" + if not track_limiter.allow("track:" + self._client_ip()): + self._json({"error": "too many requests"}, 429, cors=True) + return + if not track_global_limiter.allow("track-all"): + self._json({"error": "сервис статуса перегружен — попробуйте позже"}, 503, cors=True) + return + order = storage.find_order_by_code(raw_code.upper()) + if order is None: + self._json({"error": "order not found"}, 404, cors=True) + return + if order["status"] == "cancelled": + self._json({"error": "Этот заказ уже отменён"}, 409, cors=True) + return + if order["status"] != "new": + self._json( + {"error": "Заказ уже в работе — отменить нельзя, позвоните в бар: +7 (912) 901-72-17"}, + 409, cors=True, + ) + return + try: + created = datetime.fromisoformat(order["created_at"]) + too_late = datetime.now(created.tzinfo) - created > timedelta(minutes=GUEST_CANCEL_MINUTES) + except (ValueError, TypeError): + too_late = True + if too_late: + self._json( + {"error": "Прошло больше 10 минут — заказ уже готовят, позвоните в бар: +7 (912) 901-72-17"}, + 409, cors=True, + ) + return + # атомарный предикат: гонка с PATCH персонала невозможна — если заказ + # ушёл из «Новый» между проверкой и отменой, UPDATE не тронет ни строку + updated = storage.cancel_order_as_guest(order["id"]) + if updated is None: + self._json( + {"error": "Заказ уже в работе — отменить нельзя, позвоните в бар: +7 (912) 901-72-17"}, + 409, cors=True, + ) + return + self._json({"ok": True, "number": updated["number"], "status": updated["status"]}, 200, cors=True) + + def _handle_order_patch(self, order_id: int): + body = self._read_body() or {} + status = body.get("status") + if status not in storage.STATUSES: + self._json({"error": "bad status"}, 400) + return + user = self._auth() + # официант и кальянщик управляют заказами только во время своей смены + if user and user.get("role") in SHIFT_ROLES and storage.get_open_shift(user["login"]) is None: + self._json({"error": "Смена не активна — управление заблокировано. Выйдите на смену."}, 403) + return + waiter = body.get("waiter") + if waiter is not None: + waiter = str(waiter)[:60].strip() or None + if waiter is not None: + # NF-03: сменные роли приписывают заказ только себе — чужое + # (пусть и существующее) имя переезжает выручку в отчётах + # задним числом; владелец/админ может любое валидное имя + if user and user.get("role") in SHIFT_ROLES and waiter != user["name"]: + self._json({"error": "заказ можно приписать только себе — выберите своё имя"}, 403) + return + # атрибуция — только реальному сотруднику (F-15) + if not storage.is_known_staff_name(waiter): + self._json({"error": "такого сотрудника нет в CRM — выберите имя из списка"}, 400) + return + updated = storage.update_order(order_id, status, waiter, actor=user) + if updated is None: + self._json({"error": "order not found"}, 404) + return + if updated == "bad_transition": + # NF-03: произвольные переходы закрыты — из терминальных статусов + # («Выдан»/«Отменён») и назад ведёт только владелец/админ + self._json({"error": "такой переход статуса недоступен вашей роли — " + "отмена выданного и возврат отменённого делает владелец/админ"}, 403) + return + self._json(updated) + + def _handle_manual_order(self, user: dict): + """Ручной заказ из зала (владелец/админ/официант/кальянщик). + Цена и названия позиций берутся из каталога CRM по id.""" + if user.get("role") in SHIFT_ROLES and storage.get_open_shift(user["login"]) is None: + self._json({"error": "Смена не активна — управление заблокировано. Выйдите на смену."}, 403) + return + body = self._read_body() or {} + table = clean_guest_text(str(body.get("table") or ""), 60).strip() + raw = body.get("items") + if not table: + self._json({"error": "выберите стол или зал"}, 400) + return + # NF-14: staff-путь валидирует стол как публичный (F-9) — произвольный + # текст не попадёт в канбан и отчёт «Столы»; пустой справочник + # залов — fail-closed + if table not in storage.hall_names(): + self._json({"error": "такого стола или зала нет — выберите из справочника"}, 400) + return + if not isinstance(raw, list) or not raw: + self._json({"error": "добавьте хотя бы одну позицию"}, 400) + return + + catalog = {m["id"]: m for m in storage.list_menu()} + items = [] + for i in raw[:30]: + if not isinstance(i, dict): + continue + item_id = str(i.get("id") or "")[:64] + entry = catalog.get(item_id) + if entry is None or entry["deleted"]: + continue # неизвестные/удалённые позиции игнорируем + try: + qty = min(50, max(1, int(i.get("qty", 1)))) + except (TypeError, ValueError, OverflowError): # NF-08: qty 1e400 + continue + items.append({"id": item_id, "name": entry["name"], "qty": qty, "price": entry["price"]}) + if not items: + self._json({"error": "нет доступных позиций — обновите список"}, 400) + return + comment = (clean_guest_text(str(body.get("comment") or ""), 300).strip() or None) if body.get("comment") else None + order = storage.create_order(table, items, source="hall", comment=comment) + self._json(order, 201) + + def _handle_shift_manage(self): + """Владелец/админ вручную записывает или снимает официанта/кальянщика.""" + body = self._read_body() or {} + login = str(body.get("login") or "").strip() + action = str(body.get("action") or "") + if not login or action not in ("in", "out"): + self._json({"error": "login и action (in|out) required"}, 400) + return + target = next((u for u in storage.staff_shift_roles() if u["login"] == login), None) + if target is None: + self._json({"error": "смены ведутся для официантов и кальянщиков"}, 400) + return + if action == "in": + if storage.get_open_shift(login) is not None: + self._json({"error": f"{target['name']} уже на смене"}, 400) + return + self._json(storage.admin_clock_in(login), 201) + return + shift = storage.clock_out(login) + self._json(shift or {"error": "нет активной смены"}, 200 if shift else 400) + + def _handle_track(self, raw_code: str): + """Публичный статус заказа по коду — для страницы «Отследить заказ». + + Отдаём только то, что нужно гостю: статус, стол, сумму и метки времени; + без телефона, имени и комментариев. + """ + if not track_limiter.allow("track:" + self._client_ip()): + self._json({"error": "too many requests"}, 429, cors=True) + return + # перебор кодов гасим и глобально — ротацией IP его не обойти (F-8) + if not track_global_limiter.allow("track-all"): + self._json({"error": "сервис статуса перегружен — попробуйте позже"}, 503, cors=True) + return + order = storage.find_order_by_code(raw_code.upper()) + if order is None: + self._json({"error": "order not found"}, 404, cors=True) + return + self._json({ + "id": order["id"], + # недельный номер заказа — тот, что гость видел при создании + "number": order["number"], + "table": order["table"], + "status": order["status"], + "price": order["price"], + "created_at": order["created_at"], + "accepted_at": order["accepted_at"], + "delivered_at": order["delivered_at"], + }, cors=True) + + def _handle_hall_create(self): + """Новый стол/зал (владелец): {name, sub?}. Имя уникально, + подпись — произвольная («основной зал», «до 8 гостей»…).""" + body = self._read_body() or {} + name = str(body.get("name") or "").strip()[:60] + sub = (str(body.get("sub") or "").strip()[:60] or None) if body.get("sub") else None + if not name: + self._json({"error": "укажите название стола или зала"}, 400) + return + created = storage.hall_create(name, sub) + if created is None: + self._json({"error": "стол или зал с таким названием уже есть"}, 409) + return + self._json(created, 201) + + def _handle_hall_manage(self, user: dict): + """Занять/освободить стол или зону (админ/владелец). + + body: {name, busy: bool, minutes?: int} — minutes задаёт таймер + освобождения; без него стол занят до ручного освобождения. + """ + body = self._read_body() or {} + name = str(body.get("name") or "").strip() + if not any(h["name"] == name for h in storage.halls_active()): + self._json({"error": "неизвестный стол или зона"}, 400) + return + busy = body.get("busy") + if not isinstance(busy, bool): + self._json({"error": "busy (bool) required"}, 400) + return + minutes = None + if busy and body.get("minutes") is not None: + try: + minutes = int(body["minutes"]) + except (TypeError, ValueError, OverflowError): # NF-08: 1e400 + self._json({"error": "minutes — целое число минут"}, 400) + return + if not 5 <= minutes <= 720: + self._json({"error": "таймер: от 5 до 720 минут"}, 400) + return + self._json(storage.set_hall_busy(name, busy, minutes, user["name"])) + + def _handle_hall_reserve(self, user: dict): + """Бронь стола/зоны: body {name, from: 'ЧЧ:ММ', until: 'ЧЧ:ММ', guest?}. + + Время «раньше сейчас» означает следующие сутки (вечерняя бронь через + полночь); окно короче 15 минут или длиннее суток не принимается. + До начала брони стол можно занять как обычно — бронь лишь помечает + стол, фронт ведёт отсчёт и напоминает персоналу за 20 минут. + """ + body = self._read_body() or {} + name = str(body.get("name") or "").strip() + if not any(h["name"] == name for h in storage.halls_active()): + self._json({"error": "неизвестный стол или зона"}, 400) + return + m_from = RESERVE_TIME_RE.match(str(body.get("from") or "").strip()) + m_until = RESERVE_TIME_RE.match(str(body.get("until") or "").strip()) + if not m_from or not m_until: + self._json({"error": "время брони в формате ЧЧ:ММ, например 21:00"}, 400) + return + tz = datetime.now().astimezone().tzinfo or timezone.utc + now = datetime.now(tz) + dt_from = now.replace(hour=int(m_from.group(1)), minute=int(m_from.group(2)), + second=0, microsecond=0) + if dt_from < now - timedelta(minutes=1): + dt_from += timedelta(days=1) + dt_until = now.replace(hour=int(m_until.group(1)), minute=int(m_until.group(2)), + second=0, microsecond=0) + # until должен быть первой меткой СТРОГО после from: и случай «01:30 + # после 23:00», и случай, когда оба времени уже прошли сегодня + while dt_until <= dt_from: + dt_until += timedelta(days=1) + if not timedelta(minutes=15) <= dt_until - dt_from <= timedelta(hours=24): + self._json({"error": "бронь — от 15 минут до суток"}, 400) + return + if dt_from > now + timedelta(days=30): + self._json({"error": "бронь не дальше чем на 30 дней"}, 400) + return + guest = (str(body.get("guest") or "").strip()[:60] or None) if body.get("guest") else None + reservation = storage.hall_reserve(name, dt_from, dt_until, guest, user["name"]) + if reservation is None: + self._json({"error": "на этот стол в это время уже есть бронь"}, 409) + return + self._json({"reservation": reservation}, 201) + + def _handle_hall_reserve_cancel(self, user: dict, res_id: int): + if not storage.hall_reserve_cancel(res_id): + self._json({"error": "бронь не найдена или уже отменена"}, 404) + return + self._json({"ok": True}) + + def _handle_menu_photo(self, item_id: str): + """Загрузка фото позиции (owner). Формат проверяем по сигнатуре файла.""" + # id строго в формате слага сайта: unquote раскрывает %2F→../ и + # %2A→*, что дало бы запись вне media/ или glob-удаление всех фото + if not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", item_id): + self._json({"error": "некорректный id позиции"}, 400) + return + data = self._read_binary(PHOTO_MAX_BYTES) + if data is None: + self._json({"error": "файл не передан или больше 6 МБ"}, 400) + return + ext = image_extension(data) + if ext is None: + self._json({"error": "поддерживаются изображения avif, jpg, png, webp, gif"}, 400) + return + media = storage.MEDIA_DIR + media.mkdir(parents=True, exist_ok=True) + # убрать старые версии с другим расширением + for old in media.glob(glob.escape(item_id) + ".*"): + old.unlink() + target = media / f"{item_id}.{ext}" + target.write_bytes(data) + self._json({"id": item_id, "image": f"/media/{target.name}"}, 201) + + def _handle_game_photo(self, game_id: int): + """Обложка игры (админ/владелец) — та же проверка по сигнатуре файла, + файлы живут в /media/games/. Игра должна существовать.""" + game = storage.game_get(game_id) + if game is None: + self._json({"error": "игра не найдена"}, 404) + return + data = self._read_binary(PHOTO_MAX_BYTES) + if data is None: + self._json({"error": "файл не передан или больше 6 МБ"}, 400) + return + ext = image_extension(data) + if ext is None: + self._json({"error": "поддерживаются изображения avif, jpg, png, webp, gif"}, 400) + return + media = storage.GAMES_MEDIA_DIR + media.mkdir(parents=True, exist_ok=True) + for old in media.glob(glob.escape(str(game_id)) + ".*"): + old.unlink() + target = media / f"{game_id}.{ext}" + target.write_bytes(data) + self._json({"id": game_id, "image": f"/media/games/{target.name}"}, 201) + + def _handle_game_create(self, user: dict): + """Новая игра: {kind: 'ps5'|'board', title, note?, difficulty?, icon?, accent?}.""" + body = self._read_body() or {} + kind = str(body.get("kind") or "").strip() + title = str(body.get("title") or "").strip()[:120] + note = (str(body.get("note") or "").strip()[:160] or None) if body.get("note") else None + if kind not in GAME_KINDS: + self._json({"error": "вид игры: ps5 или board"}, 400) + return + if not title: + self._json({"error": "укажите название игры"}, 400) + return + fields = self._parse_game_fields(body, kind) + if not isinstance(fields, dict): + self._json({"error": fields}, 400) + return + game = storage.game_create(kind, title, note, user["name"], **fields) + self._json({"game": game}, 201) + + def _parse_game_fields(self, body: dict, kind: str): + """Общие поля настолок: difficulty/icon/accent. Для PS5 не принимаются. + Возвращает dict полей либо строку с ошибкой.""" + fields: dict = {} + if kind != "board": + return fields + diff = body.get("difficulty") + if diff is not None: + try: + diff = int(diff) + except (TypeError, ValueError, OverflowError): # NF-08: 1e400 + return "сложность: 1, 2 или 3" + if diff not in (1, 2, 3): + return "сложность: 1, 2 или 3" + fields["difficulty"] = diff + icon = body.get("icon") + if icon is not None: + icon = str(icon).strip() + if icon and icon not in GAME_ICONS: + return "неизвестная иконка" + fields["icon"] = icon or None + accent = body.get("accent") + if accent is not None: + accent = str(accent).strip() + if accent and not HEX_COLOR_RE.match(accent): + return "цвет в формате #rrggbb" + fields["accent"] = accent.lower() or None + return fields + + def _handle_game_patch(self, user: dict, game_id: int): + """Правка игры: {deleted?: bool, note?, difficulty?, icon?, accent?}.""" + # тело читаем до раннего 404: непрочитанное тело PATCH ломает + # следующий запрос keep-alive-клиента (десинхронизация потока) + body = self._read_body() or {} + game = storage.game_get(game_id) + if game is None: + self._json({"error": "игра не найдена"}, 404) + return + if not body: + self._json({"error": "нечего изменять"}, 400) + return + fields: dict = {} + if "deleted" in body: + if not isinstance(body["deleted"], bool): + self._json({"error": "deleted (bool) required"}, 400) + return + if body["deleted"]: + if not storage.game_delete(game_id): + self._json({"error": "игра не найдена или уже удалена"}, 404) + return + self._json({"ok": True}) + return + updated = storage.game_set_deleted(game_id, False) + if updated is None: + self._json({"error": "игра не найдена"}, 404) + return + self._json({"game": updated}) + return + else: + fields = self._parse_game_fields(body, game["kind"]) + if not isinstance(fields, dict): + self._json({"error": fields}, 400) + return + if "note" in body: + fields["note"] = (str(body["note"] or "").strip()[:160] or None) + updated = storage.game_update(game_id, fields) + if updated is None: + self._json({"error": "игра не найдена"}, 404) + return + self._json({"game": updated}) + + def _handle_game_delete(self, game_id: int): + """Удаление игры → корзина (мягко): сид сайта не пересоздаст её, + вернуть можно через PATCH {deleted: false}. Обложка сохраняется.""" + if not storage.game_delete(game_id): + self._json({"error": "игра не найдена или уже удалена"}, 404) + return + self._json({"ok": True}) + + def _handle_catalog_patch(self, kind: str, item_id: str, user: dict): + """Стоп-лист (админ/владелец), флаг deleted — удаление/восстановление + (только владелец), подсветка (админ/владелец), поштучный учёт + stock/stock_add (только владелец, только меню) и правка карточки + (имя/категория/цена/порция, только владелец и только CRM-позиции). + kind: 'menu' | 'tobacco'. + + Двухфазный (F-18): права и валидность КАЖДОГО поля проверяются до + первой записи — мульти-полевой PATCH либо применяется целиком, + либо отвечает ошибкой, не оставляя половину изменений в БД.""" + body = self._read_body() or {} + if not body: + self._json({"error": "stopped, deleted, accent, stock, stock_add или поля правки required"}, 400) + return + role = user.get("role") + ops = [] + + if "stopped" in body: + # стоп-лист ведут админ/владелец по всему каталогу, кальянщик — + # только по табаку (вкусы для кальянов) + if role not in ADMIN_ROLES and \ + not (kind == "tobacco" and role == "hookah"): + self._json({"error": "admin only"}, 403) + return + if not isinstance(body["stopped"], bool): + self._json({"error": "stopped (bool) required"}, 400) + return + ops.append(lambda conn: storage.set_menu_stopped(item_id, body["stopped"], conn=conn) if kind == "menu" + else storage.set_tobacco_stopped(item_id, body["stopped"], conn=conn)) + + if "deleted" in body: + if role != "owner": + self._json({"error": "owner only"}, 403) + return + if not isinstance(body["deleted"], bool): + self._json({"error": "deleted (bool) required"}, 400) + return + ops.append(lambda conn: storage.set_menu_deleted(item_id, body["deleted"], conn=conn) if kind == "menu" + else storage.set_tobacco_deleted(item_id, body["deleted"], conn=conn)) + + if "accent" in body and kind == "menu": + if role not in ADMIN_ROLES: + self._json({"error": "admin only"}, 403) + return + accent = body["accent"] + if accent is not None: + accent = str(accent).strip() + if not HEX_COLOR_RE.match(accent): + self._json({"error": "цвет подсветки в формате #rrggbb"}, 400) + return + accent = accent.lower() + ops.append(lambda conn: storage.set_menu_accent(item_id, accent or None, conn=conn)) + + # поштучный учёт (пиво/энергетики банками): stock — задать остаток + # абсолютно (None — выключить учёт), stock_add — приход N штук + if "stock" in body or "stock_add" in body: + if kind != "menu": + self._json({"error": "поштучный учёт ведётся только по позициям меню"}, 400) + return + if role != "owner": + self._json({"error": "owner only"}, 403) + return + if "stock" in body and "stock_add" in body: + self._json({"error": "отправьте либо stock, либо stock_add"}, 400) + return + if "stock" in body: + raw = body["stock"] + if raw is None: + ops.append(lambda conn: storage.set_menu_stock(item_id, new_stock=None, conn=conn)) + else: + try: + qty = int(raw) + except (TypeError, ValueError, OverflowError): # NF-08: 1e400 + self._json({"error": "stock — целое число штук или null"}, 400) + return + if not 0 <= qty <= 100_000: + self._json({"error": "stock — от 0 до 100000"}, 400) + return + ops.append(lambda conn: storage.set_menu_stock(item_id, new_stock=qty, conn=conn)) + else: + try: + add = int(body["stock_add"]) + except (TypeError, ValueError, OverflowError): # NF-08 + self._json({"error": "stock_add — целое число штук"}, 400) + return + if not 1 <= add <= 100_000: + self._json({"error": "stock_add — приход от 1 до 100000 штук"}, 400) + return + ops.append(lambda conn: storage.set_menu_stock(item_id, add=add, conn=conn)) + + # правка карточки позиции: имя/категория/цена/порция (только владелец, + # только позиции, добавленные через CRM, — стартовые задаёт код сайта) + edit_keys = [k for k in ("name", "category", "price", "portion") if k in body] + if edit_keys: + if kind != "menu": + self._json({"error": "название и цена редактируются только у позиций меню"}, 400) + return + if role != "owner": + self._json({"error": "owner only"}, 403) + return + fields: dict = {} + if "name" in body: + name = str(body["name"] or "").strip()[:120] + if not name: + self._json({"error": "укажите название позиции"}, 400) + return + fields["name"] = name + if "category" in body: + category = str(body["category"] or "").strip()[:60] + if category not in storage.menu_categories(): + self._json({"error": "выберите существующую категорию: " + ", ".join(storage.menu_categories())}, 400) + return + fields["category"] = category + if "price" in body: + try: + fields["price"] = min(1_000_000, max(0, int(body["price"]))) + except (TypeError, ValueError, OverflowError): # NF-08: 1e400 + self._json({"error": "цена — целое число"}, 400) + return + if "portion" in body: + portion = str(body["portion"] or "").strip()[:40] + fields["portion"] = portion or None + ops.append(lambda conn: storage.update_menu_item(item_id, fields, conn=conn)) + + # --- фаза 2: применяем в ОДНОЙ транзакции (NF-07): все проверки + # пройдены, но и записи атомарны — отказ любого op (seed / not found) + # откатывает уже применённые, «400 на втором поле» больше не оставляет + # закоммиченный первый стоп/удаление + updated = None + try: + with storage.db() as conn: + for op in ops: + updated = op(conn) + if updated == "seed": + raise _CatalogPatchAbort({ + "error": "стартовые позиции сайта заданы в его коде — править" + " можно только позиции, добавленные через CRM", + }, 400) + if updated is None: + raise _CatalogPatchAbort({"error": "not found"}, 404) + except _CatalogPatchAbort as abort: + self._json(abort.payload, abort.code) + return + if updated is None: # тело было, но ни один известный ключ не совпал + self._json({"error": "not found"}, 404) + return + self._json(updated) + + # ---------------- владелец: сотрудники, позиции, отчёты ---------------- + + def _handle_user_create(self): + body = self._read_body() or {} + login = str(body.get("login") or "").strip() + password = str(body.get("password") or "") + name = str(body.get("name") or "").strip() + role = str(body.get("role") or "") + if not LOGIN_RE.match(login): + self._json({"error": "логин: 3–40 символов, латиница, цифры, . _ -"}, 400) + return + if len(password) < 8: + self._json({"error": "пароль минимум 8 символов"}, 400) + return + if not name: + self._json({"error": "укажите имя сотрудника"}, 400) + return + if role not in CREATABLE_ROLES: + self._json({"error": "роль: waiter, hookah или admin"}, 400) + return + created = storage.create_user(login, password, name, role) + if created is None: + self._json({"error": "такой логин уже занят"}, 409) + return + if created == "name_taken": + self._json({"error": "сотрудник с таким именем уже есть — атрибуция заказов" + " требует уникальных имён"}, 409) + return + self._json(created, 201) + + def _handle_user_password(self): + """Сброс пароля сотрудника владельцем: текущий пароль сотрудника не + нужен, его сессии гасятся (см. storage.reset_password). Владельца + через этот эндпоинт менять нельзя — только сам, кнопкой 🔑.""" + body = self._read_body() or {} + login = str(body.get("login") or "").strip() + new = str(body.get("new_password") or "") + if not login or not new: + self._json({"error": "login и new_password required"}, 400) + return + if len(new) < 8: + self._json({"error": "новый пароль — минимум 8 символов"}, 400) + return + targets = [u for u in storage.list_users() if u["login"] == login] + if not targets: + self._json({"error": "сотрудник не найден"}, 404) + return + if targets[0]["role"] == "owner": + self._json({"error": "пароль владельца меняется только им самим (кнопка 🔑)"}, 400) + return + if not storage.reset_password(login, new): + self._json({"error": "сотрудник не найден"}, 404) + return + self._json({"ok": True}) + + def _handle_menu_create(self): + body = self._read_body() or {} + name = str(body.get("name") or "").strip()[:120] + category = str(body.get("category") or "").strip()[:60] + portion = (str(body.get("portion") or "").strip()[:40] or None) if body.get("portion") else None + try: + price = min(1_000_000, max(0, int(body.get("price", 0)))) + except (TypeError, ValueError, OverflowError): # NF-08: 1e400 + self._json({"error": "цена — целое число"}, 400) + return + # поштучный учёт: необязательное начальное количество в штуках + raw_stock = body.get("stock") + stock = None + if raw_stock not in (None, ""): + try: + stock = int(raw_stock) + except (TypeError, ValueError, OverflowError): # NF-08: 1e400 + self._json({"error": "количество — целое число штук"}, 400) + return + if not 0 <= stock <= 100_000: + self._json({"error": "количество — от 0 до 100000 штук"}, 400) + return + if not name: + self._json({"error": "укажите название позиции"}, 400) + return + # категория должна существовать — иначе сайт не знает, куда поставить позицию + if category not in storage.menu_categories(): + self._json({"error": "выберите существующую категорию: " + ", ".join(storage.menu_categories())}, 400) + return + self._json(storage.create_custom_menu_item(name, category, price, portion, stock), 201) + + def _handle_tobacco_create(self): + body = self._read_body() or {} + brand = str(body.get("brand") or "").strip()[:60] + flavor = str(body.get("flavor") or "").strip()[:120] + note = (str(body.get("note") or "").strip()[:160] or None) if body.get("note") else None + try: + strength = min(3, max(1, int(body.get("strength", 2)))) + except (TypeError, ValueError, OverflowError): # NF-08: 1e400 + self._json({"error": "крепость — число от 1 до 3"}, 400) + return + cooling = bool(body.get("cooling")) + if not brand: + self._json({"error": "укажите бренд"}, 400) + return + if not flavor: + self._json({"error": "укажите название вкуса"}, 400) + return + self._json(storage.create_custom_tobacco(brand, flavor, note, strength, cooling), 201) + + def _parse_mix_payload(self, body: dict) -> dict | None: + """Общая валидация полей микса для создания и правки. + None — ошибка уже отправлена гостю; иначе словарь полей.""" + name = str(body.get("name") or "").strip()[:120] + note = (str(body.get("note") or "").strip()[:200] or None) if body.get("note") else None + if not name: + self._json({"error": "укажите название микса"}, 400) + return None + raw_ids = body.get("flavor_ids") + if not isinstance(raw_ids, list): + self._json({"error": "выберите вкусы микса"}, 400) + return None + flavor_ids: list[str] = [] + for x in raw_ids: + fid = str(x or "").strip()[:64] + if fid and fid not in flavor_ids: + flavor_ids.append(fid) + if not 2 <= len(flavor_ids) <= 5: + self._json({"error": "микс — это 2–5 вкусов"}, 400) + return None + + def opt_int(key, lo, hi): + """Необязательное целое в диапазоне; None — поле не задано.""" + if body.get(key) in (None, ""): + return None + try: + val = int(body[key]) + except (TypeError, ValueError, OverflowError): + return None # мусор (включая 1e400, NF-08) трактуем как «не задано» + return val if lo <= val <= hi else None + + # теги для AI-генератора: максимум 6, каждый 1–20 видимых символов + tags: list[str] = [] + raw_tags = body.get("tags") + if isinstance(raw_tags, list): + for x in raw_tags[:6]: + tag = str(x or "").strip()[:20] + if tag and tag not in tags: + tags.append(tag) + + return { + "name": name, "note": note, "flavor_ids": flavor_ids, + "strength": opt_int("strength", 1, 5), "cooling": opt_int("cooling", 0, 3), + "price": opt_int("price", 0, 1_000_000), "tags": tags, + } + + def _handle_mix_create(self): + """Микс от владельца или кальянщика: название + вкусы справочника + табаков, без долей. + + Состав видят гости на странице «Миксы НЕОНа», пропорции делает + кальянщик. Валидируем: имя, 2–5 существующих вкусов, заметка ≤200. + Расширенная информация (всё необязательное): крепость 1–5, + холодок 0–3, цена ₽, теги — сайт покажет их в карточке микса и + по тегам подмешает микс в AI-генерацию. + """ + fields = self._parse_mix_payload(self._read_body() or {}) + if fields is None: + return + created = storage.create_custom_mix(**fields) + if created is None: + self._json({"error": "выберите минимум два вкуса из справочника табаков"}, 400) + return + self._json(created, 201) + + def _handle_mix_patch(self, mix_id: str): + """Правка микса (владелец/кальянщик): тот же набор полей, что и + при создании — название, состав и расширенная информация.""" + fields = self._parse_mix_payload(self._read_body() or {}) + if fields is None: + return + updated = storage.update_mix(mix_id, **fields) + if updated == "not_found": + self._json({"error": "микс не найден"}, 404) + return + if updated in ("bad_flavors", None): + self._json({"error": "выберите минимум два вкуса из справочника табаков"}, 400) + return + self._json(updated) + + def _handle_site_mix_patch(self, mix_id: str): + """Правка витринного микса сайта (только владелец). + + Форма присылает полный снимок полей: название, заметку (пустая строка + = убрать описание), крепость/холодок/цену и теги. Состав опционален: + flavor_ids [] — оставить исходный состав витрины, иначе 2–5 вкусов + из справочника табаков. Снимок сохраняется в site_mix_edits, сайт + применяет его к своей витрине через /api/catalog; DELETE по тому же + адресу возвращает микс к исходному виду.""" + # тело читаем до любых ранних выходов: непрочитанное тело PATCH + # остаётся в сокете и ломает следующий запрос keep-alive-клиента + body = self._read_body() or {} + seed_ids = {m["id"] for m in storage.site_mixes()} + if mix_id not in seed_ids: + self._json({"error": "витринный микс с таким id не найден"}, 404) + return + name = str(body.get("name") or "").strip()[:120] + if not name: + self._json({"error": "укажите название микса"}, 400) + return + + def opt_int(key, lo, hi): + if body.get(key) in (None, ""): + return None + try: + val = int(body[key]) + except (TypeError, ValueError, OverflowError): # NF-08: 1e400 + return None + return val if lo <= val <= hi else None + + strength = opt_int("strength", 1, 5) + cooling = opt_int("cooling", 0, 3) + price = opt_int("price", 0, 1_000_000) + if strength is None or cooling is None or price is None: + self._json({"error": "заполните крепость (1–5), холодок (0–3) и цену"}, 400) + return + + tags: list[str] = [] + raw_tags = body.get("tags") + if isinstance(raw_tags, list): + for x in raw_tags[:6]: + tag = str(x or "").strip()[:20] + if tag and tag not in tags: + tags.append(tag) + + raw_ids = body.get("flavor_ids") + if raw_ids is None or raw_ids == []: + flavors = None # состав не менялся — сайт оставит исходный + elif isinstance(raw_ids, list): + flavor_ids: list[str] = [] + for x in raw_ids: + fid = str(x or "").strip()[:64] + if fid and fid not in flavor_ids: + flavor_ids.append(fid) + if len(flavor_ids) == 1: + self._json({"error": "микс — это 2–5 вкусов; очистите состав, чтобы оставить исходный"}, 400) + return + rows = storage.list_tobacco() + by_id = {t["id"]: t for t in rows if not t.get("deleted")} + flavors = [ + {"id": t["id"], "brand": t["brand"], "flavor": t["flavor"]} + for t in (by_id.get(i) for i in flavor_ids) if t + ] + if len(flavors) < 2: + self._json({"error": "выберите минимум два вкуса из справочника табаков"}, 400) + return + else: + self._json({"error": "flavor_ids — список вкусов"}, 400) + return + + storage.update_site_mix(mix_id, { + "name": name, + "note": str(body.get("note") or "").strip()[:200], + "strength": strength, "cooling": cooling, "price": price, + "tags": tags, "flavors": flavors, + }) + self._json({"ok": True, "id": mix_id}) + + def _report_period(self, qs: dict): + """Период отчёта из query: ?from=ГГГГ-ММ-ДД&to=ГГГГ-ММ-ДД или ?all=1 + (от самого раннего заказа по сегодня). Возвращает (day_from, day_to) + или None — ответ об ошибке уже отправлен.""" + tz = datetime.now().astimezone().tzinfo or timezone.utc + if (qs.get("all") or ["0"])[0] == "1": + # «за всё время»: от самого раннего заказа по сегодня включительно + first = storage.first_order_dt() + day_from = first.astimezone(tz).date() if first else datetime.now(tz).date() + return day_from, datetime.now(tz).date() + raw_from = (qs.get("from") or [""])[0] + raw_to = (qs.get("to") or [""])[0] + if not DATE_RE.match(raw_from) or not DATE_RE.match(raw_to): + self._json({"error": "период: from=ГГГГ-ММ-ДД&to=ГГГГ-ММ-ДД"}, 400) + return None + try: + day_from = date.fromisoformat(raw_from) + day_to = date.fromisoformat(raw_to) + except ValueError: + self._json({"error": "неверная дата"}, 400) + return None + if day_to < day_from: + self._json({"error": "конец периода раньше начала"}, 400) + return None + return day_from, day_to + + def _report_utc_range(self, days): + """(day_from, day_to) → (dt_from, dt_to) UTC; конец — полночь + следующего дня, границы включительные.""" + tz = datetime.now().astimezone().tzinfo or timezone.utc + day_from, day_to = days + dt_from = datetime.combine(day_from, dtime(0, 0), tzinfo=tz).astimezone(timezone.utc) + dt_to = datetime.combine(day_to + timedelta(days=1), dtime(0, 0), tzinfo=tz).astimezone(timezone.utc) + return dt_from, dt_to + + def _handle_report(self, user: dict): + if user["role"] != "owner": + self._json({"error": "owner only"}, 403) + return + days = self._report_period(parse_qs(urlsplit(self.path).query)) + if days is None: + return + dt_from, dt_to = self._report_utc_range(days) + report = storage.orders_report(dt_from, dt_to) + report["period"] = {"from": days[0].isoformat(), "to": days[1].isoformat()} + self._json(report) + + def _handle_staff_report(self): + """Статистика сотрудников за период: заказы, выручка, средний чек, + средняя подача, смены и часы (см. storage.staff_report).""" + days = self._report_period(parse_qs(urlsplit(self.path).query)) + if days is None: + return + dt_from, dt_to = self._report_utc_range(days) + staff = storage.staff_report(dt_from, dt_to) + self._json({ + "period": {"from": days[0].isoformat(), "to": days[1].isoformat()}, + "staff": staff, + }) + + def _handle_report_excel(self): + """Выгрузка отчёта в xlsx (stdlib zip+xml, без зависимостей): + сводка с итогом «Всего (сайт + зал)», по дням, по часам, топ + позиций, столы и сотрудники. by_day=1 — отдельный лист на каждый + день периода (лимит 31 день).""" + qs = parse_qs(urlsplit(self.path).query) + days = self._report_period(qs) + if days is None: + return + dt_from, dt_to = self._report_utc_range(days) + report = storage.orders_report(dt_from, dt_to) + staff = storage.staff_report(dt_from, dt_to) + period = {"from": days[0].isoformat(), "to": days[1].isoformat()} + if (qs.get("by_day") or ["0"])[0] == "1": + span = (days[1] - days[0]).days + 1 + if span > 31: + self._json({"error": "разбивка по дням — период не длиннее 31 дня"}, 400) + return + sheets = [_summary_sheet(report, staff, period)] + day = days[0] + while day <= days[1]: + d_from, d_to = self._report_utc_range((day, day)) + sheets.append(_day_sheet(storage.orders_report(d_from, d_to), + storage.staff_report(d_from, d_to), day)) + day += timedelta(days=1) + name = f"neon-report-days_{period['from']}_{period['to']}.xlsx" + else: + sheets = _report_xlsx(report, staff, period) + name = f"neon-report_{period['from']}_{period['to']}.xlsx" + self._send_file( + _xlsx_bytes(sheets), name, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + + +# ---------------------------------------------------------------- запуск + +def main() -> None: + import mimetypes + + mimetypes.add_type("image/avif", ".avif") + mimetypes.add_type("image/webp", ".webp") + + storage.init_pool() + storage.init_schema() + + # F-6: seed-файлы проверяем до старта — битый или отсутствующий роняет + # запуск с внятной ошибкой вместо тихой чистки каталога при сидинге + storage.validate_seeds() + + # проверяем ФАКТ: у каких учёток пароль всё ещё дефолтный. Смена + # пароля в .env на уже созданной базе не применяется (сид делает + # ON CONFLICT DO NOTHING), поэтому переменную окружения не проверяем — + # сверяем хэши со всеми известными дефолтами (F-13). По умолчанию — + # отказ старта: с дефолтными паролями панель доступна всему интернету. + weak = storage.weak_default_logins() + if weak: + message = ( + "[security] Учётки с паролем по умолчанию: " + ", ".join(weak) + ".\n" + "Смените пароли каждому сотруднику (панель: кнопка «Сменить пароль»)\n" + "или пересоздайте базу с новыми значениями в .env.\n" + "Запуск с дефолтными паролями запрещён; для dev-стенда явно задайте\n" + "переменную окружения CRM_ALLOW_DEFAULT_PASSWORDS=1." + ) + if os.environ.get("CRM_ALLOW_DEFAULT_PASSWORDS", "").strip() == "1": + print(message, file=sys.stderr) + else: + sys.exit(message) + + # NF-12: бинд задаётся переменной CRM_BIND. В контейнере 0.0.0.0 — + # снаружи порт всё равно закрыт (compose публикует только 127.0.0.1), + # а локальный dev-стенд (dev-crm.bat) ставит 127.0.0.1, чтобы панель + # со слабыми dev-настройками не светилась в LAN. + bind = os.environ.get("CRM_BIND", "0.0.0.0").strip() or "0.0.0.0" + server = ThreadingHTTPServer((bind, PORT), Handler) + print(f"NEON CRM -> http://{bind}:{PORT} ({storage.BACKEND}, таблицы готовы)") + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + storage.close_pool() + + +if __name__ == "__main__": + main() diff --git a/crm/set_all_passwords.py b/crm/set_all_passwords.py new file mode 100644 index 0000000..570aefa --- /dev/null +++ b/crm/set_all_passwords.py @@ -0,0 +1,62 @@ +"""Разовая утилита: задать ВСЕМ действующим учёткам CRM один пароль. + +Использование: + python set_all_passwords.py neon2026 + +Обновляет dev-базу SQLite (crm/data/crm.db) и, если контейнер neon-db +запущен, базу PostgreSQL в docker-стеке. Только для тестового стенда: +на живом сервере пароли меняют в панели (свой — кнопка 🔑, чужой — +владелец в разделе «Сотрудники»). После смены пароля в .env не забудь: +он действует только при ПЕРВОМ создании учётки. +""" +import hashlib +import secrets +import sqlite3 +import subprocess +import sys +from pathlib import Path + + +def hash_password(password: str) -> str: + # тот же формат, что storage.hash_password: pbkdf2_sha256$iter$salt$hex + salt = secrets.token_hex(16) + digest = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt), 200_000) + return f"pbkdf2_sha256$200000${salt}${digest.hex()}" + + +def main() -> None: + if len(sys.argv) != 2 or len(sys.argv[1]) < 8: + print("Использование: python set_all_passwords.py <пароль-минимум-8-символов>") + sys.exit(2) + password = sys.argv[1] + new_hash = hash_password(password) + + # 1) dev-база SQLite рядом со скриптом + sqlite_path = Path(__file__).resolve().parent / "data" / "crm.db" + if sqlite_path.exists(): + with sqlite3.connect(sqlite_path) as conn: + cur = conn.execute( + "UPDATE crm_users SET password_hash = ? WHERE NOT deleted", (new_hash,) + ) + print(f"SQLite ({sqlite_path.name}): пароль изменён у {cur.rowcount} учёток") + else: + print(f"SQLite не найдена ({sqlite_path}) — пропускаю") + + # 2) PostgreSQL в docker-стеке (контейнер neon-db) + sql = f"UPDATE crm_users SET password_hash = '{new_hash}' WHERE NOT deleted;" + try: + proc = subprocess.run( + ["docker", "exec", "neon-db", "psql", "-U", "neon", "-d", "neon_crm", "-c", sql], + capture_output=True, text=True, timeout=30, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + print("docker или контейнер neon-db недоступен — postgres-базу не трогал") + return + if proc.returncode == 0: + print("PostgreSQL (neon-db): пароль изменён у всех действующих учёток") + else: + print(f"postgres UPDATE не прошёл:\n{proc.stderr.strip()}") + + +if __name__ == "__main__": + main() diff --git a/crm/storage.py b/crm/storage.py new file mode 100644 index 0000000..6c69ede --- /dev/null +++ b/crm/storage.py @@ -0,0 +1,3164 @@ +# -*- coding: utf-8 -*- +""" +Хранилище НЕОН CRM. + +Два бэкенда с одинаковым API (выбор через переменную окружения CRM_DB): + +- postgres (по умолчанию) — продакшн под Docker, пул psycopg; +- sqlite — локальный запуск на машине без Docker/PostgreSQL + (`CRM_DB=sqlite python server.py`), реализация в storage_sqlite.py, + файл базы: crm/data/crm.db. + +Общая логика (статусы, хэши паролей, seed-каталог, демо-заказы) живёт здесь, +специфичные для БД функции — в своей ветке. +""" + +import hashlib +import hmac +import json +import os +import re +import secrets +import shutil +from datetime import date, datetime, timedelta, timezone +from pathlib import Path + +SEED_DIR = Path(__file__).resolve().parent / "seed" +DATA_DIR = Path(__file__).resolve().parent / "data" +MEDIA_DIR = Path(__file__).resolve().parent / "media" # фото позиций, отдаются как /media/<файл> +GAMES_MEDIA_DIR = MEDIA_DIR / "games" # обложки игр PS5: /media/games/. + +STATUSES = ("new", "active", "done", "cancelled") +# NF-03: переходы статусов, доступные сменным ролям (официант/кальянщик). +# «Сразу выдать» — это new→done, тоже легитимный путь. Из терминальных +# (done, cancelled) и назад — только владелец/админ: эти переходы меняют +# выручку отчётов задним числом. +ORDER_TRANSITIONS_STAFF = frozenset({ + ("new", "active"), ("new", "done"), ("new", "cancelled"), + ("active", "done"), ("active", "cancelled"), +}) +SESSION_TTL = timedelta(hours=12) +NOVELTY_DAYS = 7 # сколько дней позиция, добавленная владельцем, считается «новинкой» + +BACKEND = (os.environ.get("CRM_DB", "postgres").strip().lower() or "postgres") + + +class PostgresOnlyFeature(RuntimeError): + """Функция не реализована в sqlite-режиме (F-11): игры, доски/заметки, + брони столов, подсветка позиций, сброс нумерации — только postgres + (боевой режим). Сервер переводит её в 501 с понятным текстом.""" + +# Столы и зоны зала — канонический список для отметок занятости (GET /api/halls). +# Имена должны совпадать с массивом halls в crm/js/app.js и TABLE_OPTIONS сайта. +HALLS = [ + {"name": "Стол 1", "sub": "основной зал"}, + {"name": "Стол 2", "sub": "основной зал"}, + {"name": "Стол 3", "sub": "основной зал"}, + {"name": "Стол 4", "sub": "основной зал"}, + {"name": "Стол 5", "sub": "основной зал"}, + {"name": "VIP-зал", "sub": "до 8 гостей"}, + {"name": "Кинозал", "sub": "до 15 гостей"}, +] + +def order_week_start(now: datetime | None = None) -> date: + """Дата старта «недели нумерации» заказов — вторник 00:00 по времени бара. + + Понедельник у бара выходной, ночная смена вс→пн должна остаться в одной + нумерации, поэтому неделя нумерации начинается во вторник: номера + обнуляются каждую неделю в закрытые часы. + """ + day = (now or datetime.now().astimezone()).astimezone().date() + return day - timedelta(days=(day.weekday() - 1) % 7) + + +# Алфавит кодов отслеживания заказов — без похожих символов (0/O, 1/I/L) +TRACK_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" + + +def generate_track_code() -> str: + return "".join(secrets.choice(TRACK_ALPHABET) for _ in range(6)) + + +# Гигиена свободного текста (и гостя, и персонала — NF-14): убираем +# управляющие и скрытые символы (нуль-байты, bidi-переопределения, +# zero-width), которые не видны глазу, но ломают рендер/БД или прячут +# часть строки от персонала. Живёт в storage, потому что нужен обоим +# слоям — server.py (публичные заказы, ручные заказы) и notes_api.py +# (заголовки досок и заметок), а импортировать server из notes_api нельзя. +_HIDDEN_CHARS_RE = re.compile( + r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\u00ad\u061c\u180e\u200b-\u200f" + r"\u2028\u2029\u202a-\u202e\u2060-\u206f\ufeff]" +) + + +def clean_guest_text(value: str, limit: int) -> str: + return _HIDDEN_CHARS_RE.sub("", value)[:limit] + + +def photo_for_item(item_id: str) -> str | None: + """Путь к загруженному фото позиции (/media/<файл>) или None.""" + if not MEDIA_DIR.is_dir(): + return None + for p in MEDIA_DIR.iterdir(): + if p.stem == item_id and p.is_file(): + return f"/media/{p.name}" + return None + + +def purge_item_photos(item_id: str) -> None: + """Удалить фото позиции с диска (все версии с разными расширениями). + Общий для обоих бэкендов — purge_menu_item вызывает при полном удалении.""" + if not MEDIA_DIR.is_dir(): + return + for p in MEDIA_DIR.iterdir(): + if p.stem == item_id and p.is_file(): + p.unlink(missing_ok=True) + + +def game_cover(game_id: int) -> str | None: + """Путь к обложке игры (/media/games/.) или None.""" + if not GAMES_MEDIA_DIR.is_dir(): + return None + for p in GAMES_MEDIA_DIR.iterdir(): + if p.stem == str(game_id) and p.is_file(): + return f"/media/games/{p.name}" + return None + + +def _copy_seed_cover(covers_dir: Path, game: dict, game_id: int) -> None: + """Обложка сид-игры: seed/covers/<файл> → media/games/.. + Перезаписывает и убирает версии с другим расширением (как фото меню).""" + cover = str(game.get("cover") or "") + if not cover: + return + src = covers_dir / cover + if not src.is_file(): + return + GAMES_MEDIA_DIR.mkdir(parents=True, exist_ok=True) + for old in GAMES_MEDIA_DIR.glob(f"{game_id}.*"): + old.unlink() + shutil.copyfile(src, GAMES_MEDIA_DIR / f"{game_id}{src.suffix.lower()}") + + +# ---------------------------------------------------------------- расписание смен +# Часы работы бара: длительность смены по дню недели (Пн — выходной). +# Python weekday(): Пн=0 … Вс=6. Начало всегда в 19:00. +SHIFT_START_HOUR = 19 +SHIFT_DURATION_HOURS = {0: None, 1: 6, 2: 6, 3: 6, 4: 8, 5: 8, 6: 6} +SHIFT_PREPARE_MINUTES = 20 # за сколько минут до начала официант может выйти на смену + +# Единое барное время (F-21): окна смен, гейтинг входа и авто-закрытие +# считают по фиксированному UTC+5 — тому же поясу, что онлайн-заказы в +# server.py (BAR_TZ). Раньше смены жили по host-поясу: без TZ на сервере +# окно смены уезжало относительно окна заказов. +BAR_TZ = timezone(timedelta(hours=5)) + + +def bar_now() -> datetime: + """Текущее барное время — единый барный-clock для всего хранилища.""" + return datetime.now(BAR_TZ) + + +def shift_window(day_offset: int) -> tuple[datetime, datetime] | None: + """Окно смены (начало, конец) для дня со смещением от сегодня; None — выходной.""" + from datetime import date, time as dtime + + tz = BAR_TZ + day = bar_now().date() + timedelta(days=day_offset) + hours = SHIFT_DURATION_HOURS.get(day.weekday()) + if not hours: + return None + start = datetime.combine(day, dtime(SHIFT_START_HOUR, 0), tzinfo=tz) + return start, start + timedelta(hours=hours) + + +def clock_in_check() -> tuple[bool, str]: + """Можно ли выйти на смену сейчас: за 20 минут до начала, во время смены + или в хвосте вчерашней. Иначе — причина с временем ближайшей смены.""" + now = bar_now() + prepare = timedelta(minutes=SHIFT_PREPARE_MINUTES) + for offset in (-1, 0): + window = shift_window(offset) + if window and window[0] - prepare <= now <= window[1]: + return True, "" + for offset in range(0, 8): + window = shift_window(offset) + if window and now < window[0]: + return False, (f"Смена начнётся {window[0].strftime('%d.%m в %H:%M')} — " + f"кнопка появится за {SHIFT_PREPARE_MINUTES} минут до начала") + return False, "Сегодня выходной" + + +def auto_close_cutoff(started: datetime, now: datetime) -> datetime | None: + """Момент, когда открытая смена, начавшаяся в started, должна закрыться + автоматически; None — пока рано. Смена, начавшаяся в окно расписания + (в т.ч. за 20 минут), закрывается в конце этого окна. Записанная вне + расписания — через 10 часов после начала.""" + local = started.astimezone(BAR_TZ) + prepare = timedelta(minutes=SHIFT_PREPARE_MINUTES) + for offset in range(-2, 8): + window = shift_window(offset) + if window and window[0] - prepare <= local <= window[1]: + return window[1] if now > window[1] else None + cap = local + timedelta(hours=10) + return cap if now > cap else None + + +def _assemble_report(statuses: list[dict], by_day: list[dict], + top_items: list[dict], avg_duration: float | None) -> dict: + """Общая сборка отчёта для обоих бэкендов.""" + totals = { + "orders": 0, "new": 0, "active": 0, "done": 0, "cancelled": 0, + "revenue": 0, "cancelled_amount": 0, + } + for r in statuses: + status, cnt, total = r["status"], r["cnt"], r["total"] + totals["orders"] += cnt + totals[status] = cnt + if status == "done": + totals["revenue"] = total + if status == "cancelled": + totals["cancelled_amount"] = total + totals["avg_check"] = round(totals["revenue"] / totals["done"]) if totals["done"] else 0 + totals["avg_duration_seconds"] = round(avg_duration) if avg_duration is not None else None + return { + "totals": totals, + "by_day": [dict(d) for d in by_day], + "top_items": [dict(t) for t in top_items], + } + + +def _report_extras(orders: list[dict]) -> dict: + """Доп. разрезы отчёта, общие для обоих бэкендов. На вход — строки + заказов периода: {status, source, price, table_name, created_at}. + + by_source: заказы и выручка (выданные) с сайта и из зала. + by_hour: 24 корзины по локальному часу создания — когда идёт поток. + top_tables: самые «горячие» столы (без отменённых).""" + by_source = { + "site": {"orders": 0, "revenue": 0}, + "hall": {"orders": 0, "revenue": 0}, + } + by_hour = [{"hour": h, "orders": 0, "revenue": 0} for h in range(24)] + tables: dict[str, dict] = {} + for o in orders: + status, source = o["status"], o.get("source") or "site" + if status != "cancelled": + src = by_source.setdefault(source, {"orders": 0, "revenue": 0}) + src["orders"] += 1 + bucket = by_hour[o["created_at"].astimezone(BAR_TZ).hour] + bucket["orders"] += 1 + t = tables.setdefault(o.get("table_name") or "—", + {"table": o.get("table_name") or "—", "orders": 0, "revenue": 0}) + t["orders"] += 1 + if status == "done": + src = by_source.setdefault(source, {"orders": 0, "revenue": 0}) + src["revenue"] += o["price"] + by_hour[o["created_at"].astimezone(BAR_TZ).hour]["revenue"] += o["price"] + if o.get("table_name") in tables: + tables[o["table_name"]]["revenue"] += o["price"] + top_tables = sorted(tables.values(), key=lambda x: (-x["orders"], -x["revenue"]))[:5] + return { + "by_source": by_source, + "by_hour": by_hour, + "top_tables": top_tables, + } + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def bar_tz_name() -> str | timedelta: + """Зона бара для SQL (AT TIME ZONE). + + Отдаём IANA-имя ('Asia/Yekaterinburg') из переменной TZ — Postgres + понимает его напрямую. Без IANA-имени возвращаем timedelta: psycopg + свяжет его как interval, а не как текст — текст '+05:00' Postgres + трактует по POSIX-правилу с ОБРАТНЫМ знаком и уводит день не туда.""" + env_tz = os.environ.get("TZ", "").strip() + if env_tz and "/" in env_tz: + try: + import zoneinfo + + zoneinfo.ZoneInfo(env_tz) + return env_tz + except Exception: + pass + # без IANA-имени в TZ — фиксированное барное смещение (как BAR_TZ), + # а не пояс сервера: SQL-окна отчётов обязаны совпадать с python-часами + return timedelta(hours=5) + + +# ---------------------------------------------------------------- пароли + +def hash_password(password: str) -> str: + salt = secrets.token_hex(16) + digest = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt), 200_000) + return f"pbkdf2_sha256$200000${salt}${digest.hex()}" + + +def verify_password(password: str, stored: str) -> bool: + try: + _, iterations, salt, expected = stored.split("$") + digest = hashlib.pbkdf2_hmac( + "sha256", password.encode(), bytes.fromhex(salt), int(iterations) + ) + return hmac.compare_digest(digest.hex(), expected) + except (ValueError, TypeError): + return False + + +# фиктивный хэш: логин несуществующей учётки тратит то же время на pbkdf2, +# что и существующей, — время ответа не выдаёт, какие логины есть в базе +_DUMMY_HASH = hash_password("no-such-account") + + +# ---------------------------------------------------------------- seed-каталог и демо-заказы + +def _read_seed(name: str, strict: bool = False) -> list[dict]: + """Строки seed-JSON из crm/seed/. + + strict=True — для старта сервера: отсутствующий или повреждённый файл + поднимает ошибку, а не возвращает пустой список. Иначе битый menu.json + при рестарте синхронно удалил бы весь каталог (F-6): sync-DELETE прошёл + бы по списку из двух хуков. В рантайме (site_mixes) остаётся мягкое + поведение — панель показывает пустой блок вместо 500.""" + path = SEED_DIR / name + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as e: + if strict: + raise RuntimeError(f"seed-файл {name} отсутствует или повреждён: {e}") from e + return [] + if not isinstance(data, list): + if strict: + raise RuntimeError(f"seed-файл {name}: ожидался JSON-массив") + return [] + return data + + +def validate_seeds() -> None: + """Проверка всех seed-файлов при старте — fail-closed (F-6).""" + for name in ("menu.json", "tobacco.json", "games.json", "mixes.json"): + _read_seed(name, strict=True) + + +def site_mixes() -> list[dict]: + """Авторские миксы сайта (crm/seed/mixes.json) — read-only во вкладке + «Миксы». В mix_recipes их нет: сайт рендерит их сам, дубли не нужны. + Только файл, без БД — общий для обоих бэкендов.""" + return _read_seed("mixes.json") + + +# Кальяны в системе — ровно два типа. Живут в каталоге CRM как обычные +# позиции (категория «КАЛЬЯНЫ»), на сайт не попадают: seed-экспорт их +# не содержит, а секции сайта рендерят только свои категории. +HOOKAH_ITEMS = [ + {"id": "hookah-1400", "name": "Кальян 1400 ₽", "category": "КАЛЬЯНЫ", "price": 1400, "portion": "лёгкий/средний"}, + {"id": "hookah-1800", "name": "Кальян 1800 ₽", "category": "КАЛЬЯНЫ", "price": 1800, "portion": "крепкий"}, +] + + +def _demo_rows() -> list[dict]: + """Несколько заказов на сегодня, чтобы демо-стенд не выглядел пустым.""" + from datetime import date, time + + day = date.today() + # часы заданы в локальной зоне: в контейнере она совпадает с UTC, + # на локальной машине демо-заказы не «переезжают» на завтра + tz = datetime.now().astimezone().tzinfo or timezone.utc + + def t(h, m): + return datetime.combine(day, time(h, m), tzinfo=tz) + + def row(number, table, waiter, status, created, accept_after, deliver_after, items): + created_at = t(*created) + accepted = created_at + accept_after if accept_after else None + delivered = accepted + deliver_after if accepted and deliver_after else None + duration = int((delivered - accepted).total_seconds()) if delivered and accepted else None + return { + "number": number, "table": table, "waiter": waiter, "status": status, + "price": sum(i["price"] * i["qty"] for i in items), + "items": json.dumps(items, ensure_ascii=False), + "created": created_at, "accepted": accepted, "delivered": delivered, + "duration": duration, + "week": order_week_start(created_at), + } + + return [ + row(143, "Стол 2", "Марк", "done", (20, 2), timedelta(minutes=3), timedelta(minutes=17), [ + {"name": "Кальян 1400 ₽", "qty": 1, "price": 1400}, + {"name": "Орехи ассорти", "qty": 1, "price": 250}, + ]), + row(144, "Стол 5", "Марк", "done", (20, 31), timedelta(minutes=3), timedelta(minutes=21), [ + {"name": "Кальян 1800 ₽", "qty": 2, "price": 1800}, + {"name": "Сырные шарики", "qty": 1, "price": 420}, + ]), + row(145, "Стол 1", "Марк", "active", (20, 55), timedelta(minutes=3), None, [ + {"name": "Кальян 1400 ₽", "qty": 1, "price": 1400}, + {"name": "Морс клюквенный", "qty": 1, "price": 200}, + ]), + row(146, "Кинозал", "Марк", "new", (21, 12), None, None, [ + {"name": "Кальян 1800 ₽", "qty": 1, "price": 1800}, + {"name": "Чай облепиховый", "qty": 2, "price": 390}, + ]), + ] + + +# ---------------------------------------------------------------- выбор бэкенда + +if BACKEND == "sqlite": + from storage_sqlite import ( # noqa: F401 + init_pool, close_pool, db, init_schema, + login_user, session_user, logout_user, + list_users, create_user, delete_user, + list_menu, set_menu_stopped, set_menu_deleted, menu_categories, + create_custom_menu_item, update_menu_item, create_custom_tobacco, + list_tobacco, set_tobacco_stopped, set_tobacco_deleted, + list_mixes, create_custom_mix, update_mix, set_mix_deleted, + list_site_mix_edits, site_mixes_edited, update_site_mix, reset_site_mix, + purge_tobacco_item, purge_mix_item, + stoplist, list_orders, menu_prices, create_order, update_order, + cancel_order_as_guest, change_password, is_known_staff_name, + orders_report, custom_catalog, find_order_by_code, + get_open_shift, clock_in, clock_out, on_shift, shift_counts, + admin_clock_in, staff_shift_roles, + hall_status, set_hall_busy, halls_list, halls_active, hall_names, + hall_create, hall_delete, staff_report, purge_menu_item, + set_menu_stock, first_order_dt, + weak_default_logins, + # postgres-only разделы: в sqlite-режиме это стабы с честным 501 (F-11) + board_activity, board_create, board_delete, board_guard, board_rename, + boards_list, game_create, game_delete, game_get, game_set_deleted, + game_update, games_list, hall_reserve, hall_reserve_cancel, + note_assignee_ok, note_create, note_delete, note_get, note_update, + notes_list, reset_order_numbering, set_menu_accent, + ) +else: + import psycopg + from psycopg.rows import dict_row + from psycopg_pool import ConnectionPool + + _pool: ConnectionPool | None = None + + def _conninfo() -> str: + """Строка подключения: DATABASE_URL или сборка из PG_* переменных. + + keepalives/connect_timeout — чтобы запрос при «зависшей» БД отваливался + по таймауту, а не висел вечно и не копил потоки ThreadingHTTPServer.""" + libpq = ( + "keepalives=1 keepalives_idle=30 keepalives_interval=5" + " keepalives_count=3 connect_timeout=5" + ) + url = os.environ.get("DATABASE_URL") + if url: + return url + ("&" if "?" in url else "?") + libpq.replace(" ", "&") + return ( + f"host={os.environ.get('PG_HOST', 'db')}" + f" port={os.environ.get('PG_PORT', '5432')}" + f" dbname={os.environ.get('PG_DB', 'neon_crm')}" + f" user={os.environ.get('PG_USER', 'neon')}" + f" password={os.environ.get('PG_PASSWORD', '')}" + f" {libpq}" + ) + + def init_pool() -> None: + global _pool + _pool = ConnectionPool( + conninfo=_conninfo(), + min_size=1, + max_size=10, + open=True, + # dict_row: обращения к полям по имени (r["number"] и т.д.); + # statement_timeout — от «зависших» запросов к БД + kwargs={"row_factory": dict_row, "options": "-c statement_timeout=10000"}, + ) + + def close_pool() -> None: + if _pool is not None: + _pool.close() + _pool = None + + def db(): + if _pool is None: + raise RuntimeError("pool is not initialised") + return _pool.connection() + + # ---------------------------------------------------------------- схема + + SCHEMA = """ + CREATE TABLE IF NOT EXISTS orders ( + id BIGSERIAL PRIMARY KEY, + number INTEGER NOT NULL, + week_start DATE NOT NULL, -- «неделя нумерации» (вторник, см. order_week_start) + table_name TEXT NOT NULL, + guest_name TEXT, + guest_phone TEXT, + source TEXT NOT NULL DEFAULT 'site', + waiter TEXT, + status TEXT NOT NULL DEFAULT 'new' + CHECK (status IN ('new', 'active', 'done', 'cancelled')), + price INTEGER NOT NULL DEFAULT 0, + items JSONB NOT NULL DEFAULT '[]'::jsonb, + comment TEXT, + track_code TEXT, + stock_applied BOOLEAN NOT NULL DEFAULT FALSE, -- штучные позиции уже списаны (статус done) + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + accepted_at TIMESTAMPTZ, + delivered_at TIMESTAMPTZ, + duration_seconds INTEGER + ); + + CREATE INDEX IF NOT EXISTS orders_status_idx ON orders (status, number DESC); + CREATE INDEX IF NOT EXISTS orders_created_idx ON orders (created_at DESC); + -- уникальность номера внутри недели создаёт _migrate_order_week(): + -- на старой базе колонки week_start ещё нет, когда выполняется SCHEMA + + CREATE TABLE IF NOT EXISTS menu_items ( + id TEXT PRIMARY KEY, -- id позиции на сайте (напр. 'bud') + name TEXT NOT NULL, + category TEXT NOT NULL, + price INTEGER NOT NULL DEFAULT 0, + portion TEXT, + stopped BOOLEAN NOT NULL DEFAULT FALSE, + custom BOOLEAN NOT NULL DEFAULT FALSE, -- TRUE: добавлена владельцем через CRM + deleted BOOLEAN NOT NULL DEFAULT FALSE, -- TRUE: удалена владельцем (мягко) + accent TEXT, -- подсветка карточки на сайте (#hex, CRM) + -- поштучный учёт (пиво/энергетики банками): NULL — учёт выключен, + -- число — остаток в штуках; 0 → позиция уходит в стоп-лист сама + stock INTEGER, + auto_stopped BOOLEAN NOT NULL DEFAULT FALSE, -- TRUE: в стоп ушло само (stock = 0), + -- пополнение вернёт в продажу + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE INDEX IF NOT EXISTS menu_stopped_idx ON menu_items (stopped); + + CREATE TABLE IF NOT EXISTS tobacco_flavors ( + id TEXT PRIMARY KEY, -- id вкуса на сайте (напр. 'bb-cola') + brand TEXT NOT NULL, + flavor TEXT NOT NULL, + note TEXT, + strength INTEGER NOT NULL DEFAULT 2, + cooling BOOLEAN NOT NULL DEFAULT FALSE, + stopped BOOLEAN NOT NULL DEFAULT FALSE, + custom BOOLEAN NOT NULL DEFAULT FALSE, -- TRUE: добавлен владельцем через CRM + deleted BOOLEAN NOT NULL DEFAULT FALSE, -- TRUE: удалён владельцем (мягко) + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE INDEX IF NOT EXISTS tobacco_stopped_idx ON tobacco_flavors (stopped); + + -- настройки CRM (ключ-значение): сброс нумерации заказов и т.п. + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + -- Миксы владельца: состав из вкусов табаков без долей — пропорции + -- подбирает кальянщик. Отдаются на сайт через /api/catalog. + -- strength/cooling/price — расширенная информация микса (все NULL — + -- не заданы): сайт показывает их в карточке «Миксы мастера». + -- tags — ярлыки для AI-генератора сайта («ягоды», «цитрус», «лёд»…): + -- по пересечению тегов миксы мастера подмешиваются в выдачу ИИ. + CREATE TABLE IF NOT EXISTS mix_recipes ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + flavors JSONB NOT NULL DEFAULT '[]'::jsonb, -- [{id, brand, flavor}] + note TEXT, + strength INTEGER, -- крепость 1–5 + cooling INTEGER, -- холодок 0–3 + price INTEGER, -- цена, ₽ + tags JSONB NOT NULL DEFAULT '[]'::jsonb, -- ["ягоды", ...] + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted BOOLEAN NOT NULL DEFAULT FALSE + ); + + -- Правки витринных миксов сайта («Авторские миксы сайта», seed/mixes.json): + -- строка на микс появляется, только когда владелец его отредактировал. + -- NULL — поле не менялось, сайт берёт исходное значение из своего каталога. + -- flavors NULL — состав не менялся; иначе [{id, brand, flavor}] из справочника. + CREATE TABLE IF NOT EXISTS site_mix_edits ( + id TEXT PRIMARY KEY, -- id витринного микса ('showcase-N') + name TEXT, + note TEXT, + strength INTEGER, + cooling INTEGER, + price INTEGER, + tags JSONB, + flavors JSONB, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + CREATE TABLE IF NOT EXISTS crm_users ( + login TEXT PRIMARY KEY, + password_hash TEXT NOT NULL, -- pbkdf2_sha256$iter$salt$hex + name TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'waiter', 'hookah')), + -- NF-11: мягкое удаление (увольнение). Смены и заказы бывшего + -- сотрудника остаются в базе — физический DELETE каскадом стирал + -- зарплатную историю. Строку скрывают list_users/login_user/ + -- staff_report/staff_shift_roles и др.; восстановление — вручную SQL. + deleted BOOLEAN NOT NULL DEFAULT FALSE + ); + + CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + login TEXT NOT NULL REFERENCES crm_users(login) ON DELETE CASCADE, + role TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL + ); + + CREATE TABLE IF NOT EXISTS shifts ( + id BIGSERIAL PRIMARY KEY, + login TEXT NOT NULL REFERENCES crm_users(login) ON DELETE CASCADE, + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + ended_at TIMESTAMPTZ + ); + CREATE INDEX IF NOT EXISTS shifts_login_idx ON shifts (login); + + -- Занятость столов и зон: busy_until NULL — занят без таймера, + -- до ручного освобождения. Срок таймера истёк — освобождается сам. + CREATE TABLE IF NOT EXISTS hall_status ( + name TEXT PRIMARY KEY, + busy BOOLEAN NOT NULL DEFAULT FALSE, + busy_by TEXT, + busy_until TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + -- Справочник столов и зон: владелец добавляет и удаляет столы из CRM. + -- HALLS ниже — только стартовый сид (ON CONFLICT DO NOTHING: удалённый + -- владельцем стол при рестарте не воскресает). + CREATE TABLE IF NOT EXISTS halls_def ( + name TEXT PRIMARY KEY, + sub TEXT, + sort INTEGER NOT NULL DEFAULT 0, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + -- Брони столов и зон: окно «со скольки до скольки». До начала брони стол + -- можно занять как обычно (пометка занятости отдельная от брони). + -- Когда время брони наступает, hall_status() сам занимает стол до конца + -- окна (occupied_at — метка «автозанятие применено», чтобы один раз). + -- Пересечение броней на одном столе не допускается (проверяет hall_reserve); + -- отменённые остаются в истории (cancelled = TRUE). + CREATE TABLE IF NOT EXISTS hall_reservations ( + id BIGSERIAL PRIMARY KEY, + hall_name TEXT NOT NULL, + guest TEXT, + dt_from TIMESTAMPTZ NOT NULL, + dt_until TIMESTAMPTZ NOT NULL, + created_by TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + cancelled BOOLEAN NOT NULL DEFAULT FALSE, + occupied_at TIMESTAMPTZ + ); + CREATE INDEX IF NOT EXISTS idx_hall_res_active + ON hall_reservations (hall_name, dt_from) WHERE NOT cancelled; + + -- Библиотека игр бара: PS5 и настолки. Видна всем ролям (официант + -- подбирает игру гостю), добавление/удаление — админ и владелец. + -- Обложки — файлы в GAMES_MEDIA_DIR (/media/games/.). + -- seed_id: игры сайта из crm/seed/games.json (сид не воскрешает + -- удалённые: строка с seed_id остаётся даже после «удаления»); + -- deleted: мягкое удаление — корзина с восстановлением. + -- Настолки: difficulty 1–3 и «логотип» = icon (ключ иконки сайта) + -- + accent (неоновый цвет сайта), выбираются в CRM. + CREATE TABLE IF NOT EXISTS games ( + id BIGSERIAL PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('ps5', 'board')), + title TEXT NOT NULL, + note TEXT, + created_by TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + seed_id TEXT, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + difficulty INTEGER, + icon TEXT, + accent TEXT + ); + -- уникальный индекс по seed_id создаёт _migrate_games_columns(): + -- на живой базе колонки seed_id ещё нет, когда выполняется SCHEMA + + -- Доски заметок/задач: одна общая (видят все роли) и приватные + -- «владелец ↔ админ» (ровно два участника в board_members). + -- Канонический DDL с инструкциями: crm/migrate/notes_boards.sql + CREATE TABLE IF NOT EXISTS boards ( + id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('shared', 'private')), + title TEXT NOT NULL, + created_by_login TEXT NOT NULL REFERENCES crm_users(login), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + -- участники приватных досок; общая доска доступна всем без проверки + CREATE TABLE IF NOT EXISTS board_members ( + board_id INTEGER NOT NULL REFERENCES boards(id) ON DELETE CASCADE, + user_login TEXT NOT NULL REFERENCES crm_users(login) ON DELETE CASCADE, + PRIMARY KEY (board_id, user_login) + ); + + -- заметка = задача; к сменам не привязана — доступна в любое время + -- по факту логина (владелец ставит задачи из дома, персонал проверяет + -- до выхода). updated_at ведётся через clock_timestamp() — см. notes_list + -- Логины автора/исполнителя без FK (как orders.waiter): удаление + -- сотрудника не должно ломать его задачи — имя подтягивается через + -- LEFT JOIN, у удалённого остаётся логин. + CREATE TABLE IF NOT EXISTS notes ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + board_id INTEGER NOT NULL REFERENCES boards(id) ON DELETE CASCADE, + author_login TEXT NOT NULL, + assignee_login TEXT, + title TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'open' + CHECK (status IN ('open', 'in_progress', 'done')), + important BOOLEAN NOT NULL DEFAULT FALSE, + due_date DATE, + done_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + CREATE INDEX IF NOT EXISTS idx_notes_board ON notes (board_id, status); + CREATE INDEX IF NOT EXISTS idx_notes_assignee ON notes (assignee_login) WHERE status <> 'done'; + + -- журнал действий (created/status/assigned/edited/deleted/board_created). + -- note_id ON DELETE SET NULL: запись переживает заметку, id удалённой — + -- в details (по нему клиенты узнают об удалениях при опросе с ?since=). + -- user_login без FK — журнал переживает удаление сотрудника + CREATE TABLE IF NOT EXISTS note_activity ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + board_id INTEGER NOT NULL REFERENCES boards(id) ON DELETE CASCADE, + note_id BIGINT REFERENCES notes(id) ON DELETE SET NULL, + user_login TEXT NOT NULL, + action TEXT NOT NULL, + details TEXT NOT NULL DEFAULT '', + -- clock_timestamp, а не now(): now() замирает на старте транзакции, + -- и удаление из «длинной» транзакции навсегда выпадало из ?since-курсора + created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp() + ); + + -- Append-only журнал операций с заказами (NF-03): строка на каждую смену + -- статуса и атрибуции через PATCH /api/orders/. Финансовая целостность: + -- кто, когда и что перевёл. user_login без FK — журнал переживает + -- удаление сотрудника (как note_activity); order_id без FK — журнал + -- переживает и возможную вычистку старых заказов. Записей изменения + -- в коде нет — таблица заполняется только update_order'ом. + CREATE TABLE IF NOT EXISTS orders_audit ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + order_id BIGINT NOT NULL, + user_login TEXT NOT NULL, + action TEXT NOT NULL, -- 'status' | 'waiter' + old_value TEXT, + new_value TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp() + ); + CREATE INDEX IF NOT EXISTS orders_audit_order_idx ON orders_audit (order_id, id); + """ + + def init_schema() -> None: + with db() as conn: + conn.execute(SCHEMA) + # стоп-лист переехал с брендов на вкусы: старая таблица больше не нужна + conn.execute("DROP TABLE IF EXISTS tobacco_brands") + _migrate_order_status_check() + _migrate_role_check() + _migrate_catalog_columns() + _migrate_order_columns() + _migrate_order_week() + _migrate_games_columns() + _migrate_games_fields() + _migrate_menu_accent() + _migrate_mix_columns() + _migrate_app_settings() + _migrate_hall_res_columns() + _migrate_shift_unique() + _migrate_crm_users_deleted() + _migrate_note_activity_clock() + _seed_users() + _seed_catalog() + _seed_games() + _seed_halls() + _maybe_seed_demo_orders() + boards_seed_shared() + + def _migrate_shift_unique() -> None: + """F-20: одна открытая смена на логин. Двойной clock-in в гонке двух + устройств больше не создаёт вторую открытую смену: partial unique + индекс + перехват unique violation в clock_in. Старые дубли (созданные + до индекса) закрываем, оставляя самую раннюю.""" + with db() as conn: + conn.execute(""" + UPDATE shifts SET ended_at = now() + WHERE ended_at IS NULL + AND id NOT IN ( + SELECT MIN(id) FROM shifts WHERE ended_at IS NULL GROUP BY login + ) + """) + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS shifts_open_unique_idx" + " ON shifts (login) WHERE ended_at IS NULL" + ) + + def _migrate_crm_users_deleted() -> None: + """NF-11: колонка deleted в crm_users на живых базах (мягкое удаление + сотрудников вместо каскадного стирания смен). Идемпотентно.""" + with db() as conn: + conn.execute( + "ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS deleted BOOLEAN NOT NULL DEFAULT FALSE" + ) + + def _migrate_note_activity_clock() -> None: + """Гонка ?since-курсора заметок: created_at с DEFAULT now() замирал на + старте транзакции, и удаление из «длинной» транзакции навсегда выпадало + из реплея. На живой базе переопределяем дефолт на clock_timestamp() + (в SCHEMA он уже такой для новых баз). Идемпотентно.""" + with db() as conn: + conn.execute( + "ALTER TABLE note_activity" + " ALTER COLUMN created_at SET DEFAULT clock_timestamp()" + ) + + def _seed_halls() -> None: + """Стартовый справочник столов в halls_def: HALLS — канонический + список при первом старте; на живой базе сид не перетирает правки + владельца (удалённый стол остаётся удалённым).""" + with db() as conn: + for i, h in enumerate(HALLS): + conn.execute( + "INSERT INTO halls_def (name, sub, sort) VALUES (%s, %s, %s)" + " ON CONFLICT (name) DO NOTHING", + (h["name"], h["sub"], i), + ) + + def _migrate_order_week() -> None: + """Еженедельная нумерация заказов. + + На старых базах number был глобально уникален (constraint + orders_number_key) и колонки week_start не было: снимаем старую + уникальность, дозаполняем неделю по created_at и строим уникальный + индекс (week_start, number). Все шаги идемпотентны. + """ + with db() as conn: + conn.execute("ALTER TABLE orders ADD COLUMN IF NOT EXISTS week_start DATE") + legacy = conn.execute( + """ + SELECT con.conname AS name + FROM pg_constraint con + JOIN pg_attribute att + ON att.attrelid = con.conrelid AND att.attnum = ANY (con.conkey) + WHERE con.conrelid = 'orders'::regclass AND con.contype = 'u' + AND att.attname = 'number' + """ + ).fetchall() + for r in legacy: + conn.execute(f"ALTER TABLE orders DROP CONSTRAINT {r['name']}") + nulls = conn.execute( + "SELECT id, created_at FROM orders WHERE week_start IS NULL" + ).fetchall() + for r in nulls: + conn.execute( + "UPDATE orders SET week_start = %s WHERE id = %s", + (order_week_start(r["created_at"]), r["id"]), + ) + conn.execute("ALTER TABLE orders ALTER COLUMN week_start SET NOT NULL") + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS orders_week_number_idx" + " ON orders (week_start, number)" + ) + + def _migrate_order_columns() -> None: + """Колонки comment/track_code/stock_applied добавлены позже — дополняем живую таблицу. + + Индекс уникальности кода создаём здесь (а не в SCHEMA): на старой базе + колонки ещё нет, когда выполняется SCHEMA. Для новой базы — тоже + сработает, IF NOT EXISTS идемпотентен. NULL-коды старых заказов + уникальности не нарушают (частичный индекс). + """ + with db() as conn: + conn.execute("ALTER TABLE orders ADD COLUMN IF NOT EXISTS comment TEXT") + conn.execute("ALTER TABLE orders ADD COLUMN IF NOT EXISTS track_code TEXT") + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS orders_track_code_idx" + " ON orders (track_code) WHERE track_code IS NOT NULL" + ) + # поштучный учёт: флаг «штучные позиции этого заказа уже списаны» + conn.execute( + "ALTER TABLE orders ADD COLUMN IF NOT EXISTS stock_applied BOOLEAN NOT NULL DEFAULT FALSE" + ) + + def _migrate_role_check() -> None: + """Базы до появления новых ролей имеют CHECK без них — заменяем + на полный список: владелец, администратор, официант, кальянщик.""" + want = "('owner', 'admin', 'waiter', 'hookah')" + with db() as conn: + rows = conn.execute( + "SELECT pg_get_constraintdef(oid) AS def FROM pg_constraint" + " WHERE conrelid = 'crm_users'::regclass AND contype = 'c'" + ).fetchall() + if any("hookah" in (r["def"] or "") for r in rows): + return + with db() as conn: + conn.execute("ALTER TABLE crm_users DROP CONSTRAINT IF EXISTS crm_users_role_check") + conn.execute( + "ALTER TABLE crm_users ADD CONSTRAINT crm_users_role_check" + f" CHECK (role IN {want})" + ) + + def _migrate_catalog_columns() -> None: + """Колонки custom/created_at/deleted/stock добавлены позже — дополняем живые таблицы.""" + with db() as conn: + conn.execute( + "ALTER TABLE menu_items ADD COLUMN IF NOT EXISTS custom BOOLEAN NOT NULL DEFAULT FALSE" + ) + conn.execute( + "ALTER TABLE menu_items ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT now()" + ) + conn.execute( + "ALTER TABLE menu_items ADD COLUMN IF NOT EXISTS deleted BOOLEAN NOT NULL DEFAULT FALSE" + ) + conn.execute( + "ALTER TABLE menu_items ADD COLUMN IF NOT EXISTS accent TEXT" + ) + # поштучный учёт: NULL — учёт выключен, число — остаток в штуках + conn.execute("ALTER TABLE menu_items ADD COLUMN IF NOT EXISTS stock INTEGER") + conn.execute( + "ALTER TABLE menu_items ADD COLUMN IF NOT EXISTS auto_stopped BOOLEAN NOT NULL DEFAULT FALSE" + ) + conn.execute( + "ALTER TABLE tobacco_flavors ADD COLUMN IF NOT EXISTS custom BOOLEAN NOT NULL DEFAULT FALSE" + ) + conn.execute( + "ALTER TABLE tobacco_flavors ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT now()" + ) + conn.execute( + "ALTER TABLE tobacco_flavors ADD COLUMN IF NOT EXISTS deleted BOOLEAN NOT NULL DEFAULT FALSE" + ) + + def _migrate_mix_columns() -> None: + """mix_recipes до появления расширенной информации микса: strength/cooling/price/tags.""" + with db() as conn: + conn.execute("ALTER TABLE mix_recipes ADD COLUMN IF NOT EXISTS strength INTEGER") + conn.execute("ALTER TABLE mix_recipes ADD COLUMN IF NOT EXISTS cooling INTEGER") + conn.execute("ALTER TABLE mix_recipes ADD COLUMN IF NOT EXISTS price INTEGER") + conn.execute( + "ALTER TABLE mix_recipes ADD COLUMN IF NOT EXISTS tags JSONB NOT NULL DEFAULT '[]'::jsonb" + ) + + def _migrate_games_columns() -> None: + """games до появления сид-игр сайта: добавляем seed_id/deleted. + Уникальный индекс по seed_id — частичный (NULL у игр владельца).""" + with db() as conn: + conn.execute("ALTER TABLE games ADD COLUMN IF NOT EXISTS seed_id TEXT") + conn.execute( + "ALTER TABLE games ADD COLUMN IF NOT EXISTS deleted BOOLEAN NOT NULL DEFAULT FALSE" + ) + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS games_seed_id_idx" + " ON games (seed_id) WHERE seed_id IS NOT NULL" + ) + + def _migrate_hall_res_columns() -> None: + """Брони до появления автозанятия: добавляем occupied_at + (метка однократного самозанятия стола в hall_status).""" + with db() as conn: + conn.execute( + "ALTER TABLE hall_reservations ADD COLUMN IF NOT EXISTS occupied_at TIMESTAMPTZ" + ) + + def _migrate_games_fields() -> None: + """games до появления настольных полей: difficulty/icon/accent.""" + with db() as conn: + conn.execute("ALTER TABLE games ADD COLUMN IF NOT EXISTS difficulty INTEGER") + conn.execute("ALTER TABLE games ADD COLUMN IF NOT EXISTS icon TEXT") + conn.execute("ALTER TABLE games ADD COLUMN IF NOT EXISTS accent TEXT") + + def _migrate_menu_accent() -> None: + """menu_items до появления подсветки: добавляем accent (#hex).""" + with db() as conn: + conn.execute("ALTER TABLE menu_items ADD COLUMN IF NOT EXISTS accent TEXT") + + def _migrate_app_settings() -> None: + with db() as conn: + conn.execute( + "CREATE TABLE IF NOT EXISTS app_settings (" + " key TEXT PRIMARY KEY, value TEXT NOT NULL)" + ) + + def _migrate_order_status_check() -> None: + """Базы, созданные до появления отмены, имеют CHECK без 'cancelled'. + + Если constraint ещё не знает про отмену — заменяем его. Для новой + базы constraint уже правильный, и миграция ничего не делает. + """ + with db() as conn: + rows = conn.execute( + "SELECT pg_get_constraintdef(oid) AS def FROM pg_constraint" + " WHERE conrelid = 'orders'::regclass AND contype = 'c'" + ).fetchall() + if any("cancelled" in (r["def"] or "") for r in rows): + return + with db() as conn: + conn.execute("ALTER TABLE orders DROP CONSTRAINT IF EXISTS orders_status_check") + conn.execute( + "ALTER TABLE orders ADD CONSTRAINT orders_status_check" + " CHECK (status IN ('new', 'active', 'done', 'cancelled'))" + ) + + # ---------------------------------------------------------------- пользователи/сессии + + def _seed_users() -> None: + """Учётки берутся из окружения; при их отсутствии — значения по умолчанию.""" + users = [ + ( + os.environ.get("CRM_OWNER_LOGIN", "owner"), + os.environ.get("CRM_OWNER_PASSWORD", "neon2026"), + os.environ.get("CRM_OWNER_NAME", "Нурлан"), + "owner", + ), + ( + os.environ.get("CRM_ADMIN_LOGIN", "admin"), + os.environ.get("CRM_ADMIN_PASSWORD", "neon2026"), + os.environ.get("CRM_ADMIN_NAME", "Ирина"), + "admin", + ), + ( + os.environ.get("CRM_WAITER_LOGIN", "waiter.mark"), + os.environ.get("CRM_WAITER_PASSWORD", "neon2026"), + os.environ.get("CRM_WAITER_NAME", "Марк"), + "waiter", + ), + ] + with db() as conn: + for login, password, name, role in users: + conn.execute( + """ + INSERT INTO crm_users (login, password_hash, name, role) + VALUES (%s, %s, %s, %s) + ON CONFLICT (login) DO NOTHING + """, + (login, hash_password(password), name, role), + ) + + def list_users() -> list[dict]: + with db() as conn: + rows = conn.execute( + "SELECT login, name, role FROM crm_users WHERE NOT deleted ORDER BY role, login" + ).fetchall() + return [dict(r) for r in rows] + + def create_user(login: str, password: str, name: str, role: str) -> dict | None | str: + """Новый сотрудник. None — логин уже занят; 'name_taken' — имя уже + есть (атрибуция заказов по имени требует уникальности, F-15).""" + with db() as conn: + exists = conn.execute( + "SELECT 1 FROM crm_users WHERE login = %s", (login,) + ).fetchone() + if exists: + return None + name_taken = conn.execute( + "SELECT 1 FROM crm_users WHERE name = %s", (name,) + ).fetchone() + if name_taken: + return "name_taken" + row = conn.execute( + """ + INSERT INTO crm_users (login, password_hash, name, role) + VALUES (%s, %s, %s, %s) + RETURNING login, name, role + """, + (login, hash_password(password), name, role), + ).fetchone() + return dict(row) + + def delete_user(login: str) -> bool: + """Мягкое удаление официанта/кальянщика/администратора (NF-11): + помечаем deleted и гасим сессии в одной транзакции. Владельца удалить + нельзя. Смены и заказы бывшего сотрудника остаются в базе — раньше + физический DELETE каскадом стирал смены (зарплатную историю).""" + with db() as conn: + row = conn.execute( + "UPDATE crm_users SET deleted = TRUE" + " WHERE login = %s AND role IN ('admin', 'waiter', 'hookah') AND NOT deleted" + " RETURNING login", + (login,), + ).fetchone() + if row is None: + return False + conn.execute("DELETE FROM sessions WHERE login = %s", (login,)) + return True + + def login_user(login: str, password: str) -> dict | None: + with db() as conn: + row = conn.execute( + # NF-11: удалённого (deleted) сотрудника не пускаем — для + # перебора он неотличим от несуществующего (dummy-хэш ниже) + "SELECT login, password_hash, name, role FROM crm_users" + " WHERE login = %s AND NOT deleted", + (login,), + ).fetchone() + if row is None: + verify_password(password, _DUMMY_HASH) + return None + if not verify_password(password, row["password_hash"]): + return None + + token = secrets.token_urlsafe(32) + with db() as conn: + conn.execute("DELETE FROM sessions WHERE expires_at < now()") + conn.execute( + "INSERT INTO sessions (token, login, role, expires_at)" + " VALUES (%s, %s, %s, now() + %s)", + (token, row["login"], row["role"], SESSION_TTL), + ) + return {"token": token, "login": row["login"], "name": row["name"], "role": row["role"]} + + def session_user(token: str) -> dict | None: + if not token: + return None + with db() as conn: + row = conn.execute( + """ + SELECT u.login, u.name, u.role, s.expires_at + FROM sessions s JOIN crm_users u ON u.login = s.login + WHERE s.token = %s AND s.expires_at > now() + -- ревью NF-11: сессия, влезшая в окно гонки + -- login_user→delete_user, удалённого не аутентифицирует + AND NOT u.deleted + """, + (token,), + ).fetchone() + # Скольжение сессии только для сменных ролей: планшеты бара и + # кухни опрашивают API каждые 10 секунд — пока устройство живо, + # сессия не истечёт посреди смены и никому не надо перелогиниваться. + # Владелец/админ входят как раньше — раз в 12 часов. + if row and row["role"] in ("waiter", "hookah"): + remaining = row["expires_at"] - datetime.now(timezone.utc) + if remaining < timedelta(hours=2): + conn.execute( + "UPDATE sessions SET expires_at = now() + %s WHERE token = %s", + (SESSION_TTL, token), + ) + if row is None: + return None + return {"login": row["login"], "name": row["name"], "role": row["role"]} + + def logout_user(token: str) -> None: + with db() as conn: + conn.execute("DELETE FROM sessions WHERE token = %s", (token,)) + + def weak_default_logins(default: str = "neon2026") -> list[str]: + """Логины, чей пароль в БД фактически совпадает с известным + ПУБЛИЧНЫМ дефолтом (F-13): литералы из истории проекта — 'neon2026' + и опечатка 'noen2026' из старого .env. Значения из окружения сюда + не входят: пароль из .env — и есть заданный секрет, а не дефолт. + Сид создаёт учётки один раз (ON CONFLICT DO NOTHING), поэтому смена + пароля в .env на живой базе ничего не меняет — проверяем факт.""" + candidates = {default, "neon2026", "noen2026"} + with db() as conn: + rows = conn.execute( + "SELECT login, password_hash FROM crm_users WHERE NOT deleted" + ).fetchall() + hits = { + r["login"] for r in rows + if any(verify_password(c, r["password_hash"]) for c in candidates) + } + return sorted(hits) + + def change_password(login: str, current_password: str, new_password: str, + keep_token: str | None = None) -> bool: + """Смена пароля своей учётки (F-16): текущий пароль обязателен. + Прочие сессии этого логина гасятся — украденный токен перестаёт + работать сразу после смены. False — логина нет или текущий пароль неверен.""" + with db() as conn: + row = conn.execute( + "SELECT password_hash FROM crm_users WHERE login = %s", (login,) + ).fetchone() + if row is None or not verify_password(current_password, row["password_hash"]): + return False + conn.execute( + "UPDATE crm_users SET password_hash = %s WHERE login = %s", + (hash_password(new_password), login), + ) + conn.execute( + "DELETE FROM sessions WHERE login = %s AND token IS DISTINCT FROM %s", + (login, keep_token), + ) + return True + + def reset_password(login: str, new_password: str) -> bool: + """Сброс пароля чужой учётки владельцем (без текущего пароля). + Все сессии сброшенной учётки гасятся — сотрудник на всех устройствах + улетает на экран входа. False — логина нет (или учётка удалена).""" + with db() as conn: + row = conn.execute( + "SELECT 1 FROM crm_users WHERE login = %s AND NOT deleted", (login,) + ).fetchone() + if row is None: + return False + conn.execute( + "UPDATE crm_users SET password_hash = %s WHERE login = %s", + (hash_password(new_password), login), + ) + conn.execute("DELETE FROM sessions WHERE login = %s", (login,)) + return True + + def is_known_staff_name(name: str) -> bool: + """Существует ли действующий сотрудник с таким именем или логином — + валидация атрибуции заказов (F-15): свободный текст позволял приписать + выручку чужому имени. Удалённые (NF-11) сотрудникам не считаются.""" + with db() as conn: + row = conn.execute( + "SELECT 1 FROM crm_users WHERE NOT deleted AND (name = %s OR login = %s)", + (name, name), + ).fetchone() + return row is not None + + # ---------------------------------------------------------------- каталог и стоп-лист + + def _seed_catalog() -> None: + """Наполняет меню и табаки из seed-файлов, экспортированных с сайта. + + Существующие позиции не трогаем — стоп-лист живёт в БД и переживает + перезапуск и обновление каталога. Позиции, которых больше нет на сайте, + удаляем, иначе они навсегда зависнут в CRM. Кальяны (два типа) добавляем + всегда — они нужны для ручных заказов в CRM. Seed читается строго: + битый файл роняет старт ДО всякой записи в БД (F-6) — иначе sync-DELETE + по списку из двух хуков вычистил бы весь каталог. + """ + menu = _read_seed("menu.json", strict=True) + [dict(h) for h in HOOKAH_ITEMS] + tobacco = _read_seed("tobacco.json", strict=True) + if not menu and not tobacco: + return + + with db() as conn: + if menu: + upsert = """ + INSERT INTO menu_items (id, name, category, price, portion, created_at) + VALUES (%(id)s, %(name)s, %(category)s, %(price)s, %(portion)s, + now() - interval '30 days') + ON CONFLICT (id) DO UPDATE + SET name = EXCLUDED.name, category = EXCLUDED.category, + price = EXCLUDED.price, portion = EXCLUDED.portion + """ + for item in menu: + conn.execute(upsert, item) + # custom-позиции владельца не трогаем и не удаляем — их на сайте нет; + # сеянные позиции — не «новинки», их created_at всегда в прошлом + conn.execute( + "DELETE FROM menu_items WHERE custom = FALSE AND NOT (id = ANY(%s))", + ([m["id"] for m in menu],), + ) + conn.execute( + "UPDATE menu_items SET created_at = now() - interval '30 days'" + " WHERE custom = FALSE" + ) + if tobacco: + upsert = """ + INSERT INTO tobacco_flavors (id, brand, flavor, note, strength, cooling, created_at) + VALUES (%(id)s, %(brand)s, %(flavor)s, %(note)s, %(strength)s, %(cooling)s, + now() - interval '30 days') + ON CONFLICT (id) DO UPDATE + SET brand = EXCLUDED.brand, flavor = EXCLUDED.flavor, + note = EXCLUDED.note, strength = EXCLUDED.strength, + cooling = EXCLUDED.cooling + """ + for item in tobacco: + conn.execute(upsert, item) + # custom-вкусы владельца не трогаем и не удаляем — их на сайте нет + conn.execute( + "DELETE FROM tobacco_flavors WHERE custom = FALSE AND NOT (id = ANY(%s))", + ([t["id"] for t in tobacco],), + ) + conn.execute( + "UPDATE tobacco_flavors SET created_at = now() - interval '30 days'" + " WHERE custom = FALSE" + ) + + def _seed_games() -> None: + """Игры сайта (crm/seed/games.json) → библиотека CRM. + + Строка с seed_id уже существует (в т.ч. удалённая) — не трогаем: + удалённая владельцем игра не воскресает при перезапуске. У живых + сид-игр обновляем название/пометку и дозаливаем обложку, если её + нет. Обложки лежат в seed/covers/, копируются в GAMES_MEDIA_DIR + под числовым id игры. + """ + seed = _read_seed("games.json", strict=True) + if not seed: + return + covers = SEED_DIR / "covers" + with db() as conn: + for g in seed: + seed_id = str(g.get("seed_id") or "").strip() + title = str(g.get("title") or "").strip() + if not seed_id or not title: + continue + row = conn.execute( + "SELECT id, deleted FROM games WHERE seed_id = %s", (seed_id,) + ).fetchone() + if row is not None: + if not row["deleted"]: + # сайт — источник названия/пометки/сложности; логотип + # (icon/accent) дозаливаем только если владелец ещё + # не выбирал свой (NULL) — его выбор не перетираем + conn.execute( + "UPDATE games SET title = %s, note = %s, difficulty = %s," + " icon = COALESCE(icon, %s), accent = COALESCE(accent, %s)" + " WHERE id = %s", + (title, g.get("note"), g.get("difficulty"), + g.get("icon"), g.get("accent"), row["id"]), + ) + _copy_seed_cover(covers, g, row["id"]) + continue + new_id = conn.execute( + """ + INSERT INTO games (kind, title, note, created_by, seed_id, + difficulty, icon, accent) + VALUES (%s, %s, %s, 'NEON сайт', %s, %s, %s, %s) + RETURNING id + """, + (g.get("kind") or "board", title, g.get("note"), seed_id, + g.get("difficulty"), g.get("icon"), g.get("accent")), + ).fetchone()["id"] + _copy_seed_cover(covers, g, new_id) + + def list_menu() -> list[dict]: + with db() as conn: + rows = conn.execute( + "SELECT id, name, category, price, portion, stopped, custom, deleted, accent," + " stock, created_at FROM menu_items ORDER BY category, name" + ).fetchall() + out = [] + for r in rows: + d = dict(r) + d["created_at"] = d["created_at"].astimezone().isoformat() + d["has_photo"] = photo_for_item(d["id"]) is not None + out.append(d) + return out + + def set_menu_accent(item_id: str, accent: str | None, conn=None) -> dict | None: + """Подсветка карточки позиции на сайте (#hex или NULL — как у всех). + NF-07: conn — внешняя транзакция мульти-полевого PATCH каталога.""" + if conn is None: + with db() as conn: + return set_menu_accent(item_id, accent, conn) + row = conn.execute( + "UPDATE menu_items SET accent = %s, updated_at = now()" + " WHERE id = %s AND deleted = FALSE" + " RETURNING id, name, category, price, accent", + (accent, item_id), + ).fetchone() + return dict(row) if row else None + + def set_menu_stopped(item_id: str, stopped: bool, conn=None) -> dict | None: + if conn is None: + with db() as conn: + return set_menu_stopped(item_id, stopped, conn) + row = conn.execute( + """ + UPDATE menu_items SET stopped = %s, auto_stopped = FALSE, updated_at = now() + WHERE id = %s AND deleted = FALSE + RETURNING id, name, category, price, portion, stopped, stock + """, + (stopped, item_id), + ).fetchone() + return dict(row) if row else None + + def set_menu_deleted(item_id: str, deleted: bool, conn=None) -> dict | None: + """Мягкое удаление/восстановление позиции (только владелец).""" + if conn is None: + with db() as conn: + return set_menu_deleted(item_id, deleted, conn) + row = conn.execute( + """ + UPDATE menu_items SET deleted = %s, updated_at = now() + WHERE id = %s + RETURNING id, name, category, price, portion, stopped, custom, deleted + """, + (deleted, item_id), + ).fetchone() + return dict(row) if row else None + + def purge_menu_item(item_id: str): + """Безвозвратное удаление позиции меню (только владелец). + + Разрешено только кастомным позициям (custom = TRUE): стартовые + позиции сайта пересоздаёт сид при рестарте — их «полное» удаление + невозможно в принципе. Фото стирается с диска; история заказов не + страдает (состав хранится снапшотом). + + True — удалена; False — позиция не найдена; 'seed' — не кастомная. + """ + with db() as conn: + row = conn.execute( + "SELECT custom FROM menu_items WHERE id = %s", (item_id,) + ).fetchone() + if row is None: + return False + if not row["custom"]: + return "seed" + conn.execute("DELETE FROM menu_items WHERE id = %s", (item_id,)) + purge_item_photos(item_id) + return True + + def menu_categories() -> list[str]: + with db() as conn: + rows = conn.execute( + "SELECT DISTINCT category FROM menu_items ORDER BY category" + ).fetchall() + return [r["category"] for r in rows] + + def set_menu_stock(item_id: str, new_stock: int | None = None, add: int | None = None, + conn=None) -> dict | None: + """Поштучный учёт позиции (владелец). + + new_stock — задать остаток абсолютно: число включит учёт (и снимет + авто-стоп, если остаток > 0), None — выключит учёт (авто-стоп снимаем: + позиция перестала быть штучной — она снова доступна; ручной стоп + остаётся). add — приход: к текущему остатку прибавить N штук + (учёт уже должен быть включён); остаток > 0 снимает авто-стоп. + NF-07: conn — внешняя транзакция мульти-полевого PATCH каталога.""" + if conn is None: + with db() as conn: + return set_menu_stock(item_id, new_stock, add, conn) + if add is not None: + row = conn.execute( + """ + UPDATE menu_items SET + stock = COALESCE(stock, 0) + %s, + stopped = CASE WHEN COALESCE(stock, 0) + %s > 0 AND auto_stopped + THEN FALSE ELSE stopped END, + auto_stopped = CASE WHEN COALESCE(stock, 0) + %s > 0 + THEN FALSE ELSE auto_stopped END, + updated_at = now() + WHERE id = %s AND deleted = FALSE AND stock IS NOT NULL + RETURNING id, name, category, price, portion, stopped, stock + """, + (add, add, add, item_id), + ).fetchone() + elif new_stock is None: + # выключение учёта: авто-стоп снимаем (позиция перестала быть + # штучной — снова доступна), ручной стоп остаётся + row = conn.execute( + """ + UPDATE menu_items SET + stock = NULL, + stopped = CASE WHEN auto_stopped THEN FALSE ELSE stopped END, + auto_stopped = FALSE, + updated_at = now() + WHERE id = %s AND deleted = FALSE + RETURNING id, name, category, price, portion, stopped, stock + """, + (item_id,), + ).fetchone() + else: + row = conn.execute( + """ + UPDATE menu_items SET + stock = %s, + stopped = CASE + WHEN %s > 0 THEN CASE WHEN auto_stopped THEN FALSE ELSE stopped END + ELSE TRUE END, + auto_stopped = (%s = 0), + updated_at = now() + WHERE id = %s AND deleted = FALSE + RETURNING id, name, category, price, portion, stopped, stock + """, + (new_stock, new_stock, new_stock, item_id), + ).fetchone() + return dict(row) if row else None + + def create_custom_menu_item( + name: str, category: str, price: int, portion: str | None, stock: int | None = None + ) -> dict: + """Новая позиция меню от владельца. Видна на сайте как «новинка» неделю. + + stock (шт) — сразу включить поштучный учёт (пиво/энергетики банками).""" + item_id = "custom-" + secrets.token_hex(4) + with db() as conn: + row = conn.execute( + """ + INSERT INTO menu_items (id, name, category, price, portion, stopped, custom, + stock, created_at) + VALUES (%s, %s, %s, %s, %s, FALSE, TRUE, %s, now()) + RETURNING id, name, category, price, portion, stock, created_at + """, + (item_id, name, category, price, portion, stock), + ).fetchone() + d = dict(row) + d["created_at"] = d["created_at"].astimezone().isoformat() + return d + + def update_menu_item(item_id: str, fields: dict, conn=None): + """Правка позиции владельцем: имя/категория/цена/порция — только + переданные поля. 'seed' — стартовая позиция сайта (правится в коде + сайта, CRM меняет лишь стоп/подсветку/остаток); None — не найдена. + NF-07: conn — внешняя транзакция мульти-полевого PATCH каталога.""" + sets, vals = [], [] + for col in ("name", "category", "price", "portion"): + if col in fields: + sets.append(f"{col} = %s") + vals.append(fields[col]) + if not sets: + return None + if conn is None: + with db() as conn: + return update_menu_item(item_id, fields, conn) + check = conn.execute( + "SELECT custom FROM menu_items WHERE id = %s", (item_id,) + ).fetchone() + if check is None: + return None + if not check["custom"]: + return "seed" + row = conn.execute( + f"UPDATE menu_items SET {', '.join(sets)}, updated_at = now()" + f" WHERE id = %s" + f" RETURNING id, name, category, price, portion, stock, created_at", + (*vals, item_id), + ).fetchone() + d = dict(row) + d["created_at"] = d["created_at"].astimezone().isoformat() + return d + + def create_custom_tobacco( + brand: str, flavor: str, note: str | None, strength: int, cooling: bool + ) -> dict: + """Новый вкус табака от владельца. Виден на сайте как «новинка» неделю.""" + flavor_id = "custom-" + secrets.token_hex(4) + with db() as conn: + row = conn.execute( + """ + INSERT INTO tobacco_flavors (id, brand, flavor, note, strength, cooling, stopped, custom, created_at) + VALUES (%s, %s, %s, %s, %s, %s, FALSE, TRUE, now()) + RETURNING id, brand, flavor, note, strength, cooling, created_at + """, + (flavor_id, brand, flavor, note, strength, cooling), + ).fetchone() + d = dict(row) + d["created_at"] = d["created_at"].astimezone().isoformat() + return d + + def list_tobacco() -> list[dict]: + with db() as conn: + rows = conn.execute( + "SELECT id, brand, flavor, note, strength, cooling, stopped, custom, deleted, created_at" + " FROM tobacco_flavors ORDER BY brand, flavor" + ).fetchall() + out = [] + for r in rows: + d = dict(r) + d["created_at"] = d["created_at"].astimezone().isoformat() + out.append(d) + return out + + def set_tobacco_deleted(flavor_id: str, deleted: bool, conn=None) -> dict | None: + """Мягкое удаление/восстановление вкуса (только владелец).""" + if conn is None: + with db() as conn: + return set_tobacco_deleted(flavor_id, deleted, conn) + row = conn.execute( + """ + UPDATE tobacco_flavors SET deleted = %s, updated_at = now() + WHERE id = %s + RETURNING id, brand, flavor, note, strength, cooling, stopped, custom, deleted + """, + (deleted, flavor_id), + ).fetchone() + return dict(row) if row else None + + def set_tobacco_stopped(flavor_id: str, stopped: bool, conn=None) -> dict | None: + if conn is None: + with db() as conn: + return set_tobacco_stopped(flavor_id, stopped, conn) + row = conn.execute( + """ + UPDATE tobacco_flavors SET stopped = %s, updated_at = now() + WHERE id = %s AND deleted = FALSE + RETURNING id, brand, flavor, note, strength, cooling, stopped + """, + (stopped, flavor_id), + ).fetchone() + return dict(row) if row else None + + # ---------------------------------------------------------------- миксы владельца + + def _mix_row(r) -> dict: + d = dict(r) + d["created_at"] = d["created_at"].astimezone().isoformat() + return d + + def list_mixes() -> list[dict]: + """Миксы владельца (не удалённые) для управления в CRM.""" + with db() as conn: + rows = conn.execute( + "SELECT id, name, flavors, note, strength, cooling, price, tags, created_at" + " FROM mix_recipes WHERE deleted = FALSE ORDER BY created_at DESC" + ).fetchall() + return [_mix_row(r) for r in rows] + + def create_custom_mix( + name: str, flavor_ids: list[str], note: str | None, + strength: int | None = None, cooling: int | None = None, price: int | None = None, + tags: list[str] | None = None, + ) -> dict | None: + """Микс от владельца: название + вкусы из справочника табаков. + + None — вкусы не выбраны или в справочнике их меньше двух. + Доли не хранятся: пропорции подбирает кальянщик. strength/cooling/price — + расширенная информация для сайта (крепость 1–5, холодок 0–3, цена ₽), + tags — ярлыки для подмешивания микса в AI-генерацию на сайте.""" + with db() as conn: + rows = conn.execute( + "SELECT id, brand, flavor FROM tobacco_flavors" + " WHERE id = ANY(%s) AND deleted = FALSE", + (flavor_ids,), + ).fetchall() + if len(rows) < 2: + return None + by_id = {r["id"]: dict(r) for r in rows} + flavors = [by_id[i] for i in flavor_ids if i in by_id] + row = conn.execute( + """ + INSERT INTO mix_recipes (id, name, flavors, note, strength, cooling, price, + tags, created_at) + VALUES (%s, %s, %s::jsonb, %s, %s, %s, %s, %s::jsonb, now()) + RETURNING id, name, flavors, note, strength, cooling, price, tags, created_at + """, + ("mix-" + secrets.token_hex(4), name, + json.dumps(flavors, ensure_ascii=False), note, strength, cooling, price, + json.dumps(tags or [], ensure_ascii=False)), + ).fetchone() + return _mix_row(row) + + def update_mix( + mix_id: str, name: str, flavor_ids: list[str], note: str | None, + strength: int | None = None, cooling: int | None = None, price: int | None = None, + tags: list[str] | None = None, + ): + """Правка микса: название, состав и расширенные поля — тот же набор, + что при создании (см. create_custom_mix). 'not_found' — микса нет + или он удалён; 'bad_flavors' — в справочнике меньше двух живых + вкусов; иначе обновлённый микс.""" + with db() as conn: + row = conn.execute( + "SELECT id FROM mix_recipes WHERE id = %s AND deleted = FALSE", + (mix_id,), + ).fetchone() + if row is None: + return "not_found" + rows = conn.execute( + "SELECT id, brand, flavor FROM tobacco_flavors" + " WHERE id = ANY(%s) AND deleted = FALSE", + (flavor_ids,), + ).fetchall() + if len(rows) < 2: + return "bad_flavors" + by_id = {r["id"]: dict(r) for r in rows} + flavors = [by_id[i] for i in flavor_ids if i in by_id] + row = conn.execute( + """ + UPDATE mix_recipes SET name = %s, flavors = %s::jsonb, note = %s, + strength = %s, cooling = %s, price = %s, tags = %s::jsonb + WHERE id = %s AND deleted = FALSE + RETURNING id, name, flavors, note, strength, cooling, price, tags, created_at + """, + (name, json.dumps(flavors, ensure_ascii=False), note, strength, cooling, + price, json.dumps(tags or [], ensure_ascii=False), mix_id), + ).fetchone() + return _mix_row(row) + + # ------------------------------------------------------- витрина сайта + + def list_site_mix_edits() -> list[dict]: + """Правки витринных миксов сайта: {id, name?, note?, strength?, + cooling?, price?, tags?, flavors?} — NULL значит «не менялось». + Отдаются публично через /api/catalog (сайт применяет к своей витрине).""" + with db() as conn: + rows = conn.execute( + "SELECT id, name, note, strength, cooling, price, tags, flavors FROM site_mix_edits" + ).fetchall() + return [dict(r) for r in rows] + + def site_mixes_edited() -> list[dict]: + """Витринные миксы сайта, слитые с правками CRM: edited=True, поверх + исходных значений наложены переопределённые поля (и состав, если правился).""" + edits = {e["id"]: e for e in list_site_mix_edits()} + out = [] + for m in site_mixes(): + row = dict(m) + e = edits.get(row["id"]) + if not e: + row["edited"] = False + out.append(row) + continue + row["edited"] = True + if e.get("name"): + row["name"] = e["name"] + if e.get("note") is not None: + row["note"] = e["note"] + if e.get("strength") is not None: + row["strength"] = e["strength"] + if e.get("cooling") is not None: + row["cooling"] = e["cooling"] + if e.get("price") is not None: + row["price"] = e["price"] + if e.get("tags"): + row["flavor_tags"] = e["tags"] + if e.get("flavors"): + row["flavors"] = e["flavors"] + out.append(row) + return out + + def update_site_mix(mix_id: str, fields: dict): + """Сохранить правку витринного микса (владелец): flavors None = состав + не менялся. Возвращает id сохранённой правки.""" + with db() as conn: + conn.execute( + """ + INSERT INTO site_mix_edits (id, name, note, strength, cooling, price, tags, flavors) + VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, %s::jsonb) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, note = EXCLUDED.note, strength = EXCLUDED.strength, + cooling = EXCLUDED.cooling, price = EXCLUDED.price, tags = EXCLUDED.tags, + flavors = EXCLUDED.flavors, updated_at = now() + """, + (mix_id, fields.get("name"), fields.get("note"), fields.get("strength"), + fields.get("cooling"), fields.get("price"), + json.dumps(fields.get("tags"), ensure_ascii=False) if fields.get("tags") is not None else None, + json.dumps(fields.get("flavors"), ensure_ascii=False) if fields.get("flavors") is not None else None), + ) + return mix_id + + def reset_site_mix(mix_id: str) -> bool: + """Вернуть витринный микс к исходному виду: правка удаляется. + True — была правка и она снята; False — правки не было.""" + with db() as conn: + cur = conn.execute("DELETE FROM site_mix_edits WHERE id = %s", (mix_id,)) + return cur.rowcount > 0 + + def purge_tobacco_item(flavor_id: str): + """Безвозвратное удаление вкуса (см. purge_menu_item): только кастомные — + стартовые вкусы сайта сид пересоздаёт при рестарте. Фото у вкусов нет; + история заказов не страдает (состав хранится снапшотом). + + True — удалён; False — вкус не найден; 'seed' — не кастомный. + """ + with db() as conn: + row = conn.execute( + "SELECT custom FROM tobacco_flavors WHERE id = %s", (flavor_id,) + ).fetchone() + if row is None: + return False + if not row["custom"]: + return "seed" + conn.execute("DELETE FROM tobacco_flavors WHERE id = %s", (flavor_id,)) + return True + + def purge_mix_item(mix_id: str): + """Безвозвратное удаление микса. В mix_recipes только миксы владельца — + авторские миксы сайта читаются из seed/mixes.json без БД (site_mixes), + сид здесь ничего не восстанавливает. + + True — удалён; False — микс не найден. + """ + with db() as conn: + row = conn.execute( + "SELECT id FROM mix_recipes WHERE id = %s", (mix_id,) + ).fetchone() + if row is None: + return False + conn.execute("DELETE FROM mix_recipes WHERE id = %s", (mix_id,)) + return True + + def set_mix_deleted(mix_id: str, deleted: bool) -> dict | None: + """Мягкое удаление/восстановление микса (только владелец).""" + with db() as conn: + row = conn.execute( + """ + UPDATE mix_recipes SET deleted = %s + WHERE id = %s AND deleted <> %s + RETURNING id, name, flavors, note, strength, cooling, price, tags, created_at + """, + (deleted, mix_id, deleted), + ).fetchone() + return _mix_row(row) if row else None + + def stoplist() -> dict: + """Публичный стоп-лист для сайта: id позиций меню и id вкусов табака.""" + with db() as conn: + menu = [r["id"] for r in conn.execute( + "SELECT id FROM menu_items WHERE stopped" + ).fetchall()] + tobacco = [r["id"] for r in conn.execute( + "SELECT id FROM tobacco_flavors WHERE stopped" + ).fetchall()] + return {"stopped_menu": menu, "stopped_tobacco": tobacco} + + # ---------------------------------------------------------------- заказы + + def order_to_json(r) -> dict: + return { + "id": r["id"], + # недельный номер для показа (обнуляется каждую неделю); + # стабильный идентификатор API — внутренний id выше + "number": r["number"], + "table": r["table_name"], + "guest_name": r["guest_name"], + "guest_phone": r["guest_phone"], + "source": r["source"], + "waiter": r["waiter"], + "status": r["status"], + "price": r["price"], + "items": r["items"], + "comment": r["comment"], + "code": r["track_code"], + "created_at": r["created_at"].astimezone().isoformat() + if r["created_at"] else None, + "accepted_at": r["accepted_at"].astimezone().isoformat() + if r["accepted_at"] else None, + "delivered_at": r["delivered_at"].astimezone().isoformat() + if r["delivered_at"] else None, + "duration_seconds": r["duration_seconds"], + } + + def list_orders() -> list[dict]: + # сортируем по внутреннему id: при недельной нумерации number + # сбрасывается и не отражает порядок создания между неделями + with db() as conn: + rows = conn.execute( + "SELECT * FROM orders ORDER BY id DESC LIMIT 500" + ).fetchall() + return [order_to_json(r) for r in rows] + + def menu_prices() -> dict[str, int]: + with db() as conn: + rows = conn.execute("SELECT id, price FROM menu_items").fetchall() + return {r["id"]: r["price"] for r in rows} + + def create_order( + table_name: str, + items: list[dict], + source: str = "site", + guest_name: str | None = None, + guest_phone: str | None = None, + comment: str | None = None, + ) -> dict: + """Создаёт заказ; цену считаем на сервере. + + Для позиций с известным id меню цену берём из БД, а не от клиента — + сумму заказа нельзя подделать с фронтенда. Номер — внутри «недели + нумерации» (MAX+1 по week_start, сбрасывается каждую неделю), код — + для отслеживания гостем. Коллизии (гонка двух заказов или код) — + повторная попытка: каждая попытка в своей транзакции. + """ + prices = menu_prices() + prepared = [] + for i in items: + # цену берём ТОЛЬКО из каталога: у заказа с сайта id всегда из + # меню, а цена клиента игнорируется даже для неизвестного id — + # иначе сумму заказа можно подделать прямым запросом к API + price = prices.get(i.get("id") or "", 0) + prepared.append({ + "id": i.get("id"), + "name": str(i.get("name", ""))[:120], + "qty": int(i.get("qty", 1)), + "price": price, + }) + total = sum(i["price"] * i["qty"] for i in prepared) + week = effective_order_week(datetime.now().astimezone()) + + for _ in range(5): + code = generate_track_code() + try: + with db() as conn: + number = conn.execute( + "SELECT COALESCE(MAX(number), 0) + 1 AS n FROM orders" + " WHERE week_start = %s", + (week,), + ).fetchone()["n"] + row = conn.execute( + """ + INSERT INTO orders (number, week_start, table_name, guest_name, + guest_phone, source, status, price, items, + comment, track_code, created_at) + VALUES (%s, %s, %s, %s, %s, %s, 'new', %s, %s::jsonb, %s, %s, now()) + RETURNING * + """, + (number, week, table_name, guest_name, guest_phone, source, total, + json.dumps(prepared, ensure_ascii=False), comment, code), + ).fetchone() + return order_to_json(row) + except psycopg.errors.UniqueViolation: + continue + raise RuntimeError("не удалось сгенерировать номер/код заказа") + + def find_order_by_code(code: str) -> dict | None: + """Заказ по коду отслеживания (публичная страница статуса на сайте).""" + with db() as conn: + row = conn.execute( + "SELECT * FROM orders WHERE track_code = %s", (code,) + ).fetchone() + return order_to_json(row) if row else None + + def _stock_touch(conn, items, sign: int) -> list[str]: + """Списать (+1) или вернуть (-1... sign=-1 — возврат) штучные позиции + заказа. items — распарсенный jsonb заказа (список {id, qty, ...}). + Возвращает id затронутых позиций: после списания среди них снимем/поставим + авто-стоп. Учёт ведётся только по позициям с включённым учётом (stock NOT NULL). + Списание без клампа (F-17): остаток может уйти в минус — это честный + дефицит; асимметрия «списали с полом, вернули целиком» создавала + фантомные банки за каждый цикл done↔undone.""" + touched: list[str] = [] + for i in items or []: + if not isinstance(i, dict): + continue + item_id = str(i.get("id") or "")[:64] + try: + qty = min(50, max(1, int(i.get("qty", 1)))) + except (TypeError, ValueError): + continue + if not item_id: + continue + if sign > 0: # выдача: списываем + row = conn.execute( + "UPDATE menu_items SET stock = stock - %s, updated_at = now()" + " WHERE id = %s AND stock IS NOT NULL AND deleted = FALSE" + " RETURNING id, stock", + (qty, item_id), + ).fetchone() + else: # возврат (заказ ушёл из «Выдан»): возвращаем на полку + row = conn.execute( + "UPDATE menu_items SET stock = stock + %s, updated_at = now()" + " WHERE id = %s AND stock IS NOT NULL AND deleted = FALSE" + " RETURNING id, stock", + (qty, item_id), + ).fetchone() + if row is not None: + touched.append(row["id"]) + return touched + + def update_order(order_id: int, status: str, waiter: str | None, + actor: dict | None = None) -> dict | str | None: + """Смена статуса по внутреннему id; метки времени и длительность считает сервер. + + NF-03: машина переходов и append-only журнал. + - Сменные роли (официант/кальянщик) ведут заказ только вперёд по + ORDER_TRANSITIONS_STAFF; из терминальных (done/cancelled) и назад — + только владелец/админ: 'bad_transition' (хендлер отвечает 403). + - Повтор того же статуса без смены официанта — no-op (повторный done + больше не перезаписывает delivered_at/длительность). + - Каждая фактическая смена статуса и атрибуции пишется в orders_audit + (кто — actor['login'], старое/новое значение, clock_timestamp). + + Штучный учёт: переход в «Выдан» списывает позиции с включённым учётом + (однократно, флаг stock_applied), вышедшие из «Выдан» — возвращает. + Позиции с остатком 0 уходят в стоп-лист автоматически.""" + with db() as conn: + row = conn.execute( + "SELECT * FROM orders WHERE id = %s", (order_id,) + ).fetchone() + if row is None: + return None + + old = row["status"] + old_waiter = row["waiter"] + actor_login = (actor or {}).get("login") or "system" + actor_role = (actor or {}).get("role") + + if status == old and (waiter is None or waiter == old_waiter): + return order_to_json(row) # повторный PATCH без изменений — no-op + + # ревью NF-03: сменная роль не может переатрибутить заказ «на + # месте» (без смены статуса) — в т.ч. переписать чужой выданный/ + # отменённый заказ на себя; атрибуцию вне перехода правит только + # владелец/админ + if status == old and waiter is not None \ + and actor_role not in ("owner", "admin"): + return "bad_transition" + + if status != old and actor_role not in ("owner", "admin") \ + and (old, status) not in ORDER_TRANSITIONS_STAFF: + return "bad_transition" + + if status != old: + ts = utcnow() + accepted, delivered, duration = row["accepted_at"], row["delivered_at"], row["duration_seconds"] + if status == "new": + accepted = delivered = duration = None + elif status == "active": + accepted = accepted or ts + delivered, duration = None, None + elif status == "cancelled": + # отмена: историю принятия оставляем, выдачи и длительности нет + delivered, duration = None, None + else: # done + delivered = ts + # длительность — только реальный отрезок «принят → выдан» + duration = int((ts - accepted).total_seconds()) if accepted else None + else: + accepted = row["accepted_at"] + delivered = row["delivered_at"] + duration = row["duration_seconds"] + + if waiter is None: + waiter = old_waiter + + was_done = old == "done" + now_done = status == "done" + applied = row["stock_applied"] + + updated = conn.execute( + """ + UPDATE orders SET status = %s, waiter = %s, accepted_at = %s, + delivered_at = %s, duration_seconds = %s + WHERE id = %s RETURNING * + """, + (status, waiter, accepted, delivered, duration, order_id), + ).fetchone() + + # журнал (NF-03): по строке на каждую фактическую смену + if status != old: + conn.execute( + "INSERT INTO orders_audit (order_id, user_login, action, old_value, new_value)" + " VALUES (%s, %s, 'status', %s, %s)", + (order_id, actor_login, old, status), + ) + if waiter != old_waiter: + conn.execute( + "INSERT INTO orders_audit (order_id, user_login, action, old_value, new_value)" + " VALUES (%s, %s, 'waiter', %s, %s)", + (order_id, actor_login, old_waiter, waiter), + ) + + # списание/возврат под атомарным предикатом на флаге (F-кл.: RMW-гонка): + # конкурентный дублирующий PATCH увидит 0 затронутых строк и не + # спишет/не вернёт товар второй раз + if now_done and not was_done and not applied: + claimed = conn.execute( + "UPDATE orders SET stock_applied = TRUE" + " WHERE id = %s AND stock_applied = FALSE", + (order_id,), + ) + if claimed.rowcount == 1: + touched = _stock_touch(conn, row["items"], sign=1) + # остаток кончился (в т.ч. ушёл в минус) → позиция в стоп-лист + if touched: + conn.execute( + "UPDATE menu_items SET stopped = TRUE, auto_stopped = TRUE," + " updated_at = now()" + " WHERE id = ANY(%s) AND stock <= 0 AND stopped = FALSE", + (touched,), + ) + elif was_done and not now_done and applied: + # заказ вернули из «Выдан» — товар вернулся на полку + released = conn.execute( + "UPDATE orders SET stock_applied = FALSE" + " WHERE id = %s AND stock_applied = TRUE", + (order_id,), + ) + if released.rowcount == 1: + touched = _stock_touch(conn, row["items"], sign=-1) + if touched: + conn.execute( + "UPDATE menu_items SET stopped = FALSE, auto_stopped = FALSE," + " updated_at = now()" + " WHERE id = ANY(%s) AND auto_stopped AND stock > 0", + (touched,), + ) + return order_to_json(updated) + + def cancel_order_as_guest(order_id: int) -> dict | None: + """Гостевая отмена по коду: атомарно ТОЛЬКО из статуса «Новый». + Предикат в UPDATE закрывает гонку с PATCH персонала (needs_validation + fp.track-cancel): если заказ ушёл из «Новый» между проверкой и отменой, + UPDATE не тронет ни строку — гость получит честный отказ. None — уже поздно.""" + with db() as conn: + row = conn.execute( + "UPDATE orders SET status = 'cancelled', delivered_at = NULL," + " duration_seconds = NULL" + " WHERE id = %s AND status = 'new' RETURNING *", + (order_id,), + ).fetchone() + return order_to_json(row) if row else None + + # ---------------------------------------------------------------- отчёты и каталог владельца + + def orders_report(dt_from, dt_to) -> dict: + """Отчёт по заказам за период [dt_from, dt_to) — timezone-aware UTC.""" + with db() as conn: + statuses = [dict(r) for r in conn.execute( + "SELECT status, COUNT(*) AS cnt, COALESCE(SUM(price), 0) AS total" + " FROM orders WHERE created_at >= %s AND created_at < %s" + " GROUP BY status", + (dt_from, dt_to), + ).fetchall()] + by_day = [dict(r) for r in conn.execute( + """ + SELECT to_char(created_at AT TIME ZONE %s, 'YYYY-MM-DD') AS day, + COUNT(*) AS cnt, + COALESCE(SUM(price) FILTER (WHERE status = 'done'), 0) AS revenue, + COUNT(*) FILTER (WHERE status = 'cancelled') AS cancelled + FROM orders + WHERE created_at >= %s AND created_at < %s + GROUP BY day ORDER BY day + """, + (bar_tz_name(), dt_from, dt_to), + ).fetchall()] + top = [dict(r) for r in conn.execute( + """ + SELECT i->>'name' AS name, + SUM((i->>'qty')::int) AS qty, + SUM((i->>'qty')::int * (i->>'price')::int) AS revenue + FROM orders o, jsonb_array_elements(o.items) i + WHERE o.created_at >= %s AND o.created_at < %s AND o.status <> 'cancelled' + GROUP BY name ORDER BY revenue DESC, qty DESC + LIMIT 10 + """, + (dt_from, dt_to), + ).fetchall()] + avg = conn.execute( + "SELECT AVG(duration_seconds) AS d FROM orders" + " WHERE created_at >= %s AND created_at < %s AND duration_seconds IS NOT NULL", + (dt_from, dt_to), + ).fetchone() + raw = conn.execute( + "SELECT status, source, price, table_name, created_at FROM orders" + " WHERE created_at >= %s AND created_at < %s", + (dt_from, dt_to), + ).fetchall() + by_day = [{"day": d["day"], "orders": d["cnt"], "revenue": d["revenue"], + "cancelled": d["cancelled"]} for d in by_day] + report = _assemble_report(statuses, by_day, top, avg["d"]) + report.update(_report_extras([dict(r) for r in raw])) + return report + + def first_order_dt() -> datetime | None: + """Самый ранний заказ — для отчёта «за всё время». None, если заказов нет.""" + with db() as conn: + row = conn.execute("SELECT MIN(created_at) AS mn FROM orders").fetchone() + return row["mn"] if row and row["mn"] else None + + def staff_report(dt_from, dt_to) -> list[dict]: + """Статистика по каждому сотруднику за период [dt_from, dt_to). + + Заказы атрибутируются по orders.waiter — там хранится имя принявшего + (валидация на PATCH: только имена/логины из crm_users). Смены — + по shifts.login; открытая смена считается до текущего момента. + Забытая смена сначала автозакрывается (F-19): иначе фантомные часы + раздували отчёт до первого случайного триггера auto_close.""" + auto_close_shifts() + with db() as conn: + order_rows = [dict(r) for r in conn.execute( + """ + SELECT u.login, u.name, u.role, + COUNT(o.id) AS orders_total, + COUNT(o.id) FILTER (WHERE o.status = 'done') AS orders_done, + COUNT(o.id) FILTER (WHERE o.status = 'cancelled') AS orders_cancelled, + COALESCE(SUM(o.price) FILTER (WHERE o.status = 'done'), 0) AS revenue, + COUNT(o.id) FILTER (WHERE o.source = 'site') AS site_orders, + COUNT(o.id) FILTER (WHERE o.source = 'hall') AS hall_orders, + AVG(o.price) FILTER (WHERE o.status = 'done') AS avg_check, + AVG(o.duration_seconds) AS avg_duration + FROM crm_users u + LEFT JOIN orders o + ON o.waiter = u.name + AND o.created_at >= %s AND o.created_at < %s + WHERE NOT u.deleted + GROUP BY u.login, u.name, u.role + """, + (dt_from, dt_to), + ).fetchall()] + shift_rows = [dict(r) for r in conn.execute( + """ + SELECT s.login, + COUNT(*) AS shifts, + COALESCE(SUM(EXTRACT(EPOCH FROM + (COALESCE(s.ended_at, now()) - s.started_at))), 0) AS seconds + FROM shifts s + WHERE s.started_at >= %s AND s.started_at < %s + GROUP BY s.login + """, + (dt_from, dt_to), + ).fetchall()] + shifts_by_login = {r["login"]: r for r in shift_rows} + out = [] + for r in order_rows: + sh = shifts_by_login.get(r["login"]) + out.append({ + "login": r["login"], + "name": r["name"], + "role": r["role"], + "orders_total": r["orders_total"], + "orders_done": r["orders_done"], + "orders_cancelled": r["orders_cancelled"], + "revenue": r["revenue"], + "avg_check": round(r["avg_check"]) if r["avg_check"] is not None else None, + "avg_duration_seconds": round(r["avg_duration"]) if r["avg_duration"] is not None else None, + "site_orders": r["site_orders"], + "hall_orders": r["hall_orders"], + "shifts": sh["shifts"] if sh else 0, + "hours_seconds": round(sh["seconds"]) if sh else 0, + }) + out.sort(key=lambda r: (-r["orders_done"], -r["revenue"], r["name"])) + return out + + def custom_catalog() -> dict: + """Публичный список позиций, добавленных владельцем через CRM, — + сайт подмешивает их в меню и табаки и помечает «новинку» на неделю. + Удалённые позиции сюда не попадают: их id сайт фильтрует из каталога. + accents: подсветка (#hex) любых неудалённых позиций с выставленным + акцентом — сайт накладывает её поверх своей (правка 2: новые позиции + перестают быть «все одного цвета»).""" + with db() as conn: + menu = conn.execute( + "SELECT id, name, category, price, portion, accent, created_at FROM menu_items" + " WHERE custom = TRUE AND deleted = FALSE ORDER BY created_at DESC" + ).fetchall() + tobacco = conn.execute( + "SELECT id, brand, flavor, note, strength, cooling, created_at" + " FROM tobacco_flavors WHERE custom = TRUE AND deleted = FALSE" + " ORDER BY created_at DESC" + ).fetchall() + mixes = conn.execute( + "SELECT id, name, flavors, note, strength, cooling, price, tags, created_at" + " FROM mix_recipes WHERE deleted = FALSE ORDER BY created_at DESC" + ).fetchall() + deleted_menu = conn.execute( + "SELECT id FROM menu_items WHERE deleted = TRUE" + ).fetchall() + deleted_tobacco = conn.execute( + "SELECT id FROM tobacco_flavors WHERE deleted = TRUE" + ).fetchall() + accents = conn.execute( + "SELECT id, accent FROM menu_items" + " WHERE deleted = FALSE AND accent IS NOT NULL" + ).fetchall() + + def with_iso(rows): + out = [] + for r in rows: + d = dict(r) + d["created_at"] = d["created_at"].astimezone().isoformat() + out.append(d) + return out + + menu_rows = with_iso(menu) + for d in menu_rows: + d["image"] = photo_for_item(d["id"]) + return { + "menu": menu_rows, + "tobacco": with_iso(tobacco), + "mixes": with_iso(mixes), + "site_mix_edits": list_site_mix_edits(), + "deleted_menu": [r["id"] for r in deleted_menu], + "deleted_tobacco": [r["id"] for r in deleted_tobacco], + "accents": {r["id"]: r["accent"] for r in accents}, + } + + # ---------------------------------------------------------------- настройки и нумерация + + def get_setting(key: str) -> str | None: + with db() as conn: + row = conn.execute( + "SELECT value FROM app_settings WHERE key = %s", (key,) + ).fetchone() + return row["value"] if row else None + + def set_setting(key: str, value: str) -> None: + with db() as conn: + conn.execute( + "INSERT INTO app_settings (key, value) VALUES (%s, %s)" + " ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value", + (key, value), + ) + + def effective_order_week(now: datetime) -> date: + """Неделя нумерации с учётом сброса владельцем. + + Сброс хранит две настройки: order_number_reset_at (момент сброса) и + order_number_week (свободное пространство ключей). Переопределение + действует, пока сброс сделан в ТЕКУЩЕЙ естественной неделе: с + наступлением новой недели (вторник) условие само отпускает, и + нумерация начинается естественно с №1. + """ + week = order_week_start(now) + reset_at = get_setting("order_number_reset_at") + if reset_at: + try: + if order_week_start(datetime.fromisoformat(reset_at)) != week: + return week + except ValueError: + return week + override = get_setting("order_number_week") + if override: + try: + return date.fromisoformat(override) + except ValueError: + return week + return week + + def reset_order_numbering() -> dict: + """Сброс нумерации: следующий заказ получит №1. + + Один сброс на естественную неделю: если прошлый сброс случился в эту + же неделю (order_number_reset_at) — повторный запрещён, иначе номера + 1, 2, … выделялись бы заново поверх уже выданных. Пространство + (order_number_week) выбираем свободное: дата текущей недели, а если + в ней уже есть заказы — предыдущий день (он не может быть week_start + старых заказов: недели начинаются по вторникам).""" + natural = order_week_start() + prev = get_setting("order_number_reset_at") + if prev: + try: + if order_week_start(datetime.fromisoformat(prev)) == natural: + return {} + except ValueError: + pass + candidate = natural + with db() as conn: + for _ in range(2): + taken = conn.execute( + "SELECT 1 FROM orders WHERE week_start = %s LIMIT 1", (candidate,) + ).fetchone() + if taken is None: + break + candidate = candidate - timedelta(days=1) + else: + return {} + set_setting("order_number_week", candidate.isoformat()) + set_setting("order_number_reset_at", datetime.now(timezone.utc).isoformat()) + return {"week": candidate.isoformat(), "next_number": 1} + + # ---------------------------------------------------------------- смены официантов + + def _shift_row(r) -> dict: + d = dict(r) + for key in ("started_at", "ended_at"): + if d.get(key) is not None: + d[key] = d[key].isoformat() + return d + + def auto_close_shifts() -> int: + """Автоматически закрывает смены, чьё окно уже завершилось + (забыл нажать «Закончить смену» — смена закроется сама).""" + now = utcnow() + closed = 0 + with db() as conn: + rows = conn.execute( + "SELECT id, started_at FROM shifts WHERE ended_at IS NULL" + ).fetchall() + for r in rows: + end = auto_close_cutoff(r["started_at"], now) + if end is not None: + conn.execute( + "UPDATE shifts SET ended_at = %s WHERE id = %s AND ended_at IS NULL", + (end, r["id"]), + ) + closed += 1 + return closed + + def get_open_shift(login: str) -> dict | None: + auto_close_shifts() + with db() as conn: + row = conn.execute( + "SELECT id, login, started_at, ended_at FROM shifts" + " WHERE login = %s AND ended_at IS NULL ORDER BY started_at DESC LIMIT 1", + (login,), + ).fetchone() + return _shift_row(row) if row else None + + def clock_in(login: str) -> tuple[dict | None, str]: + ok, reason = clock_in_check() + if not ok: + return None, reason + if get_open_shift(login): + return None, "Вы уже на смене" + try: + with db() as conn: + row = conn.execute( + "INSERT INTO shifts (login) VALUES (%s) RETURNING id, login, started_at, ended_at", + (login,), + ).fetchone() + except Exception: + # partial unique index (см. миграцию): двойной clock-in в миллисекундной + # гонке двух устройств не создаёт вторую открытую смену (F-20) + return None, "Вы уже на смене" + return _shift_row(row), "" + + def clock_out(login: str) -> dict | None: + with db() as conn: + row = conn.execute( + "UPDATE shifts SET ended_at = now()" + " WHERE login = %s AND ended_at IS NULL RETURNING id, login, started_at, ended_at", + (login,), + ).fetchone() + return _shift_row(row) if row else None + + def admin_clock_in(login: str) -> dict | None: + """Ручная запись на смену владельцем/админом — без проверки окна.""" + with db() as conn: + row = conn.execute( + "INSERT INTO shifts (login) VALUES (%s) RETURNING id, login, started_at, ended_at", + (login,), + ).fetchone() + return _shift_row(row) + + def staff_shift_roles() -> list[dict]: + """Все действующие официанты и кальянщики (для управления сменами + владельцем/админом); удалённые (NF-11) скрыты.""" + with db() as conn: + rows = conn.execute( + "SELECT login, name FROM crm_users" + " WHERE role IN ('waiter', 'hookah') AND NOT deleted ORDER BY name" + ).fetchall() + return [dict(r) for r in rows] + + def on_shift() -> list[dict]: + """Официанты, находящиеся на смене прямо сейчас (без удалённых).""" + auto_close_shifts() + with db() as conn: + rows = conn.execute( + "SELECT s.login, u.name, s.started_at FROM shifts s" + " JOIN crm_users u ON u.login = s.login AND NOT u.deleted" + " WHERE s.ended_at IS NULL ORDER BY s.started_at" + ).fetchall() + return [_shift_row(r) for r in rows] + + def shift_counts() -> list[dict]: + """Количество смен у действующих официантов и кальянщиков (для владельца).""" + with db() as conn: + rows = conn.execute( + "SELECT u.login, u.name, COUNT(s.id) AS shifts FROM crm_users u" + " LEFT JOIN shifts s ON s.login = u.login" + " WHERE u.role IN ('waiter', 'hookah') AND NOT u.deleted" + " GROUP BY u.login, u.name" + " ORDER BY shifts DESC, u.name" + ).fetchall() + return [dict(r) for r in rows] + + # ---------------------------------------------------------------- занятость столов и зон + + def halls_list(include_deleted: bool = False) -> list[dict]: + """Справочник столов/залов: активные по порядку сортировки; + include_deleted — с удалёнными в конце (для владельца).""" + with db() as conn: + rows = conn.execute( + "SELECT name, sub, sort, deleted FROM halls_def" + + ("" if include_deleted else " WHERE NOT deleted") + + " ORDER BY deleted, sort, name" + ).fetchall() + return [dict(r) for r in rows] + + def halls_active() -> list[dict]: + """Активные столы/зоны — {name, sub} в порядке сортировки.""" + with db() as conn: + rows = conn.execute( + "SELECT name, sub FROM halls_def WHERE NOT deleted ORDER BY sort, name" + ).fetchall() + return [dict(r) for r in rows] + + def hall_names() -> list[str]: + return [h["name"] for h in halls_active()] + + def hall_create(name: str, sub: str | None) -> dict | None: + """Новый стол/зал. None — активный стол с таким именем уже есть; + удалённый с тем же именем восстанавливается с новой подписью. + Восстановленный стол освобождается: старое «занят» могло остаться + с тех времён, когда стол удалили не освобождая.""" + with db() as conn: + row = conn.execute( + "SELECT name, deleted FROM halls_def WHERE name = %s", (name,) + ).fetchone() + if row is not None: + if row["deleted"]: + updated = conn.execute( + "UPDATE halls_def SET deleted = FALSE, sub = %s WHERE name = %s" + " RETURNING name, sub, sort, deleted", + (sub, name), + ).fetchone() + created = dict(updated) + else: + return None + else: + nxt = conn.execute("SELECT COALESCE(MAX(sort), 0) + 1 AS n FROM halls_def").fetchone() + created = dict(conn.execute( + "INSERT INTO halls_def (name, sub, sort) VALUES (%s, %s, %s)" + " RETURNING name, sub, sort, deleted", + (name, sub, nxt["n"]), + ).fetchone()) + # сброс зависшей занятости (для нового стола — просто свободная строка) + conn.execute( + "INSERT INTO hall_status (name, busy, busy_by, busy_until, updated_at)" + " VALUES (%s, FALSE, NULL, NULL, now())" + " ON CONFLICT (name) DO UPDATE SET" + " busy = FALSE, busy_by = NULL, busy_until = NULL, updated_at = now()", + (name,), + ) + return created + + def hall_delete(name: str) -> bool: + """Мягкое удаление стола: исчезает из списков и валидации, + история броней остаётся. Вернуть можно повторным созданием.""" + with db() as conn: + row = conn.execute( + "UPDATE halls_def SET deleted = TRUE WHERE name = %s AND NOT deleted", + (name,), + ) + return (row.rowcount or 0) > 0 + + def _hall_row(r) -> dict: + d = dict(r) + for key in ("busy_until", "updated_at"): + if d.get(key) is not None: + d[key] = d[key].astimezone().isoformat() + return d + + def _reservation_row(r) -> dict: + return { + "id": r["id"], + "hall": r["hall_name"], + "guest": r["guest"], + "from": r["dt_from"].astimezone().isoformat(), + "until": r["dt_until"].astimezone().isoformat(), + "by": r["created_by"], + } + + def hall_status() -> list[dict]: + """Состояние всех столов и зон; таймеры с истёкшим сроком освобождаются + сами, наступившие брони сами занимают стол до конца своего окна + (occupied_at — метка однократного автозанятия). К каждому столу + прикладываются живые брони (будущие и идущие, до 4 ближайших).""" + with db() as conn: + active = halls_active() + conn.execute( + "UPDATE hall_status SET busy = FALSE, busy_by = NULL, busy_until = NULL," + " updated_at = now()" + " WHERE busy AND busy_until IS NOT NULL AND busy_until <= now()" + ) + # пришло время брони — стол занимается сам, таймер до конца окна + for r in conn.execute( + """ + SELECT id, hall_name, dt_until, COALESCE(NULLIF(guest, ''), 'гость') AS who + FROM hall_reservations + WHERE NOT cancelled AND occupied_at IS NULL + AND dt_from <= now() AND dt_until > now() + AND hall_name = ANY(%s) + """, + ([h["name"] for h in active],), + ).fetchall(): + conn.execute( + """ + INSERT INTO hall_status (name, busy, busy_by, busy_until, updated_at) + VALUES (%s, TRUE, %s, %s, now()) + ON CONFLICT (name) DO UPDATE SET + busy = TRUE, busy_by = EXCLUDED.busy_by, + busy_until = EXCLUDED.busy_until, updated_at = now() + """, + (r["hall_name"], "Бронь · " + r["who"], r["dt_until"]), + ) + conn.execute( + "UPDATE hall_reservations SET occupied_at = now() WHERE id = %s", + (r["id"],), + ) + rows = conn.execute( + "SELECT name, busy, busy_by, busy_until, updated_at FROM hall_status" + ).fetchall() + res_rows = conn.execute( + """ + SELECT id, hall_name, guest, dt_from, dt_until, created_by + FROM hall_reservations + WHERE NOT cancelled AND dt_until > now() + ORDER BY dt_from + """ + ).fetchall() + by_name = {r["name"]: _hall_row(r) for r in rows} + res_by_name: dict[str, list[dict]] = {} + for r in res_rows: + res_by_name.setdefault(r["hall_name"], []).append(_reservation_row(r)) + return [ + {"name": h["name"], "sub": h["sub"], + "reservations": res_by_name.get(h["name"], [])[:4], + **by_name.get(h["name"], { + "busy": False, "busy_by": None, "busy_until": None, "updated_at": None, + })} + for h in active + ] + + def hall_reserve(name: str, dt_from, dt_until, guest: str | None, by: str) -> dict | None: + """Новая бронь стола/зоны. None — на этот стол в пересекающееся окно + уже есть бронь. Занятость стола (walk-in) пересечению не помеха: + до начала брони стол остаётся свободным для гостей без брони.""" + with db() as conn: + # сериализуем брони одного стола: без лока две параллельные + # транзакции в READ COMMITTED не видят вставок друг друга и + # обе проходят проверку пересечения (двойная бронь) + conn.execute("SELECT pg_advisory_xact_lock(hashtext(%s))", (name,)) + overlap = conn.execute( + """ + SELECT 1 FROM hall_reservations + WHERE hall_name = %s AND NOT cancelled + AND dt_from < %s AND dt_until > %s + """, + (name, dt_until, dt_from), + ).fetchone() + if overlap: + return None + row = conn.execute( + """ + INSERT INTO hall_reservations (hall_name, guest, dt_from, dt_until, created_by) + VALUES (%s, %s, %s, %s, %s) + RETURNING id, hall_name, guest, dt_from, dt_until, created_by + """, + (name, guest, dt_from, dt_until, by), + ).fetchone() + return _reservation_row(row) + + def hall_reserve_cancel(res_id: int) -> bool: + """Мягкая отмена: строка остаётся в истории помеченной cancelled. + Если броня уже успела сама занять стол (occupied_at) и стол до сих + пор помечен именно этой бронью — освобождаем сразу: гости не придут, + и «занят до конца окна» вводил бы персонал в заблуждение.""" + with db() as conn: + row = conn.execute( + """ + SELECT hall_name, dt_until, occupied_at, + 'Бронь · ' || COALESCE(NULLIF(guest, ''), 'гость') AS marker + FROM hall_reservations + WHERE id = %s AND NOT cancelled + """, + (res_id,), + ).fetchone() + if row is None: + return False + if row["occupied_at"] is not None: + conn.execute( + """ + UPDATE hall_status SET busy = FALSE, busy_by = NULL, busy_until = NULL, + updated_at = now() + WHERE name = %s AND busy_by = %s AND busy_until = %s + """, + (row["hall_name"], row["marker"], row["dt_until"]), + ) + conn.execute( + "UPDATE hall_reservations SET cancelled = TRUE WHERE id = %s", + (res_id,), + ) + return True + + def set_hall_busy(name: str, busy: bool, minutes: int | None, by: str) -> dict | None: + """Занять стол/зону (опционально на minutes минут) или освободить. + + Продление: если стол уже занят с активным таймером, минуты добавляются + к текущему дедлайну; занят без таймера — отсчёт идёт от сейчас. + """ + with db() as conn: + if not busy: + row = conn.execute( + """ + INSERT INTO hall_status (name, busy, busy_by, busy_until, updated_at) + VALUES (%s, FALSE, NULL, NULL, now()) + ON CONFLICT (name) DO UPDATE SET + busy = FALSE, busy_by = NULL, busy_until = NULL, updated_at = now() + RETURNING name, busy, busy_by, busy_until, updated_at + """, + (name,), + ).fetchone() + return _hall_row(row) + + until = None + if minutes is not None: + base = utcnow() + current = conn.execute( + "SELECT busy_until FROM hall_status WHERE name = %s", (name,) + ).fetchone() + if current and current["busy_until"] and current["busy_until"] > base: + base = current["busy_until"] + until = base + timedelta(minutes=minutes) + row = conn.execute( + """ + INSERT INTO hall_status (name, busy, busy_by, busy_until, updated_at) + VALUES (%s, TRUE, %s, %s, now()) + ON CONFLICT (name) DO UPDATE SET + busy = TRUE, busy_by = EXCLUDED.busy_by, + busy_until = EXCLUDED.busy_until, updated_at = now() + RETURNING name, busy, busy_by, busy_until, updated_at + """, + (name, by, until), + ).fetchone() + return _hall_row(row) + + # ---------------------------------------------------------------- игры (PS5 и настолки) + + def games_list(include_deleted: bool = False) -> list[dict]: + """Библиотека игр, свежие сверху; include_deleted — для корзины + (админ/владелец): удалённые идут в конце с флагом deleted.""" + with db() as conn: + rows = conn.execute( + "SELECT id, kind, title, note, created_by, created_at, seed_id, deleted," + " difficulty, icon, accent" + " FROM games" + ("" if include_deleted else " WHERE NOT deleted") + + " ORDER BY deleted, id DESC" + ).fetchall() + out = [] + for r in rows: + d = dict(r) + d["created_at"] = d["created_at"].astimezone().isoformat() + d["cover"] = None if d["deleted"] else game_cover(d["id"]) + out.append(d) + return out + + def game_create(kind: str, title: str, note: str | None, by: str, + difficulty: int | None = None, icon: str | None = None, + accent: str | None = None) -> dict: + with db() as conn: + row = conn.execute( + """ + INSERT INTO games (kind, title, note, created_by, difficulty, icon, accent) + VALUES (%s, %s, %s, %s, %s, %s, %s) + RETURNING id, kind, title, note, created_by, created_at, seed_id, deleted, + difficulty, icon, accent + """, + (kind, title, note, by, difficulty, icon, accent), + ).fetchone() + d = dict(row) + d["created_at"] = d["created_at"].astimezone().isoformat() + d["cover"] = None + return d + + def game_update(game_id: int, fields: dict) -> dict | None: + """Правка игры: note/difficulty/icon/accent (название/вид — только + через пересоздание, чтобы сид сайта оставался источником названий).""" + sets, args = [], [] + for key in ("note", "icon", "accent"): + if key in fields: + sets.append(f"{key} = %s") + args.append(fields[key]) + if "difficulty" in fields: + sets.append("difficulty = %s") + args.append(fields["difficulty"]) + if not sets: + return None + with db() as conn: + args.append(game_id) + row = conn.execute( + f"UPDATE games SET {', '.join(sets)} WHERE id = %s" + " RETURNING id, kind, title, note, created_by, created_at, seed_id," + " deleted, difficulty, icon, accent", + args, + ).fetchone() + if row is None: + return None + d = dict(row) + d["created_at"] = d["created_at"].astimezone().isoformat() + d["cover"] = None if d["deleted"] else game_cover(d["id"]) + return d + + def game_get(game_id: int) -> dict | None: + """Одна игра (для проверки прав на обложку).""" + with db() as conn: + row = conn.execute( + "SELECT id, kind, title, note, created_by, created_at, seed_id, deleted," + " difficulty, icon, accent FROM games WHERE id = %s", + (game_id,), + ).fetchone() + if row is None: + return None + d = dict(row) + d["created_at"] = d["created_at"].astimezone().isoformat() + d["cover"] = game_cover(d["id"]) + return d + + def game_delete(game_id: int) -> bool: + """Мягкое удаление → корзина: сид сайта не пересоздаёт игру, пока + строка существует; восстановление — game_set_deleted(id, False).""" + with db() as conn: + row = conn.execute( + "UPDATE games SET deleted = TRUE" + " WHERE id = %s AND NOT deleted RETURNING id", + (game_id,), + ).fetchone() + return row is not None + + def game_set_deleted(game_id: int, deleted: bool) -> dict | None: + """Корзина ↔ восстановление игры (обложка при удалении сохраняется).""" + with db() as conn: + row = conn.execute( + "UPDATE games SET deleted = %s WHERE id = %s" + " RETURNING id, kind, title, note, created_by, created_at, seed_id, deleted", + (deleted, game_id), + ).fetchone() + if row is None: + return None + d = dict(row) + d["created_at"] = d["created_at"].astimezone().isoformat() + d["cover"] = game_cover(d["id"]) + return d + + # ---------------------------------------------------------------- доски заметок/задач + # Общая доска видна всем ролям, приватные — только участникам (и владельцу). + # Чужая приватная доска неотличима от несуществующей (гвард ниже) — наружу 404. + + _NOTES_SELECT = """ + SELECT n.*, COALESCE(a.name, n.author_login) AS author_name, + COALESCE(s.name, n.assignee_login) AS assignee_name + FROM notes n + LEFT JOIN crm_users a ON a.login = n.author_login + LEFT JOIN crm_users s ON s.login = n.assignee_login + """ + + def _note_row(r) -> dict: + return { + "id": r["id"], + "board_id": r["board_id"], + "author": {"id": r["author_login"], "name": r["author_name"]}, + "assignee": ({"id": r["assignee_login"], "name": r["assignee_name"]} + if r["assignee_login"] else None), + "title": r["title"], + "body": r["body"], + "status": r["status"], + "important": r["important"], + "due_date": r["due_date"].isoformat() if r["due_date"] else None, + "done_at": r["done_at"].astimezone().isoformat() if r["done_at"] else None, + "created_at": r["created_at"].astimezone().isoformat(), + "updated_at": r["updated_at"].astimezone().isoformat(), + } + + def boards_seed_shared() -> None: + """Одна общая доска на базу; вызывается из init_schema после seed-учёток.""" + with db() as conn: + if conn.execute("SELECT 1 FROM boards WHERE kind = 'shared'").fetchone(): + return + owner = conn.execute( + "SELECT login FROM crm_users WHERE role = 'owner' ORDER BY login LIMIT 1" + ).fetchone() + if owner is None: + return + conn.execute( + "INSERT INTO boards (kind, title, created_by_login)" + " VALUES ('shared', 'Общая', %s)", + (owner["login"],), + ) + + def boards_list(user: dict) -> list[dict]: + """Доступные доски: общая для всех; приватные — свои, у владельца все. + Официант и кальянщик получают только общую — это норма. + members: участники (для общей — все сотрудники): селектор исполнителя + и карточки «владелец ↔ админ» на фронте.""" + with db() as conn: + rows = conn.execute( + """ + SELECT b.id, b.kind, b.title, b.created_at + FROM boards b + WHERE b.kind = 'shared' + OR %s = 'owner' + OR EXISTS (SELECT 1 FROM board_members m + WHERE m.board_id = b.id AND m.user_login = %s) + ORDER BY b.kind, b.created_at + """, + (user["role"], user["login"]), + ).fetchall() + # NF-11: удалённые сотрудники не участвуют в досках + users = conn.execute( + "SELECT login, name, role FROM crm_users WHERE NOT deleted ORDER BY name" + ).fetchall() + member_rows = conn.execute( + """ + SELECT m.board_id, m.user_login AS login, u.name, u.role + FROM board_members m JOIN crm_users u ON u.login = m.user_login + WHERE m.board_id = ANY(%s) AND NOT u.deleted + """, + ([b["id"] for b in rows],), + ).fetchall() if rows else [] + members: dict[int, list[dict]] = {} + for m in member_rows: + members.setdefault(m["board_id"], []).append( + {"login": m["login"], "name": m["name"], "role": m["role"]}) + out = [] + for r in rows: + d = dict(r) + d["created_at"] = d["created_at"].astimezone().isoformat() + # у общей доски неявный участник — весь персонал + d["members"] = members.get(d["id"]) if d["kind"] == "private" else [ + {"login": u["login"], "name": u["name"], "role": u["role"]} + for u in users + ] + out.append(d) + return out + + def board_guard(board_id: int, user: dict) -> dict | None: + """Доступ к доске. None — и для несуществующей, и для чужой приватной: + оба случая отдаются клиенту как 404, существование чужих досок не + раскрываем.""" + with db() as conn: + row = conn.execute( + """ + SELECT id, kind, title, created_at FROM boards + WHERE id = %s AND ( + kind = 'shared' OR %s = 'owner' + OR EXISTS (SELECT 1 FROM board_members m + WHERE m.board_id = boards.id AND m.user_login = %s)) + """, + (board_id, user["role"], user["login"]), + ).fetchone() + return dict(row) if row else None + + def note_assignee_ok(board: dict, assignee_login: str) -> bool: + """Исполнителем может быть только действующий участник доски (NF-11: + удалённые сотрудники исполнителями быть не могут): на общей — любой + сотрудник, на приватной — только её пара (владелец и админ).""" + if board["kind"] == "shared": + with db() as conn: + return conn.execute( + "SELECT 1 FROM crm_users WHERE login = %s AND NOT deleted", + (assignee_login,), + ).fetchone() is not None + with db() as conn: + return conn.execute( + """ + SELECT 1 FROM board_members m + JOIN crm_users u ON u.login = m.user_login AND NOT u.deleted + WHERE m.board_id = %s AND m.user_login = %s + """, + (board["id"], assignee_login), + ).fetchone() is not None + + def board_create(title: str, admin_login: str, owner_login: str) -> dict: + """Приватная доска «владелец ↔ админ»: ровно два участника, состав + фиксируется сразу (ON CONFLICT — повторный вызов не падает).""" + with db() as conn: + row = conn.execute( + """ + INSERT INTO boards (kind, title, created_by_login) + VALUES ('private', %s, %s) + RETURNING id, kind, title, created_at + """, + (title, owner_login), + ).fetchone() + conn.execute( + """ + INSERT INTO board_members (board_id, user_login) + VALUES (%s, %s), (%s, %s) ON CONFLICT DO NOTHING + """, + (row["id"], owner_login, row["id"], admin_login), + ) + conn.execute( + "INSERT INTO note_activity (board_id, user_login, action, details)" + " VALUES (%s, %s, 'board_created', %s)", + (row["id"], owner_login, admin_login), + ) + d = dict(row) + d["created_at"] = d["created_at"].astimezone().isoformat() + return d + + def board_rename(board_id: int, title: str) -> dict | None: + with db() as conn: + row = conn.execute( + "UPDATE boards SET title = %s WHERE id = %s" + " RETURNING id, kind, title, created_at", + (title, board_id), + ).fetchone() + if row is None: + return None + d = dict(row) + d["created_at"] = d["created_at"].astimezone().isoformat() + return d + + def board_delete(board_id: int) -> bool: + """Каскадно уносит заметки, участников и журнал (FK ON DELETE CASCADE).""" + with db() as conn: + row = conn.execute( + "DELETE FROM boards WHERE id = %s RETURNING id", (board_id,) + ).fetchone() + return row is not None + + def notes_list(board_id: int, since: datetime | None = None) -> dict: + """Заметки доски. since (из server_time прошлого ответа) — только + изменённые с того момента + id удалённых (из журнала). + + Метки времени мутаций — clock_timestamp(), не now(): now() замирает + на старте транзакции, и изменение, закоммиченное между двумя опросами + из «длинной» транзакции, могло бы навсегда выпасть из курсора. + """ + with db() as conn: + ts = conn.execute("SELECT clock_timestamp() AS ts").fetchone()["ts"] + if since is None: + rows = conn.execute( + _NOTES_SELECT + " WHERE n.board_id = %s ORDER BY n.updated_at DESC", + (board_id,), + ).fetchall() + deleted: list[int] = [] + else: + rows = conn.execute( + _NOTES_SELECT + + " WHERE n.board_id = %s AND n.updated_at > %s" + " ORDER BY n.updated_at DESC", + (board_id, since), + ).fetchall() + deleted = [ + r["nid"] for r in conn.execute( + """ + SELECT DISTINCT details::BIGINT AS nid FROM note_activity + WHERE board_id = %s AND action = 'deleted' + AND created_at > %s AND details ~ '^[0-9]+$' + """, + (board_id, since), + ).fetchall() + ] + return { + "notes": [_note_row(r) for r in rows], + "deleted_ids": deleted, + "server_time": ts.astimezone().isoformat(), + } + + def note_get(note_id: int) -> dict | None: + """Одна заметка в API-формате; доступ к её доске проверяет хендлер.""" + with db() as conn: + row = conn.execute( + _NOTES_SELECT + " WHERE n.id = %s", (note_id,) + ).fetchone() + return _note_row(row) if row else None + + def note_create(board: dict, author: dict, title: str, body: str, + assignee_login: str | None, important: bool, due_date) -> dict: + """Новая заметка; журнал и метки времени ведёт сервер.""" + with db() as conn: + row = conn.execute( + """ + INSERT INTO notes (board_id, author_login, assignee_login, title, + body, important, due_date, updated_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, clock_timestamp()) + RETURNING id + """, + (board["id"], author["login"], assignee_login, title, body, + important, due_date), + ).fetchone() + conn.execute( + "INSERT INTO note_activity (board_id, note_id, user_login, action, details)" + " VALUES (%s, %s, %s, 'created', %s)", + (board["id"], row["id"], author["login"], title), + ) + full = conn.execute( + _NOTES_SELECT + " WHERE n.id = %s", (row["id"],) + ).fetchone() + return _note_row(full) + + def note_update(note_id: int, user: dict, board: dict, fields: dict) -> dict | None: + """Частичная правка тем, кто видит доску. status='done' проставляет + done_at, уход из done — сбрасывает; updated_at двигается всегда. + Журнал — только по реально изменившимся полям.""" + with db() as conn: + old = conn.execute( + "SELECT status, assignee_login FROM notes WHERE id = %s", (note_id,) + ).fetchone() + if old is None: + return None + + sets, args = [], [] + if "title" in fields: + sets.append("title = %s") + args.append(fields["title"]) + if "body" in fields: + sets.append("body = %s") + args.append(fields["body"]) + if "important" in fields: + sets.append("important = %s") + args.append(fields["important"]) + if "due_date" in fields: + sets.append("due_date = %s") + args.append(fields["due_date"]) + if "assignee_login" in fields: + sets.append("assignee_login = %s") + args.append(fields["assignee_login"]) + status = fields.get("status") + if status is not None: + sets.append("done_at = %s") + args.append(utcnow() if status == "done" else None) + sets.append("status = %s") + args.append(status) + sets.append("updated_at = clock_timestamp()") + args.append(note_id) + conn.execute(f"UPDATE notes SET {', '.join(sets)} WHERE id = %s", args) + + if any(k in fields for k in ("title", "body", "important", "due_date")): + conn.execute( + "INSERT INTO note_activity (board_id, note_id, user_login, action)" + " VALUES (%s, %s, %s, 'edited')", + (board["id"], note_id, user["login"]), + ) + if status is not None and status != old["status"]: + conn.execute( + "INSERT INTO note_activity (board_id, note_id, user_login, action, details)" + " VALUES (%s, %s, %s, 'status', %s)", + (board["id"], note_id, user["login"], status), + ) + if "assignee_login" in fields and fields["assignee_login"] != old["assignee_login"]: + conn.execute( + "INSERT INTO note_activity (board_id, note_id, user_login, action, details)" + " VALUES (%s, %s, %s, 'assigned', %s)", + (board["id"], note_id, user["login"], fields["assignee_login"] or ""), + ) + full = conn.execute( + _NOTES_SELECT + " WHERE n.id = %s", (note_id,) + ).fetchone() + return _note_row(full) + + def note_delete(note_id: int, user_login: str) -> dict | None: + """Жёсткое удаление (права проверяет хендлер). В журнале остаётся + запись с id заметки в details — по нему клиенты чистят кэш.""" + with db() as conn: + row = conn.execute( + "DELETE FROM notes WHERE id = %s RETURNING id, title, board_id", + (note_id,), + ).fetchone() + if row is None: + return None + conn.execute( + "INSERT INTO note_activity (board_id, user_login, action, details)" + " VALUES (%s, %s, 'deleted', %s)", + (row["board_id"], user_login, str(row["id"])), + ) + return dict(row) + + def board_activity(board_id: int, limit: int = 50) -> list[dict]: + """Последние события доски — для ленты «кто что сделал».""" + with db() as conn: + rows = conn.execute( + """ + SELECT a.id, a.action, a.details, a.created_at, + COALESCE(u.name, a.user_login) AS user_name + FROM note_activity a LEFT JOIN crm_users u ON u.login = a.user_login + WHERE a.board_id = %s + ORDER BY a.id DESC LIMIT %s + """, + (board_id, limit), + ).fetchall() + out = [] + for r in rows: + d = dict(r) + d["created_at"] = d["created_at"].astimezone().isoformat() + out.append(d) + return out + + # ---------------------------------------------------------------- демо-заказы + + def _maybe_seed_demo_orders() -> None: + if os.environ.get("CRM_SEED_DEMO_ORDERS", "0") != "1": + return + with db() as conn: + if conn.execute("SELECT 1 FROM orders LIMIT 1").fetchone(): + return + insert = """ + INSERT INTO orders (number, week_start, table_name, waiter, status, price, items, + created_at, accepted_at, delivered_at, duration_seconds) + VALUES (%(number)s, %(week)s, %(table)s, %(waiter)s, %(status)s, %(price)s, + %(items)s::jsonb, %(created)s, %(accepted)s, %(delivered)s, %(duration)s) + """ + for row in _demo_rows(): + conn.execute(insert, row) diff --git a/crm/storage_sqlite.py b/crm/storage_sqlite.py new file mode 100644 index 0000000..b75d1e5 --- /dev/null +++ b/crm/storage_sqlite.py @@ -0,0 +1,1934 @@ +# -*- coding: utf-8 -*- +""" +SQLite-хранилище НЕОН CRM — локальный режим без PostgreSQL. + +Включается переменной CRM_DB=sqlite: storage.py сам подменяет свои +функции функциями этого модуля. API полностью повторяет Postgres-версию, +поэтому server.py не знает, какой бэкенд под ним работает. + +Файл базы: crm/data/crm.db — создаётся при первом запуске. +Продакшн (Docker) всегда работает на PostgreSQL; SQLite — только для +локальной разработки и демонстрации. +""" + +import json +import os +import secrets +import sqlite3 +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone + +import storage + +DB_PATH = storage.DATA_DIR / "crm.db" + + +# ---------------------------------------------------------------- postgres-only +# Часть функций существует только в postgres-режиме (боевом). В sqlite-режиме +# они отвечали 500 «internal error» — теперь честный 501 с текстом (F-11). +def _postgres_only(name): + def _stub(*_a, **_k): + raise storage.PostgresOnlyFeature( + f"«{name}» недоступно в sqlite-режиме — этот раздел работает" + " только в боевом postgres-режиме (docker)" + ) + return _stub + + +board_activity = _postgres_only("доски: лента событий") +board_create = _postgres_only("новая доска") +board_delete = _postgres_only("удаление доски") +board_guard = _postgres_only("доступ к доске") +board_rename = _postgres_only("переименование доски") +boards_list = _postgres_only("доски заметок") +game_create = _postgres_only("игры: добавление") +game_delete = _postgres_only("игры: удаление") +game_get = _postgres_only("игры: карточка") +game_set_deleted = _postgres_only("игры: корзина") +game_update = _postgres_only("игры: правка") +games_list = _postgres_only("библиотека игр") +hall_reserve = _postgres_only("брони столов") +hall_reserve_cancel = _postgres_only("брони столов: отмена") +note_assignee_ok = _postgres_only("заметки: исполнитель") +note_create = _postgres_only("заметки: создание") +note_delete = _postgres_only("заметки: удаление") +note_get = _postgres_only("заметки: карточка") +note_update = _postgres_only("заметки: правка") +notes_list = _postgres_only("заметки: список") +reset_order_numbering = _postgres_only("сброс нумерации заказов") +set_menu_accent = _postgres_only("подсветка карточек на сайте") + + +# ---------------------------------------------------------------- служебное + +def _iso(dt: datetime | None) -> str | None: + return dt.astimezone(timezone.utc).isoformat() if dt else None + + +def _parse(ts: str | None) -> datetime | None: + return datetime.fromisoformat(ts) if ts else None + + +@contextmanager +def db(): + """Соединение с автокоммитом по выходу из блока. + + psycopg-версия отдаёт соединение из пула; здесь — новое соединение + на каждый вызов, для локальной нагрузки этого достаточно. + """ + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def init_pool() -> None: + pass # пул не нужен: соединения открываются на запрос в db() + + +def close_pool() -> None: + pass + + +# ---------------------------------------------------------------- схема + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + number INTEGER NOT NULL, + week_start TEXT NOT NULL, -- «неделя нумерации» (вторник, см. storage.order_week_start) + table_name TEXT NOT NULL, + guest_name TEXT, + guest_phone TEXT, + source TEXT NOT NULL DEFAULT 'site', + waiter TEXT, + status TEXT NOT NULL DEFAULT 'new' + CHECK (status IN ('new', 'active', 'done', 'cancelled')), + price INTEGER NOT NULL DEFAULT 0, + items TEXT NOT NULL DEFAULT '[]', + comment TEXT, + track_code TEXT, + stock_applied INTEGER NOT NULL DEFAULT 0, -- штучные позиции уже списаны (done) + created_at TEXT NOT NULL, + accepted_at TEXT, + delivered_at TEXT, + duration_seconds INTEGER +); +CREATE INDEX IF NOT EXISTS orders_status_idx ON orders (status, number DESC); + +CREATE TABLE IF NOT EXISTS menu_items ( + id TEXT PRIMARY KEY, -- id позиции на сайте (напр. 'bud') + name TEXT NOT NULL, + category TEXT NOT NULL, + price INTEGER NOT NULL DEFAULT 0, + portion TEXT, + stopped INTEGER NOT NULL DEFAULT 0, + custom INTEGER NOT NULL DEFAULT 0, -- 1: добавлена владельцем через CRM + deleted INTEGER NOT NULL DEFAULT 0, -- 1: удалена владельцем (мягко) + stock INTEGER, -- поштучный учёт: NULL — выключен, число — остаток + auto_stopped INTEGER NOT NULL DEFAULT 0, -- 1: в стоп ушло само (stock = 0) + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS menu_stopped_idx ON menu_items (stopped); + +CREATE TABLE IF NOT EXISTS tobacco_flavors ( + id TEXT PRIMARY KEY, -- id вкуса на сайте (напр. 'bb-cola') + brand TEXT NOT NULL, + flavor TEXT NOT NULL, + note TEXT, + strength INTEGER NOT NULL DEFAULT 2, + cooling INTEGER NOT NULL DEFAULT 0, + stopped INTEGER NOT NULL DEFAULT 0, + custom INTEGER NOT NULL DEFAULT 0, -- 1: добавлен владельцем через CRM + deleted INTEGER NOT NULL DEFAULT 0, -- 1: удалён владельцем (мягко) + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS tobacco_stopped_idx ON tobacco_flavors (stopped); + +-- Миксы владельца: состав из вкусов табаков без долей — пропорции +-- подбирает кальянщик. Отдаются на сайт через /api/catalog. +CREATE TABLE IF NOT EXISTS mix_recipes ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + flavors TEXT NOT NULL DEFAULT '[]', -- JSON [{id, brand, flavor}] + note TEXT, + strength INTEGER, -- крепость 1–5 + cooling INTEGER, -- холодок 0–3 + price INTEGER, -- цена, ₽ + tags TEXT NOT NULL DEFAULT '[]', -- JSON ["ягоды", ...] — ярлыки для AI-генератора + created_at TEXT NOT NULL, + deleted INTEGER NOT NULL DEFAULT 0 +); + +-- Правки витринных миксов сайта («Авторские миксы сайта», seed/mixes.json): +-- строка на микс появляется, только когда владелец его отредактировал. +-- NULL — поле не менялось, сайт берёт исходное значение из своего каталога. +-- flavors NULL — состав не менялся; иначе JSON [{id, brand, flavor}]. +CREATE TABLE IF NOT EXISTS site_mix_edits ( + id TEXT PRIMARY KEY, -- id витринного микса ('showcase-N') + name TEXT, + note TEXT, + strength INTEGER, + cooling INTEGER, + price INTEGER, + tags TEXT, -- JSON ["ягоды", ...] или NULL + flavors TEXT -- JSON [{id, brand, flavor}] или NULL +); + +CREATE TABLE IF NOT EXISTS crm_users ( + login TEXT PRIMARY KEY, + password_hash TEXT NOT NULL, -- pbkdf2_sha256$iter$salt$hex + name TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'waiter', 'hookah')), + -- NF-11: мягкое удаление (см. postgres-версию) + deleted INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + login TEXT NOT NULL REFERENCES crm_users(login) ON DELETE CASCADE, + role TEXT NOT NULL, + expires_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS shifts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + login TEXT NOT NULL REFERENCES crm_users(login) ON DELETE CASCADE, + started_at TEXT NOT NULL, + ended_at TEXT +); +CREATE INDEX IF NOT EXISTS shifts_login_idx ON shifts (login); + +-- Занятость столов и зон: busy_until NULL — занят без таймера, +-- до ручного освобождения. Срок таймера истёк — освобождается сам. +CREATE TABLE IF NOT EXISTS hall_status ( + name TEXT PRIMARY KEY, + busy INTEGER NOT NULL DEFAULT 0, + busy_by TEXT, + busy_until TEXT, + updated_at TEXT NOT NULL +); + +-- Справочник столов и залов: владелец добавляет и удаляет столы из CRM. +-- storage.HALLS — стартовый сид (ON CONFLICT DO NOTHING). +CREATE TABLE IF NOT EXISTS halls_def ( + name TEXT PRIMARY KEY, + sub TEXT, + sort INTEGER NOT NULL DEFAULT 0, + deleted INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); + +-- Append-only журнал операций с заказами (NF-03) — зеркало postgres-версии. +CREATE TABLE IF NOT EXISTS orders_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_id INTEGER NOT NULL, + user_login TEXT NOT NULL, + action TEXT NOT NULL, -- 'status' | 'waiter' + old_value TEXT, + new_value TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); +CREATE INDEX IF NOT EXISTS orders_audit_order_idx ON orders_audit (order_id, id); +""" + + +def init_schema() -> None: + with db() as conn: + conn.executescript(SCHEMA) + # стоп-лист переехал с брендов на вкусы: старая таблица больше не нужна + conn.execute("DROP TABLE IF EXISTS tobacco_brands") + _migrate_catalog_columns(conn) + _migrate_order_columns(conn) + _migrate_mix_columns(conn) + _migrate_order_week(conn) + _migrate_crm_users_role(conn) + _migrate_crm_users_deleted(conn) + _migrate_shift_unique(conn) + _seed_users() + _seed_catalog() + _seed_halls() + _maybe_seed_demo_orders() + + +def _migrate_crm_users_deleted(conn) -> None: + """NF-11: колонка deleted в crm_users на живых базах. Идемпотентно.""" + cols = _table_columns(conn, "crm_users") + if "deleted" not in cols: + conn.execute("ALTER TABLE crm_users ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0") + + +def _migrate_shift_unique(conn) -> None: + """F-20: одна открытая смена на логин. Двойной clock-in в гонке двух + устройств не создаёт вторую открытую смену: partial unique индекс + + перехват IntegrityError в clock_in. Старые дубли (до индекса) закрываем, + оставляя самую раннюю.""" + conn.execute( + "UPDATE shifts SET ended_at = ?" + " WHERE ended_at IS NULL" + " AND id NOT IN (SELECT MIN(id) FROM shifts WHERE ended_at IS NULL GROUP BY login)", + (_iso(storage.utcnow()),), + ) + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS shifts_open_unique_idx" + " ON shifts (login) WHERE ended_at IS NULL" + ) + + +def _seed_halls() -> None: + """Стартовый справочник столов: сид не перетирает правки владельца.""" + now = _iso(storage.utcnow()) + with db() as conn: + for i, h in enumerate(storage.HALLS): + conn.execute( + "INSERT INTO halls_def (name, sub, sort, created_at) VALUES (?, ?, ?, ?)" + " ON CONFLICT(name) DO NOTHING", + (h["name"], h["sub"], i, now), + ) + + +def _table_columns(conn, table: str) -> set[str]: + return {r["name"] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()} + + +def _migrate_order_columns(conn) -> None: + """Колонки comment/track_code/stock_applied добавлены позже — дополняем живую таблицу. + + Индекс создаём здесь, а не в SCHEMA: на старой базе колонки ещё нет, + когда выполняется SCHEMA. IF NOT EXISTS идемпотентен. + """ + cols = _table_columns(conn, "orders") + if "comment" not in cols: + conn.execute("ALTER TABLE orders ADD COLUMN comment TEXT") + if "track_code" not in cols: + conn.execute("ALTER TABLE orders ADD COLUMN track_code TEXT") + if "stock_applied" not in cols: + conn.execute("ALTER TABLE orders ADD COLUMN stock_applied INTEGER NOT NULL DEFAULT 0") + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS orders_track_code_idx ON orders (track_code)" + " WHERE track_code IS NOT NULL" + ) + + +def _migrate_mix_columns(conn) -> None: + """mix_recipes до появления расширенной информации микса: strength/cooling/price/tags.""" + cols = _table_columns(conn, "mix_recipes") + if "strength" not in cols: + conn.execute("ALTER TABLE mix_recipes ADD COLUMN strength INTEGER") + if "cooling" not in cols: + conn.execute("ALTER TABLE mix_recipes ADD COLUMN cooling INTEGER") + if "price" not in cols: + conn.execute("ALTER TABLE mix_recipes ADD COLUMN price INTEGER") + if "tags" not in cols: + conn.execute("ALTER TABLE mix_recipes ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'") + + +def _migrate_order_week(conn) -> None: + """Еженедельная нумерация заказов. + + На старых базах number был глобально уникален (column UNIQUE) — SQLite + не умеет снимать ограничение без пересоздания таблицы: переносим данные + в новую таблицу с week_start и уникальностью (week_start, number). + """ + if "week_start" in _table_columns(conn, "orders"): + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS orders_week_number_idx" + " ON orders (week_start, number)" + ) + return + rows = [dict(r) for r in conn.execute("SELECT * FROM orders").fetchall()] + conn.execute("PRAGMA foreign_keys = OFF") + conn.executescript( + """ + DROP TABLE orders; + CREATE TABLE orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + number INTEGER NOT NULL, + week_start TEXT NOT NULL DEFAULT '', + table_name TEXT NOT NULL, + guest_name TEXT, + guest_phone TEXT, + source TEXT NOT NULL DEFAULT 'site', + waiter TEXT, + status TEXT NOT NULL DEFAULT 'new' + CHECK (status IN ('new', 'active', 'done', 'cancelled')), + price INTEGER NOT NULL DEFAULT 0, + items TEXT NOT NULL DEFAULT '[]', + comment TEXT, + track_code TEXT, + stock_applied INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + accepted_at TEXT, + delivered_at TEXT, + duration_seconds INTEGER + ); + CREATE INDEX IF NOT EXISTS orders_status_idx ON orders (status, number DESC); + """ + ) + for d in rows: + created = _parse(d.get("created_at")) + week = storage.order_week_start(created).isoformat() if created else "" + conn.execute( + """ + INSERT INTO orders (id, number, week_start, table_name, guest_name, guest_phone, + source, waiter, status, price, items, comment, track_code, + created_at, accepted_at, delivered_at, duration_seconds) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (d["id"], d["number"], week, d["table_name"], d.get("guest_name"), + d.get("guest_phone"), d["source"], d.get("waiter"), d["status"], d["price"], + d["items"], d.get("comment"), d.get("track_code"), d["created_at"], + d.get("accepted_at"), d.get("delivered_at"), d["duration_seconds"]), + ) + conn.execute("PRAGMA foreign_keys = ON") + # пересоздание сносит все индексы старой таблицы — восстанавливаем оба + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS orders_track_code_idx ON orders (track_code)" + " WHERE track_code IS NOT NULL" + ) + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS orders_week_number_idx ON orders (week_start, number)" + ) + + +def _migrate_catalog_columns(conn) -> None: + """Колонки custom/created_at/deleted/stock добавлены позже — дополняем живые таблицы.""" + cols = _table_columns(conn, "menu_items") + if "custom" not in cols: + conn.execute("ALTER TABLE menu_items ADD COLUMN custom INTEGER NOT NULL DEFAULT 0") + if "created_at" not in cols: + conn.execute("ALTER TABLE menu_items ADD COLUMN created_at TEXT NOT NULL DEFAULT ''") + if "deleted" not in cols: + conn.execute("ALTER TABLE menu_items ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0") + if "stock" not in cols: + conn.execute("ALTER TABLE menu_items ADD COLUMN stock INTEGER") + if "auto_stopped" not in cols: + conn.execute("ALTER TABLE menu_items ADD COLUMN auto_stopped INTEGER NOT NULL DEFAULT 0") + cols = _table_columns(conn, "tobacco_flavors") + if "custom" not in cols: + conn.execute("ALTER TABLE tobacco_flavors ADD COLUMN custom INTEGER NOT NULL DEFAULT 0") + if "created_at" not in cols: + conn.execute("ALTER TABLE tobacco_flavors ADD COLUMN created_at TEXT NOT NULL DEFAULT ''") + if "deleted" not in cols: + conn.execute("ALTER TABLE tobacco_flavors ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0") + + +def _migrate_crm_users_role(conn) -> None: + """Если в CHECK роли нет какой-то из текущих ролей (база до + нововведения) — пересоздаём таблицу. Сессии эфемерны, их сбрасываем.""" + row = conn.execute( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'crm_users'" + ).fetchone() + sql = row["sql"] or "" if row else "" + if row and "'owner'" in sql and "'hookah'" in sql: + return + conn.execute("PRAGMA foreign_keys = OFF") + conn.executescript( + """ + DROP TABLE IF EXISTS sessions; + DROP TABLE crm_users; + CREATE TABLE crm_users ( + login TEXT PRIMARY KEY, + password_hash TEXT NOT NULL, + name TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'waiter', 'hookah')) + ); + CREATE TABLE sessions ( + token TEXT PRIMARY KEY, + login TEXT NOT NULL REFERENCES crm_users(login) ON DELETE CASCADE, + role TEXT NOT NULL, + expires_at TEXT NOT NULL + ); + """ + ) + conn.execute("PRAGMA foreign_keys = ON") + + +# ---------------------------------------------------------------- пользователи/сессии + +def _seed_users() -> None: + users = [ + ( + os.environ.get("CRM_OWNER_LOGIN", "owner"), + os.environ.get("CRM_OWNER_PASSWORD", "neon2026"), + os.environ.get("CRM_OWNER_NAME", "Нурлан"), + "owner", + ), + ( + os.environ.get("CRM_ADMIN_LOGIN", "admin"), + os.environ.get("CRM_ADMIN_PASSWORD", "neon2026"), + os.environ.get("CRM_ADMIN_NAME", "Ирина"), + "admin", + ), + ( + os.environ.get("CRM_WAITER_LOGIN", "waiter.mark"), + os.environ.get("CRM_WAITER_PASSWORD", "neon2026"), + os.environ.get("CRM_WAITER_NAME", "Марк"), + "waiter", + ), + ] + with db() as conn: + for login, password, name, role in users: + conn.execute( + "INSERT OR IGNORE INTO crm_users (login, password_hash, name, role)" + " VALUES (?, ?, ?, ?)", + (login, storage.hash_password(password), name, role), + ) + + +def list_users() -> list[dict]: + with db() as conn: + rows = conn.execute( + "SELECT login, name, role FROM crm_users WHERE NOT deleted ORDER BY role, login" + ).fetchall() + return [dict(r) for r in rows] + + +def create_user(login: str, password: str, name: str, role: str) -> dict | None | str: + """Новый сотрудник. None — логин уже занят; 'name_taken' — имя занято + (атрибуция заказов требует уникальных имён, F-15).""" + with db() as conn: + exists = conn.execute("SELECT 1 FROM crm_users WHERE login = ?", (login,)).fetchone() + if exists: + return None + name_taken = conn.execute("SELECT 1 FROM crm_users WHERE name = ?", (name,)).fetchone() + if name_taken: + return "name_taken" + conn.execute( + "INSERT INTO crm_users (login, password_hash, name, role) VALUES (?, ?, ?, ?)", + (login, storage.hash_password(password), name, role), + ) + return {"login": login, "name": name, "role": role} + + +def delete_user(login: str) -> bool: + """Мягкое удаление (NF-11): помечаем deleted и гасим сессии в одной + транзакции; смены и заказы бывшего сотрудника остаются в базе.""" + with db() as conn: + cur = conn.execute( + "UPDATE crm_users SET deleted = 1" + " WHERE login = ? AND role IN ('admin', 'waiter', 'hookah') AND NOT deleted", + (login,), + ) + if cur.rowcount == 0: + return False + conn.execute("DELETE FROM sessions WHERE login = ?", (login,)) + return True + + +def login_user(login: str, password: str) -> dict | None: + with db() as conn: + row = conn.execute( + # NF-11: удалённого сотрудника не пускаем — неотличимо от + # несуществующего логина (dummy-хэш ниже) + "SELECT login, password_hash, name, role FROM crm_users" + " WHERE login = ? AND NOT deleted", + (login,), + ).fetchone() + if row is None: + # та же стоимость, что у существующего логина (анти-enumeration) + storage.verify_password(password, storage._DUMMY_HASH) + return None + if not storage.verify_password(password, row["password_hash"]): + return None + + token = secrets.token_urlsafe(32) + now = storage.utcnow() + with db() as conn: + expired = [ + r["token"] for r in conn.execute("SELECT token, expires_at FROM sessions").fetchall() + if (_parse(r["expires_at"]) or now) < now + ] + conn.executemany("DELETE FROM sessions WHERE token = ?", [(t,) for t in expired]) + conn.execute( + "INSERT INTO sessions (token, login, role, expires_at) VALUES (?, ?, ?, ?)", + (token, row["login"], row["role"], _iso(now + storage.SESSION_TTL)), + ) + return {"token": token, "login": row["login"], "name": row["name"], "role": row["role"]} + + +def session_user(token: str) -> dict | None: + if not token: + return None + with db() as conn: + row = conn.execute( + """ + SELECT u.login, u.name, u.role, s.expires_at + FROM sessions s JOIN crm_users u ON u.login = s.login + WHERE s.token = ? + AND NOT u.deleted -- ревью NF-11: гонка login_user→delete_user + """, + (token,), + ).fetchone() + if row is None or (_parse(row["expires_at"]) or storage.utcnow()) <= storage.utcnow(): + return None + # скольжение сессии для сменных ролей (см. postgres-версию): + # планшет, который опрашивает API, не разлогинится посреди смены + if row["role"] in ("waiter", "hookah"): + remaining = (_parse(row["expires_at"]) or storage.utcnow()) - storage.utcnow() + if remaining < timedelta(hours=2): + conn.execute( + "UPDATE sessions SET expires_at = ? WHERE token = ?", + ((storage.utcnow() + storage.SESSION_TTL).isoformat(), token), + ) + return {"login": row["login"], "name": row["name"], "role": row["role"]} + + +def logout_user(token: str) -> None: + with db() as conn: + conn.execute("DELETE FROM sessions WHERE token = ?", (token,)) + + +def weak_default_logins(default: str = "neon2026") -> list[str]: + """Логины, чей пароль в БД фактически совпадает с известным ПУБЛИЧНЫМ + дефолтом (F-13) — см. postgres-версию в storage.py.""" + candidates = {default, "neon2026", "noen2026"} + with db() as conn: + rows = conn.execute( + "SELECT login, password_hash FROM crm_users WHERE NOT deleted" + ).fetchall() + hits = { + r["login"] for r in rows + if any(storage.verify_password(c, r["password_hash"]) for c in candidates) + } + return sorted(hits) + + +def change_password(login: str, current_password: str, new_password: str, + keep_token: str | None = None) -> bool: + """Смена пароля своей учётки (F-16) — контракт postgres-версии.""" + with db() as conn: + row = conn.execute( + "SELECT password_hash FROM crm_users WHERE login = ?", (login,) + ).fetchone() + if row is None or not storage.verify_password(current_password, row["password_hash"]): + return False + conn.execute( + "UPDATE crm_users SET password_hash = ? WHERE login = ?", + (storage.hash_password(new_password), login), + ) + conn.execute( + "DELETE FROM sessions WHERE login = ? AND token IS NOT ?", + (login, keep_token), + ) + return True + + +def reset_password(login: str, new_password: str) -> bool: + """Сброс пароля чужой учётки владельцем — контракт postgres-версии.""" + with db() as conn: + row = conn.execute( + "SELECT 1 FROM crm_users WHERE login = ? AND NOT deleted", (login,) + ).fetchone() + if row is None: + return False + conn.execute( + "UPDATE crm_users SET password_hash = ? WHERE login = ?", + (storage.hash_password(new_password), login), + ) + conn.execute("DELETE FROM sessions WHERE login = ?", (login,)) + return True + + +def is_known_staff_name(name: str) -> bool: + """Действующий сотрудник с таким именем/логином существует (валидация + waiter, F-15); удалённые (NF-11) не считаются.""" + with db() as conn: + row = conn.execute( + "SELECT 1 FROM crm_users WHERE NOT deleted AND (name = ? OR login = ?)", + (name, name), + ).fetchone() + return row is not None + + +# ---------------------------------------------------------------- каталог и стоп-лист + +def _seed_catalog() -> None: + """Тот же контракт, что и Postgres-версия: обновить каталог, + сохранив стоп-листы, и удалить позиции, которых больше нет на сайте. + Seed читается строго (F-6): битый файл роняет старт до записи в БД.""" + menu = storage._read_seed("menu.json", strict=True) + tobacco = storage._read_seed("tobacco.json", strict=True) + + with db() as conn: + # сеянные позиции — не «новинки»: их created_at всегда на 30 дней в прошлом + old_iso = _iso(storage.utcnow() - timedelta(days=30)) + menu = menu + [dict(h) for h in storage.HOOKAH_ITEMS] + if menu: + for m in menu: + conn.execute( + """ + INSERT INTO menu_items (id, name, category, price, portion, created_at, custom) + VALUES (?, ?, ?, ?, ?, ?, 0) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, category = excluded.category, + price = excluded.price, portion = excluded.portion + """, + (m["id"], m["name"], m["category"], m["price"], m.get("portion"), old_iso), + ) + marks = ",".join("?" * len(menu)) + # custom-позиции владельца не трогаем и не удаляем — их на сайте нет + conn.execute( + f"DELETE FROM menu_items WHERE custom = 0 AND id NOT IN ({marks})", + [m["id"] for m in menu], + ) + conn.execute("UPDATE menu_items SET created_at = ? WHERE custom = 0", (old_iso,)) + if tobacco: + for t in tobacco: + conn.execute( + """ + INSERT INTO tobacco_flavors (id, brand, flavor, note, strength, cooling, created_at, custom) + VALUES (?, ?, ?, ?, ?, ?, ?, 0) + ON CONFLICT(id) DO UPDATE SET + brand = excluded.brand, flavor = excluded.flavor, + note = excluded.note, strength = excluded.strength, + cooling = excluded.cooling + """, + (t["id"], t["brand"], t["flavor"], t.get("note"), t.get("strength", 2), + 1 if t.get("cooling") else 0, old_iso), + ) + marks = ",".join("?" * len(tobacco)) + conn.execute( + f"DELETE FROM tobacco_flavors WHERE custom = 0 AND id NOT IN ({marks})", + [t["id"] for t in tobacco], + ) + conn.execute("UPDATE tobacco_flavors SET created_at = ? WHERE custom = 0", (old_iso,)) + + +def _menu_row(r) -> dict: + d = dict(r) + for key in ("stopped", "custom", "deleted", "cooling"): + if key in d: + d[key] = bool(d[key]) + return d + + +def list_menu() -> list[dict]: + with db() as conn: + rows = conn.execute( + "SELECT id, name, category, price, portion, stopped, custom, deleted, stock, created_at" + " FROM menu_items ORDER BY category, name" + ).fetchall() + out = [] + for r in rows: + d = _menu_row(r) + d["has_photo"] = storage.photo_for_item(d["id"]) is not None + out.append(d) + return out + + +def set_menu_stopped(item_id: str, stopped: bool, conn=None) -> dict | None: + if conn is None: + with db() as conn: + return set_menu_stopped(item_id, stopped, conn) + row = conn.execute( + """ + UPDATE menu_items SET stopped = ?, auto_stopped = 0 + WHERE id = ? AND deleted = 0 + RETURNING id, name, category, price, portion, stopped, stock + """, + (1 if stopped else 0, item_id), + ).fetchone() + return _menu_row(row) if row else None + + +def set_menu_deleted(item_id: str, deleted: bool, conn=None) -> dict | None: + """Мягкое удаление/восстановление позиции (только владелец).""" + if conn is None: + with db() as conn: + return set_menu_deleted(item_id, deleted, conn) + row = conn.execute( + """ + UPDATE menu_items SET deleted = ? WHERE id = ? + RETURNING id, name, category, price, portion, stopped, custom, deleted + """, + (1 if deleted else 0, item_id), + ).fetchone() + return _menu_row(row) if row else None + + +def purge_menu_item(item_id: str): + """Безвозвратное удаление позиции меню (см. storage.purge_menu_item): + только кастомные позиции, фото стирается, история заказов не трогается. + True — удалена; False — не найдена; 'seed' — стартовая позиция сайта.""" + with db() as conn: + row = conn.execute( + "SELECT custom FROM menu_items WHERE id = ?", (item_id,) + ).fetchone() + if row is None: + return False + if not row["custom"]: + return "seed" + conn.execute("DELETE FROM menu_items WHERE id = ?", (item_id,)) + storage.purge_item_photos(item_id) + return True + + +def menu_categories() -> list[str]: + with db() as conn: + rows = conn.execute( + "SELECT DISTINCT category FROM menu_items ORDER BY category" + ).fetchall() + return [r["category"] for r in rows] + + +def create_custom_menu_item( + name: str, category: str, price: int, portion: str | None, stock: int | None = None +) -> dict: + """Новая позиция меню от владельца. Видна на сайте как «новинка» неделю. + + stock (шт) — сразу включить поштучный учёт (пиво/энергетики банками).""" + item_id = "custom-" + secrets.token_hex(4) + created = _iso(storage.utcnow()) + with db() as conn: + conn.execute( + """ + INSERT INTO menu_items (id, name, category, price, portion, stopped, custom, + stock, created_at) + VALUES (?, ?, ?, ?, ?, 0, 1, ?, ?) + """, + (item_id, name, category, price, portion, stock, created), + ) + row = conn.execute("SELECT * FROM menu_items WHERE id = ?", (item_id,)).fetchone() + return _menu_row(row) + + +def update_menu_item(item_id: str, fields: dict, conn=None) -> dict | str | None: + """Правка позиции владельцем: имя/категория/цена/порция (только переданные поля). + 'seed' — стартовая позиция сайта (правится в коде сайта); None — не найдена; + иначе обновлённая позиция. NF-07: conn — внешняя транзакция мульти-полевого + PATCH каталога.""" + sets, vals = [], [] + for col in ("name", "category", "price", "portion"): + if col in fields: + sets.append(f"{col} = ?") + vals.append(fields[col]) + if not sets: + return None + if conn is None: + with db() as conn: + return update_menu_item(item_id, fields, conn) + row = conn.execute("SELECT custom FROM menu_items WHERE id = ?", (item_id,)).fetchone() + if row is None: + return None + if not row["custom"]: + return "seed" + row = conn.execute( + f"UPDATE menu_items SET {', '.join(sets)} WHERE id = ?" + " RETURNING id, name, category, price, portion, stock, created_at", + (*vals, item_id), + ).fetchone() + return _menu_row(row) + + +def set_menu_stock(item_id: str, new_stock: int | None = None, add: int | None = None, + conn=None) -> dict | None: + """Поштучный учёт позиции (владелец) — контракт postgres-версии storage.py. + NF-07: conn — внешняя транзакция мульти-полевого PATCH каталога.""" + if conn is None: + with db() as conn: + return set_menu_stock(item_id, new_stock, add, conn) + if add is not None: + row = conn.execute( + """ + UPDATE menu_items SET + stock = COALESCE(stock, 0) + ?, + stopped = CASE WHEN COALESCE(stock, 0) + ? > 0 AND auto_stopped = 1 + THEN 0 ELSE stopped END, + auto_stopped = CASE WHEN COALESCE(stock, 0) + ? > 0 THEN 0 ELSE auto_stopped END + WHERE id = ? AND deleted = 0 AND stock IS NOT NULL + RETURNING id, name, category, price, portion, stopped, stock + """, + (add, add, add, item_id), + ).fetchone() + elif new_stock is None: + # выключение учёта: авто-стоп снимаем, ручной остаётся + row = conn.execute( + """ + UPDATE menu_items SET + stock = NULL, + stopped = CASE WHEN auto_stopped = 1 THEN 0 ELSE stopped END, + auto_stopped = 0 + WHERE id = ? AND deleted = 0 + RETURNING id, name, category, price, portion, stopped, stock + """, + (item_id,), + ).fetchone() + else: + row = conn.execute( + """ + UPDATE menu_items SET + stock = ?, + stopped = CASE + WHEN ? > 0 THEN CASE WHEN auto_stopped = 1 THEN 0 ELSE stopped END + ELSE 1 END, + auto_stopped = CASE WHEN ? = 0 THEN 1 ELSE 0 END + WHERE id = ? AND deleted = 0 + RETURNING id, name, category, price, portion, stopped, stock + """, + (new_stock, new_stock, new_stock, item_id), + ).fetchone() + return _menu_row(row) if row else None + + +def create_custom_tobacco( + brand: str, flavor: str, note: str | None, strength: int, cooling: bool +) -> dict: + """Новый вкус табака от владельца. Виден на сайте как «новинка» неделю.""" + flavor_id = "custom-" + secrets.token_hex(4) + created = _iso(storage.utcnow()) + with db() as conn: + conn.execute( + """ + INSERT INTO tobacco_flavors (id, brand, flavor, note, strength, cooling, stopped, custom, created_at) + VALUES (?, ?, ?, ?, ?, ?, 0, 1, ?) + """, + (flavor_id, brand, flavor, note, strength, 1 if cooling else 0, created), + ) + row = conn.execute("SELECT * FROM tobacco_flavors WHERE id = ?", (flavor_id,)).fetchone() + return _menu_row(row) + + +def list_tobacco() -> list[dict]: + with db() as conn: + rows = conn.execute( + "SELECT id, brand, flavor, note, strength, cooling, stopped, custom, deleted, created_at" + " FROM tobacco_flavors ORDER BY brand, flavor" + ).fetchall() + return [_menu_row(r) for r in rows] + + +def set_tobacco_stopped(flavor_id: str, stopped: bool, conn=None) -> dict | None: + if conn is None: + with db() as conn: + return set_tobacco_stopped(flavor_id, stopped, conn) + row = conn.execute( + """ + UPDATE tobacco_flavors SET stopped = ? WHERE id = ? AND deleted = 0 + RETURNING id, brand, flavor, note, strength, cooling, stopped + """, + (1 if stopped else 0, flavor_id), + ).fetchone() + return _menu_row(row) if row else None + + +# ---------------------------------------------------------------- миксы владельца + +def _mix_row(r) -> dict: + d = dict(r) + d["flavors"] = json.loads(d["flavors"]) + if "tags" in d: + d["tags"] = json.loads(d["tags"] or "[]") + return d + + +def list_mixes() -> list[dict]: + """Миксы владельца (не удалённые) для управления в CRM.""" + with db() as conn: + rows = conn.execute( + "SELECT id, name, flavors, note, strength, cooling, price, tags, created_at" + " FROM mix_recipes WHERE deleted = 0 ORDER BY created_at DESC" + ).fetchall() + return [_mix_row(r) for r in rows] + + +def create_custom_mix( + name: str, flavor_ids: list[str], note: str | None, + strength: int | None = None, cooling: int | None = None, price: int | None = None, + tags: list[str] | None = None, +) -> dict | None: + """Микс от владельца: название + вкусы из справочника табаков. + + None — вкусы не выбраны или в справочнике их меньше двух. + Доли не хранятся: пропорции подбирает кальянщик. strength/cooling/price — + расширенная информация для сайта, tags — ярлыки для AI-генерации. + """ + with db() as conn: + marks = ",".join("?" * len(flavor_ids)) + rows = conn.execute( + f"SELECT id, brand, flavor FROM tobacco_flavors" + f" WHERE id IN ({marks}) AND deleted = 0", + flavor_ids, + ).fetchall() + if len(rows) < 2: + return None + by_id = {r["id"]: dict(r) for r in rows} + flavors = [by_id[i] for i in flavor_ids if i in by_id] + mix_id = "mix-" + secrets.token_hex(4) + conn.execute( + """ + INSERT INTO mix_recipes (id, name, flavors, note, strength, cooling, price, + tags, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (mix_id, name, json.dumps(flavors, ensure_ascii=False), note, + strength, cooling, price, + json.dumps(tags or [], ensure_ascii=False), _iso(storage.utcnow())), + ) + row = conn.execute("SELECT * FROM mix_recipes WHERE id = ?", (mix_id,)).fetchone() + return _mix_row(row) + + +def update_mix( + mix_id: str, name: str, flavor_ids: list[str], note: str | None, + strength: int | None = None, cooling: int | None = None, price: int | None = None, + tags: list[str] | None = None, +) -> dict | str | None: + """Правка микса: название, состав и расширенные поля — тот же набор, что + при создании (см. create_custom_mix). 'not_found' — микса нет или он удалён; + 'bad_flavors' — в справочнике меньше двух живых вкусов; иначе обновлённый.""" + with db() as conn: + row = conn.execute( + "SELECT id FROM mix_recipes WHERE id = ? AND deleted = 0", (mix_id,) + ).fetchone() + if row is None: + return "not_found" + marks = ",".join("?" * len(flavor_ids)) + rows = conn.execute( + f"SELECT id, brand, flavor FROM tobacco_flavors" + f" WHERE id IN ({marks}) AND deleted = 0", + flavor_ids, + ).fetchall() + if len(rows) < 2: + return "bad_flavors" + by_id = {r["id"]: dict(r) for r in rows} + flavors = [by_id[i] for i in flavor_ids if i in by_id] + row = conn.execute( + """ + UPDATE mix_recipes SET name = ?, flavors = ?, note = ?, strength = ?, + cooling = ?, price = ?, tags = ? + WHERE id = ? AND deleted = 0 + RETURNING id, name, flavors, note, strength, cooling, price, tags, created_at + """, + (name, json.dumps(flavors, ensure_ascii=False), note, strength, cooling, price, + json.dumps(tags or [], ensure_ascii=False), mix_id), + ).fetchone() + return _mix_row(row) + + +# --------------------------------------------------------------- витрина сайта + +def _site_edit_row(r) -> dict: + d = dict(r) + if isinstance(d.get("tags"), str): + d["tags"] = json.loads(d["tags"] or "[]") + if isinstance(d.get("flavors"), str): + d["flavors"] = json.loads(d["flavors"]) if d["flavors"] else None + return d + + +def list_site_mix_edits() -> list[dict]: + """Правки витринных миксов сайта: {id, name?, note?, strength?, cooling?, + price?, tags?, flavors?} — NULL/отсутствие поля значит «не менялось». + Отдаются публично через /api/catalog (сайт применяет к своей витрине).""" + with db() as conn: + rows = conn.execute( + "SELECT id, name, note, strength, cooling, price, tags, flavors FROM site_mix_edits" + ).fetchall() + return [_site_edit_row(r) for r in rows] + + +def site_mixes_edited() -> list[dict]: + """Витринные миксы сайта, слитые с правками CRM: edited=True, поверх + исходных значений наложены переопределённые поля (и состав, если правился).""" + edits = {e["id"]: e for e in list_site_mix_edits()} + out = [] + for m in storage.site_mixes(): + row = dict(m) + e = edits.get(row["id"]) + if not e: + row["edited"] = False + out.append(row) + continue + row["edited"] = True + if e.get("name"): + row["name"] = e["name"] + if e.get("note") is not None: + row["note"] = e["note"] + if e.get("strength") is not None: + row["strength"] = e["strength"] + if e.get("cooling") is not None: + row["cooling"] = e["cooling"] + if e.get("price") is not None: + row["price"] = e["price"] + if e.get("tags"): + row["flavor_tags"] = e["tags"] + if e.get("flavors"): + row["flavors"] = e["flavors"] + out.append(row) + return out + + +def update_site_mix(mix_id: str, fields: dict): + """Сохранить правку витринного микса (владелец): поля — тот же словарь, + что отдал _handle_site_mix_patch; flavors None = состав не менялся. + Возвращает сохранённую строку правки.""" + with db() as conn: + conn.execute( + """ + INSERT INTO site_mix_edits (id, name, note, strength, cooling, price, tags, flavors) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, note = excluded.note, strength = excluded.strength, + cooling = excluded.cooling, price = excluded.price, tags = excluded.tags, + flavors = excluded.flavors + """, + (mix_id, fields.get("name"), fields.get("note"), fields.get("strength"), + fields.get("cooling"), fields.get("price"), + json.dumps(fields.get("tags"), ensure_ascii=False) if fields.get("tags") is not None else None, + json.dumps(fields.get("flavors"), ensure_ascii=False) if fields.get("flavors") is not None else None), + ) + return mix_id + + +def reset_site_mix(mix_id: str) -> bool: + """Вернуть витринный микс к исходному виду: правка удаляется. + True — была правка и она снята; False — правки не было.""" + with db() as conn: + cur = conn.execute("DELETE FROM site_mix_edits WHERE id = ?", (mix_id,)) + return cur.rowcount > 0 + + +def purge_tobacco_item(flavor_id: str): + """Безвозвратное удаление вкуса (см. storage.purge_menu_item): + только кастомные вкусы; фото у вкусов нет, история заказов не трогается. + True — удалён; False — не найден; 'seed' — стартовый вкус сайта.""" + with db() as conn: + row = conn.execute( + "SELECT custom FROM tobacco_flavors WHERE id = ?", (flavor_id,) + ).fetchone() + if row is None: + return False + if not row["custom"]: + return "seed" + conn.execute("DELETE FROM tobacco_flavors WHERE id = ?", (flavor_id,)) + return True + + +def purge_mix_item(mix_id: str): + """Безвозвратное удаление микса. В mix_recipes только миксы владельца — + авторские миксы сайта читаются из seed/mixes.json без БД (site_mixes), + сид здесь ничего не восстанавливает. + True — удалён; False — не найден.""" + with db() as conn: + row = conn.execute( + "SELECT id FROM mix_recipes WHERE id = ?", (mix_id,) + ).fetchone() + if row is None: + return False + conn.execute("DELETE FROM mix_recipes WHERE id = ?", (mix_id,)) + return True + + +def set_mix_deleted(mix_id: str, deleted: bool) -> dict | None: + """Мягкое удаление/восстановление микса (только владелец).""" + with db() as conn: + row = conn.execute( + "SELECT id, name, flavors, note, strength, cooling, price, tags, created_at" + " FROM mix_recipes WHERE id = ? AND deleted <> ?", + (mix_id, 1 if deleted else 0), + ).fetchone() + if row is None: + return None + conn.execute( + "UPDATE mix_recipes SET deleted = ? WHERE id = ?", + (1 if deleted else 0, mix_id), + ) + return _mix_row(row) + + +def set_tobacco_deleted(flavor_id: str, deleted: bool, conn=None) -> dict | None: + """Мягкое удаление/восстановление вкуса (только владелец).""" + if conn is None: + with db() as conn: + return set_tobacco_deleted(flavor_id, deleted, conn) + row = conn.execute( + """ + UPDATE tobacco_flavors SET deleted = ? WHERE id = ? + RETURNING id, brand, flavor, note, strength, cooling, stopped, custom, deleted + """, + (1 if deleted else 0, flavor_id), + ).fetchone() + return _menu_row(row) if row else None + + +def stoplist() -> dict: + """Публичный стоп-лист для сайта: id позиций меню и id вкусов табака.""" + with db() as conn: + menu = [r["id"] for r in conn.execute( + "SELECT id FROM menu_items WHERE stopped = 1" + ).fetchall()] + tobacco = [r["id"] for r in conn.execute( + "SELECT id FROM tobacco_flavors WHERE stopped = 1" + ).fetchall()] + return {"stopped_menu": menu, "stopped_tobacco": tobacco} + + +# ---------------------------------------------------------------- заказы + +def _order_json(r) -> dict: + def iso(ts: str | None) -> str | None: + dt = _parse(ts) + return dt.astimezone().isoformat() if dt else None + + return { + "id": r["id"], + # недельный номер для показа (обнуляется каждую неделю); + # стабильный идентификатор API — внутренний id выше + "number": r["number"], + "table": r["table_name"], + "guest_name": r["guest_name"], + "guest_phone": r["guest_phone"], + "source": r["source"], + "waiter": r["waiter"], + "status": r["status"], + "price": r["price"], + "items": json.loads(r["items"]), + "comment": r["comment"], + "code": r["track_code"], + "created_at": iso(r["created_at"]), + "accepted_at": iso(r["accepted_at"]), + "delivered_at": iso(r["delivered_at"]), + "duration_seconds": r["duration_seconds"], + } + + +def list_orders() -> list[dict]: + # сортируем по внутреннему id: при недельной нумерации number + # сбрасывается и не отражает порядок создания между неделями + with db() as conn: + rows = conn.execute("SELECT * FROM orders ORDER BY id DESC LIMIT 500").fetchall() + return [_order_json(r) for r in rows] + + +def menu_prices() -> dict[str, int]: + with db() as conn: + rows = conn.execute("SELECT id, price FROM menu_items").fetchall() + return {r["id"]: r["price"] for r in rows} + + +def create_order( + table_name: str, + items: list[dict], + source: str = "site", + guest_name: str | None = None, + guest_phone: str | None = None, + comment: str | None = None, +) -> dict: + """Цену считаем на сервере по меню — как в Postgres-версии. + + Номер — внутри «недели нумерации» (MAX+1 по week_start, сбрасывается + каждую неделю), код — для отслеживания гостем. + """ + prices = menu_prices() + prepared = [] + for i in items: + # цену берём только из каталога — цену клиента игнорируем даже + # для неизвестного id (см. комментарий в storage.create_order) + price = prices.get(i.get("id") or "", 0) + prepared.append({ + "id": i.get("id"), + "name": str(i.get("name", ""))[:120], + "qty": int(i.get("qty", 1)), + "price": price, + }) + total = sum(i["price"] * i["qty"] for i in prepared) + week = storage.order_week_start().isoformat() + + with db() as conn: + for _ in range(5): + # коллизии номера (гонка двух заказов) или кода практически + # невероятны — страхуемся повтором с новыми значениями + number = conn.execute( + "SELECT COALESCE(MAX(number), 0) + 1 AS n FROM orders WHERE week_start = ?", + (week,), + ).fetchone()["n"] + code = storage.generate_track_code() + try: + conn.execute( + """ + INSERT INTO orders (number, week_start, table_name, guest_name, guest_phone, + source, status, price, items, comment, track_code, created_at) + VALUES (?, ?, ?, ?, ?, ?, 'new', ?, ?, ?, ?, ?) + """, + (number, week, table_name, guest_name, guest_phone, source, total, + json.dumps(prepared, ensure_ascii=False), comment, code, + _iso(storage.utcnow())), + ) + break + except sqlite3.IntegrityError: + continue + else: + raise RuntimeError("не удалось сгенерировать номер/код заказа") + row = conn.execute("SELECT * FROM orders WHERE id = last_insert_rowid()").fetchone() + return _order_json(row) + + +def find_order_by_code(code: str) -> dict | None: + """Заказ по коду отслеживания (публичная страница статуса на сайте).""" + with db() as conn: + row = conn.execute("SELECT * FROM orders WHERE track_code = ?", (code,)).fetchone() + return _order_json(row) if row else None + + +def _stock_touch(conn, items, sign: int) -> list[str]: + """Списать/вернуть штучные позиции заказа — контракт postgres-версии + (см. storage._stock_touch). Возвращает id затронутых позиций. + Без клампа (F-17): минус — честный дефицит, фантомных банок нет.""" + touched: list[str] = [] + for i in items or []: + if not isinstance(i, dict): + continue + item_id = str(i.get("id") or "")[:64] + try: + qty = min(50, max(1, int(i.get("qty", 1)))) + except (TypeError, ValueError): + continue + if not item_id: + continue + if sign > 0: # выдача: списываем + row = conn.execute( + "UPDATE menu_items SET stock = stock - ?" + " WHERE id = ? AND stock IS NOT NULL AND deleted = 0" + " RETURNING id, stock", + (qty, item_id), + ).fetchone() + else: # возврат (заказ ушёл из «Выдан») + row = conn.execute( + "UPDATE menu_items SET stock = stock + ?" + " WHERE id = ? AND stock IS NOT NULL AND deleted = 0" + " RETURNING id, stock", + (qty, item_id), + ).fetchone() + if row is not None: + touched.append(row["id"]) + return touched + + +def update_order(order_id: int, status: str, waiter: str | None, + actor: dict | None = None) -> dict | str | None: + """Смена статуса по внутреннему id; метки времени и длительность считает сервер. + + NF-03: машина переходов и журнал orders_audit — контракт postgres-версии. + 'bad_transition' — переход недоступен роли actor; повтор того же статуса + без смены официанта — no-op (повторный done не перезаписывает delivered_at). + + Штучный учёт: переход в «Выдан» списывает позиции с включённым учётом + (однократно, флаг stock_applied), вышедшие из «Выдан» — возвращает. + Позиции с остатком 0 уходят в стоп-лист автоматически.""" + with db() as conn: + row = conn.execute("SELECT * FROM orders WHERE id = ?", (order_id,)).fetchone() + if row is None: + return None + + old = row["status"] + old_waiter = row["waiter"] + actor_login = (actor or {}).get("login") or "system" + actor_role = (actor or {}).get("role") + + if status == old and (waiter is None or waiter == old_waiter): + return _order_json(row) # повторный PATCH без изменений — no-op + + # ревью NF-03: сменная роль не может переатрибутить заказ «на месте» + # (см. postgres-версию) + if status == old and waiter is not None \ + and actor_role not in ("owner", "admin"): + return "bad_transition" + + if status != old and actor_role not in ("owner", "admin") \ + and (old, status) not in storage.ORDER_TRANSITIONS_STAFF: + return "bad_transition" + + if status != old: + ts = storage.utcnow() + accepted = _parse(row["accepted_at"]) + delivered = _parse(row["delivered_at"]) + duration = row["duration_seconds"] + if status == "new": + accepted = delivered = None + duration = None + elif status == "active": + accepted = accepted or ts + delivered = None + duration = None + elif status == "cancelled": + # отмена: историю принятия оставляем, выдачи и длительности нет + delivered = None + duration = None + else: # done + delivered = ts + duration = int((ts - accepted).total_seconds()) if accepted else None + else: + accepted = _parse(row["accepted_at"]) + delivered = _parse(row["delivered_at"]) + duration = row["duration_seconds"] + + if waiter is None: + waiter = old_waiter + + conn.execute( + """ + UPDATE orders SET status = ?, waiter = ?, accepted_at = ?, + delivered_at = ?, duration_seconds = ? + WHERE id = ? + """, + (status, waiter, _iso(accepted), _iso(delivered), duration, order_id), + ) + + # журнал (NF-03): по строке на каждую фактическую смену + if status != old: + conn.execute( + "INSERT INTO orders_audit (order_id, user_login, action, old_value, new_value)" + " VALUES (?, ?, 'status', ?, ?)", + (order_id, actor_login, old, status), + ) + if waiter != old_waiter: + conn.execute( + "INSERT INTO orders_audit (order_id, user_login, action, old_value, new_value)" + " VALUES (?, ?, 'waiter', ?, ?)", + (order_id, actor_login, old_waiter, waiter), + ) + + was_done = old == "done" + now_done = status == "done" + applied = bool(row["stock_applied"]) + if now_done and not was_done and not applied: + # списание под атомарным предикатом на флаге (гонка дублирующего PATCH): + # конкурентный запрос увидит 0 строк и не спишет товар второй раз + claimed = conn.execute( + "UPDATE orders SET stock_applied = 1 WHERE id = ? AND stock_applied = 0", + (order_id,), + ) + if claimed.rowcount == 1: + touched = _stock_touch(conn, json.loads(row["items"]), sign=1) + if touched: + marks = ",".join("?" * len(touched)) + conn.execute( + f"UPDATE menu_items SET stopped = 1, auto_stopped = 1" + f" WHERE id IN ({marks}) AND stock <= 0 AND stopped = 0", + touched, + ) + elif was_done and not now_done and applied: + # заказ вернули из «Выдан» — товар вернулся на полку + released = conn.execute( + "UPDATE orders SET stock_applied = 0 WHERE id = ? AND stock_applied = 1", + (order_id,), + ) + if released.rowcount == 1: + touched = _stock_touch(conn, json.loads(row["items"]), sign=-1) + if touched: + marks = ",".join("?" * len(touched)) + conn.execute( + f"UPDATE menu_items SET stopped = 0, auto_stopped = 0" + f" WHERE id IN ({marks}) AND auto_stopped = 1 AND stock > 0", + touched, + ) + updated = conn.execute("SELECT * FROM orders WHERE id = ?", (order_id,)).fetchone() + return _order_json(updated) + + +def cancel_order_as_guest(order_id: int) -> dict | None: + """Гостевая отмена по коду: атомарно ТОЛЬКО из статуса «Новый» (гонка + с PATCH персонала закрыта предикатом в UPDATE) — контракт postgres-версии. + None — заказ уже ушёл из «Новый», гостю отказ.""" + with db() as conn: + cur = conn.execute( + "UPDATE orders SET status = 'cancelled', delivered_at = NULL," + " duration_seconds = NULL" + " WHERE id = ? AND status = 'new'", + (order_id,), + ) + if cur.rowcount != 1: + return None + row = conn.execute("SELECT * FROM orders WHERE id = ?", (order_id,)).fetchone() + return _order_json(row) + + +# ---------------------------------------------------------------- отчёты и каталог владельца + +def orders_report(dt_from, dt_to) -> dict: + """Отчёт за период [dt_from, dt_to) — timezone-aware UTC. + + Объём данных локальной базы невелик, поэтому агрегируем в Python + вместо специфичных для SQLite JSON-функций. + """ + with db() as conn: + rows = conn.execute("SELECT * FROM orders ORDER BY number").fetchall() + + statuses: dict[str, dict] = {} + by_day: dict[str, dict] = {} + top: dict[str, dict] = {} + durations: list[int] = [] + raw: list[dict] = [] + for r in rows: + created = _parse(r["created_at"]) + if created is None or not (dt_from <= created < dt_to): + continue + raw.append({"status": r["status"], "source": r["source"] or "site", + "price": r["price"], "table_name": r["table_name"], + "created_at": created}) + status = r["status"] + acc = statuses.setdefault(status, {"status": status, "cnt": 0, "total": 0}) + acc["cnt"] += 1 + acc["total"] += r["price"] + + day = created.astimezone().date().isoformat() + d = by_day.setdefault(day, {"day": day, "orders": 0, "revenue": 0, "cancelled": 0}) + d["orders"] += 1 + d["cancelled"] += 1 if status == "cancelled" else 0 + d["revenue"] += r["price"] if status == "done" else 0 + + if status != "cancelled": + for item in json.loads(r["items"]): + t = top.setdefault(item["name"], {"name": item["name"], "qty": 0, "revenue": 0}) + t["qty"] += item["qty"] + t["revenue"] += item["qty"] * item["price"] + + if r["duration_seconds"] is not None: + durations.append(r["duration_seconds"]) + + top_list = sorted(top.values(), key=lambda x: (-x["revenue"], -x["qty"]))[:10] + avg_duration = sum(durations) / len(durations) if durations else None + report = storage._assemble_report(list(statuses.values()), list(by_day.values()), + top_list, avg_duration) + report.update(storage._report_extras(raw)) + return report + + +def first_order_dt() -> datetime | None: + """Самый ранний заказ — для отчёта «за всё время». None, если заказов нет. + + created_at в разных форматах ISO — сравнивать строки ненадёжно, + парсим все и берём минимум (объём локальной базы невелик).""" + with db() as conn: + rows = conn.execute("SELECT created_at FROM orders").fetchall() + parsed = [dt for dt in (_parse(r["created_at"]) for r in rows) if dt is not None] + return min(parsed) if parsed else None + + +def staff_report(dt_from, dt_to) -> list[dict]: + """Статистика по каждому сотруднику за период — как в storage.staff_report. + + Заказы атрибутируются по orders.waiter (имя принявшего), смены — по + shifts.login; открытая смена считается до текущего момента. Забытая + смена сначала автозакрывается (F-19) — иначе фантомные часы в отчёте.""" + storage.auto_close_shifts() + now = storage.utcnow() + with db() as conn: + users = [dict(r) for r in conn.execute( + "SELECT login, name, role FROM crm_users WHERE NOT deleted" + ).fetchall()] + orders = [dict(r) for r in conn.execute( + "SELECT waiter, status, source, price, duration_seconds, created_at FROM orders" + ).fetchall()] + shifts = [dict(r) for r in conn.execute( + "SELECT login, started_at, ended_at FROM shifts" + ).fetchall()] + out = [] + for u in users: + mine = [o for o in orders + if o["waiter"] == u["name"] + and (dt := _parse(o["created_at"])) is not None + and dt_from <= dt < dt_to] + done = [o for o in mine if o["status"] == "done"] + durations = [o["duration_seconds"] for o in mine + if o["duration_seconds"] is not None] + my_shifts = [s for s in shifts + if s["login"] == u["login"] + and (st := _parse(s["started_at"])) is not None + and dt_from <= st < dt_to] + seconds = 0 + for s in my_shifts: + ended = _parse(s["ended_at"]) or now + started = _parse(s["started_at"]) + if started and ended > started: + seconds += (ended - started).total_seconds() + revenue = sum(o["price"] for o in done) + out.append({ + "login": u["login"], + "name": u["name"], + "role": u["role"], + "orders_total": len(mine), + "orders_done": len(done), + "orders_cancelled": sum(1 for o in mine if o["status"] == "cancelled"), + "revenue": revenue, + "avg_check": round(revenue / len(done)) if done else None, + "avg_duration_seconds": round(sum(durations) / len(durations)) if durations else None, + "site_orders": sum(1 for o in mine if o["source"] == "site"), + "hall_orders": sum(1 for o in mine if o["source"] == "hall"), + "shifts": len(my_shifts), + "hours_seconds": round(seconds), + }) + out.sort(key=lambda r: (-r["orders_done"], -r["revenue"], r["name"])) + return out + + +def custom_catalog() -> dict: + """Публичный список позиций, добавленных владельцем через CRM, — + сайт подмешивает их в меню и табаки и помечает «новинку» на неделю. + Удалённые позиции сюда не попадают: их id сайт фильтрует из каталога.""" + with db() as conn: + menu = conn.execute( + "SELECT id, name, category, price, portion, created_at FROM menu_items" + " WHERE custom = 1 AND deleted = 0 ORDER BY created_at DESC" + ).fetchall() + tobacco = conn.execute( + "SELECT id, brand, flavor, note, strength, cooling, created_at" + " FROM tobacco_flavors WHERE custom = 1 AND deleted = 0" + " ORDER BY created_at DESC" + ).fetchall() + mixes = conn.execute( + "SELECT id, name, flavors, note, strength, cooling, price, tags, created_at" + " FROM mix_recipes WHERE deleted = 0 ORDER BY created_at DESC" + ).fetchall() + deleted_menu = conn.execute( + "SELECT id FROM menu_items WHERE deleted = 1" + ).fetchall() + deleted_tobacco = conn.execute( + "SELECT id FROM tobacco_flavors WHERE deleted = 1" + ).fetchall() + + def with_iso(rows, bool_keys=()): + out = [] + for r in rows: + d = dict(r) + for key in bool_keys: + d[key] = bool(d.get(key)) + dt = _parse(d["created_at"]) + d["created_at"] = dt.astimezone().isoformat() if dt else None + out.append(d) + return out + + menu_rows = with_iso(menu) + for d in menu_rows: + d["image"] = storage.photo_for_item(d["id"]) + tobacco_rows = with_iso(tobacco, bool_keys=("cooling",)) + mix_rows = with_iso(mixes) + for d in mix_rows: + d["flavors"] = json.loads(d["flavors"]) + d["tags"] = json.loads(d.get("tags") or "[]") + return { + "menu": menu_rows, + "tobacco": tobacco_rows, + "mixes": mix_rows, + "site_mix_edits": list_site_mix_edits(), + "deleted_menu": [r["id"] for r in deleted_menu], + "deleted_tobacco": [r["id"] for r in deleted_tobacco], + } + + +# ---------------------------------------------------------------- смены официантов + +def auto_close_shifts() -> int: + """Автоматически закрывает смены, чьё окно уже завершилось + (забыл нажать «Закончить смену» — смена закроется сама).""" + now = storage.utcnow() + closed = 0 + with db() as conn: + rows = conn.execute( + "SELECT id, started_at FROM shifts WHERE ended_at IS NULL" + ).fetchall() + for r in rows: + started = _parse(r["started_at"]) + end = storage.auto_close_cutoff(started, now) if started else None + if end is not None: + conn.execute( + "UPDATE shifts SET ended_at = ? WHERE id = ? AND ended_at IS NULL", + (_iso(end), r["id"]), + ) + closed += 1 + return closed + + +def get_open_shift(login: str) -> dict | None: + auto_close_shifts() + with db() as conn: + row = conn.execute( + "SELECT id, login, started_at, ended_at FROM shifts" + " WHERE login = ? AND ended_at IS NULL ORDER BY started_at DESC LIMIT 1", + (login,), + ).fetchone() + return dict(row) if row else None + + +def clock_in(login: str) -> tuple[dict | None, str]: + ok, reason = storage.clock_in_check() + if not ok: + return None, reason + if get_open_shift(login): + return None, "Вы уже на смене" + started = _iso(storage.utcnow()) + with db() as conn: + try: + cur = conn.execute( + "INSERT INTO shifts (login, started_at) VALUES (?, ?)", + (login, started), + ) + except sqlite3.IntegrityError: + # partial unique index (см. _migrate_shift_unique): двойной + # clock-in в гонке двух устройств не проходит (F-20) + return None, "Вы уже на смене" + row = conn.execute("SELECT id, login, started_at, ended_at FROM shifts WHERE id = ?", + (cur.lastrowid,)).fetchone() + return dict(row), "" + + +def clock_out(login: str) -> dict | None: + with db() as conn: + # закрываем ВСЕ открытые смены логина: до unique-индекса гонка могла + # оставить дубль — его надо закрыть тоже, а не оставить висеть (F-20) + rows = conn.execute( + "SELECT id, login, started_at, ended_at FROM shifts" + " WHERE login = ? AND ended_at IS NULL ORDER BY started_at", + (login,), + ).fetchall() + if not rows: + return None + ended = _iso(storage.utcnow()) + for r in rows: + conn.execute("UPDATE shifts SET ended_at = ? WHERE id = ?", (ended, r["id"])) + updated = conn.execute( + "SELECT id, login, started_at, ended_at FROM shifts WHERE id = ?", + (rows[0]["id"],), + ).fetchone() + return dict(updated) + + +def admin_clock_in(login: str) -> dict | None: + """Ручная запись на смену владельцем/админом — без проверки окна.""" + started = _iso(storage.utcnow()) + with db() as conn: + cur = conn.execute( + "INSERT INTO shifts (login, started_at) VALUES (?, ?)", + (login, started), + ) + row = conn.execute("SELECT id, login, started_at, ended_at FROM shifts WHERE id = ?", + (cur.lastrowid,)).fetchone() + return dict(row) + + +def staff_shift_roles() -> list[dict]: + """Все действующие официанты и кальянщики (для управления сменами + владельцем/админом); удалённые (NF-11) скрыты.""" + with db() as conn: + rows = conn.execute( + "SELECT login, name FROM crm_users" + " WHERE role IN ('waiter', 'hookah') AND NOT deleted ORDER BY name" + ).fetchall() + return [dict(r) for r in rows] + + +def on_shift() -> list[dict]: + """Официанты, находящиеся на смене прямо сейчас (без удалённых).""" + auto_close_shifts() + with db() as conn: + rows = conn.execute( + "SELECT s.login, u.name, s.started_at FROM shifts s" + " JOIN crm_users u ON u.login = s.login AND NOT u.deleted" + " WHERE s.ended_at IS NULL ORDER BY s.started_at" + ).fetchall() + return [dict(r) for r in rows] + + +def shift_counts() -> list[dict]: + """Количество смен у действующих официантов и кальянщиков (для владельца).""" + with db() as conn: + rows = conn.execute( + "SELECT u.login, u.name, COUNT(s.id) AS shifts FROM crm_users u" + " LEFT JOIN shifts s ON s.login = u.login" + " WHERE u.role IN ('waiter', 'hookah') AND NOT u.deleted GROUP BY u.login, u.name" + " ORDER BY shifts DESC, u.name" + ).fetchall() + return [dict(r) for r in rows] + + +# ---------------------------------------------------------------- занятость столов и зон + +def halls_list(include_deleted: bool = False) -> list[dict]: + """Справочник столов/залов: активные по порядку сортировки; + include_deleted — с удалёнными в конце (для владельца).""" + with db() as conn: + rows = conn.execute( + "SELECT name, sub, sort, deleted FROM halls_def" + + ("" if include_deleted else " WHERE deleted = 0") + + " ORDER BY deleted, sort, name" + ).fetchall() + return [dict(r) for r in rows] + + +def halls_active() -> list[dict]: + """Активные столы/зоны — {name, sub} в порядке сортировки.""" + with db() as conn: + rows = conn.execute( + "SELECT name, sub FROM halls_def WHERE deleted = 0 ORDER BY sort, name" + ).fetchall() + return [dict(r) for r in rows] + + +def hall_names() -> list[str]: + return [h["name"] for h in halls_active()] + + +def hall_create(name: str, sub: str | None) -> dict | None: + """Новый стол/зал. None — активный стол с таким именем уже есть; + удалённый с тем же именем восстанавливается с новой подписью. + Восстановленный стол освобождается (см. storage.hall_create).""" + now = _iso(storage.utcnow()) + with db() as conn: + row = conn.execute( + "SELECT name, deleted FROM halls_def WHERE name = ?", (name,) + ).fetchone() + if row is not None: + if row["deleted"]: + conn.execute( + "UPDATE halls_def SET deleted = 0, sub = ? WHERE name = ?", + (sub, name), + ) + else: + return None + else: + nxt = conn.execute("SELECT COALESCE(MAX(sort), 0) + 1 AS n FROM halls_def").fetchone() + conn.execute( + "INSERT INTO halls_def (name, sub, sort, created_at) VALUES (?, ?, ?, ?)", + (name, sub, nxt["n"], now), + ) + # сброс зависшей занятости (для нового стола — просто свободная строка) + conn.execute( + """ + INSERT INTO hall_status (name, busy, busy_by, busy_until, updated_at) + VALUES (?, 0, NULL, NULL, ?) + ON CONFLICT(name) DO UPDATE SET + busy = 0, busy_by = NULL, busy_until = NULL, updated_at = excluded.updated_at + """, + (name, now), + ) + out = conn.execute( + "SELECT name, sub, sort, deleted FROM halls_def WHERE name = ?", (name,) + ).fetchone() + d = dict(out) + d["deleted"] = bool(d["deleted"]) + return d + + +def hall_delete(name: str) -> bool: + """Мягкое удаление стола: исчезает из списков и валидации, + история занятости остаётся. Вернуть можно повторным созданием.""" + with db() as conn: + cur = conn.execute( + "UPDATE halls_def SET deleted = 1 WHERE name = ? AND deleted = 0", (name,) + ) + return (cur.rowcount or 0) > 0 + + +def _hall_row(r) -> dict: + d = dict(r) + d["busy"] = bool(d.get("busy")) + return d + + +def hall_status() -> list[dict]: + """Состояние всех столов и зон; таймеры с истёкшим сроком освобождаются сами.""" + now = storage.utcnow() + active = halls_active() + with db() as conn: + busy_rows = conn.execute( + "SELECT name, busy_until FROM hall_status WHERE busy = 1" + ).fetchall() + expired = [ + r["name"] for r in busy_rows + if r["busy_until"] and (_parse(r["busy_until"]) or now) <= now + ] + if expired: + marks = ",".join("?" * len(expired)) + conn.execute( + f"UPDATE hall_status SET busy = 0, busy_by = NULL, busy_until = NULL" + f" WHERE name IN ({marks})", + expired, + ) + rows = conn.execute( + "SELECT name, busy, busy_by, busy_until, updated_at FROM hall_status" + ).fetchall() + by_name = {} + for r in rows: + d = _hall_row(r) + by_name[d["name"]] = d + return [ + {"name": h["name"], "sub": h["sub"], **by_name.get(h["name"], { + "busy": False, "busy_by": None, "busy_until": None, "updated_at": None, + })} + for h in active + ] + + +def set_hall_busy(name: str, busy: bool, minutes: int | None, by: str) -> dict | None: + """Занять стол/зону (опционально на minutes минут) или освободить. + + Продление: если стол уже занят с активным таймером, минуты добавляются + к текущему дедлайну; занят без таймера — отсчёт идёт от сейчас. + """ + now = storage.utcnow() + with db() as conn: + if not busy: + conn.execute( + """ + INSERT INTO hall_status (name, busy, busy_by, busy_until, updated_at) + VALUES (?, 0, NULL, NULL, ?) + ON CONFLICT(name) DO UPDATE SET + busy = 0, busy_by = NULL, busy_until = NULL, updated_at = excluded.updated_at + """, + (name, _iso(now)), + ) + else: + until = None + if minutes is not None: + base = now + current = conn.execute( + "SELECT busy_until FROM hall_status WHERE name = ? AND busy = 1", (name,) + ).fetchone() + if current and current["busy_until"] and (_parse(current["busy_until"]) or now) > now: + base = _parse(current["busy_until"]) + until = _iso(base + timedelta(minutes=minutes)) + conn.execute( + """ + INSERT INTO hall_status (name, busy, busy_by, busy_until, updated_at) + VALUES (?, 1, ?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + busy = 1, busy_by = excluded.busy_by, + busy_until = excluded.busy_until, updated_at = excluded.updated_at + """, + (name, by, until, _iso(now)), + ) + row = conn.execute( + "SELECT name, busy, busy_by, busy_until, updated_at FROM hall_status WHERE name = ?", + (name,), + ).fetchone() + return _hall_row(row) + + +# ---------------------------------------------------------------- демо-заказы + +def _maybe_seed_demo_orders() -> None: + if os.environ.get("CRM_SEED_DEMO_ORDERS", "0") != "1": + return + with db() as conn: + if conn.execute("SELECT 1 FROM orders LIMIT 1").fetchone(): + return + for r in storage._demo_rows(): + conn.execute( + """ + INSERT INTO orders (number, week_start, table_name, waiter, status, price, items, + created_at, accepted_at, delivered_at, duration_seconds) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (r["number"], r["week"].isoformat(), r["table"], r["waiter"], r["status"], + r["price"], r["items"], _iso(r["created"]), _iso(r["accepted"]), + _iso(r["delivered"]), r["duration"]), + ) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9b26d2c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,93 @@ +name: neon-bar + +# Три контейнера: сайт (nginx + статика), CRM (Python API), PostgreSQL. +# Сеть внутренняя: наружу смотрит только сайт; CRM отдаёт ему /api/. + +services: + db: + # NF-20: дайджест-пин базового образа — пересборка в другой день даёт + # те же слои (обновление пина — осознанное действие: новый digest + тест) + image: postgres:17-alpine@sha256:b0f9560a2de083e2cc7382e75f808c7381a32852a7ec49117deedb300e552b24 + container_name: neon-db + restart: unless-stopped + stop_grace_period: 10s + mem_limit: 512m + logging: &logging + driver: json-file + options: { max-size: "10m", max-file: "3" } + environment: + # Пароли БЕЗ дефолтов (F-1/F-2): compose упадёт с понятной ошибкой, + # если .env не задан — дефолтные креды в контейнер не попадут никогда. + POSTGRES_DB: ${PG_DB:?Задайте PG_DB в .env} + POSTGRES_USER: ${PG_USER:?Задайте PG_USER в .env} + POSTGRES_PASSWORD: ${PG_PASSWORD:?Задайте PG_PASSWORD в .env} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${PG_USER} -d ${PG_DB}"] + interval: 5s + timeout: 3s + retries: 20 + + crm: + build: ./crm + container_name: neon-crm + restart: unless-stopped + stop_grace_period: 10s + mem_limit: 256m + logging: *logging + depends_on: + db: + condition: service_healthy + environment: + DATABASE_URL: postgresql://${PG_USER}:${PG_PASSWORD}@db:5432/${PG_DB} + CRM_PORT: "47613" + # расписание смен, границы суток в отчётах и «новинках» считаются + # по барному времени (фиксированный UTC+5) — TZ важен для SQL-окон + TZ: ${CRM_TZ:-Asia/Yekaterinburg} + CRM_OWNER_LOGIN: ${CRM_OWNER_LOGIN:-owner} + # Пароли только из .env (F-2): дефолтных neon2026 больше не существует. + # Первый старт создаёт учётки с этими паролями; смена .env на живой базе + # не действует — каждому сотруднику меняет пароль в панели (кнопка 🔑). + CRM_OWNER_PASSWORD: ${CRM_OWNER_PASSWORD:?Задайте CRM_OWNER_PASSWORD в .env} + CRM_OWNER_NAME: ${CRM_OWNER_NAME:-Нурлан} + CRM_ADMIN_LOGIN: ${CRM_ADMIN_LOGIN:-admin} + CRM_ADMIN_PASSWORD: ${CRM_ADMIN_PASSWORD:?Задайте CRM_ADMIN_PASSWORD в .env} + CRM_ADMIN_NAME: ${CRM_ADMIN_NAME:-Ирина} + CRM_WAITER_LOGIN: ${CRM_WAITER_LOGIN:-waiter.mark} + CRM_WAITER_PASSWORD: ${CRM_WAITER_PASSWORD:?Задайте CRM_WAITER_PASSWORD в .env} + CRM_WAITER_NAME: ${CRM_WAITER_NAME:-Марк} + CRM_SEED_DEMO_ORDERS: ${CRM_SEED_DEMO_ORDERS:-0} + # 1 — разрешить старт с дефолтными паролями (только dev-стенд!) + CRM_ALLOW_DEFAULT_PASSWORDS: ${CRM_ALLOW_DEFAULT_PASSWORDS:-0} + # Публикуем только на localhost: посетителям CRM не нужна, + # а админ может зайти с сервера через SSH-туннель при желании. + ports: + - "127.0.0.1:47613:47613" + volumes: + # фото позиций, загружаемые владельцем, переживают пересборку + - ./crm/media:/app/media + + site: + build: ./site + container_name: neon-site + restart: unless-stopped + stop_grace_period: 10s + mem_limit: 64m + logging: *logging + depends_on: + - crm + ports: + - "${SITE_BIND:-127.0.0.1}:${SITE_PORT:-8080}:80" + +volumes: + pgdata: + +# NF-10: фиксированная подсеть сети compose — site/nginx.conf доверяет +# X-Forwarded-For только ей и 127.0.0.1 (set_real_ip_from). Меняете здесь — +# меняйте и там. +networks: + default: + ipam: + config: + - subnet: 172.18.0.0/16 diff --git a/site/.dockerignore b/site/.dockerignore new file mode 100644 index 0000000..270c1b7 --- /dev/null +++ b/site/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.dockerignore +Dockerfile +.git +*.log diff --git a/site/AUDIT_REPORT.md b/site/AUDIT_REPORT.md new file mode 100644 index 0000000..2a31872 --- /dev/null +++ b/site/AUDIT_REPORT.md @@ -0,0 +1,65 @@ +# Аудит и изменения проекта НЕОН + +## Что проверено + +- Исходная структура проекта, `package.json`, TypeScript-конфигурация и Vite-конфигурация. +- Страница меню и все три набора данных: авторский чай, пивная карта, барное меню. +- Все 57 файлов в `public/menu` и `public/menu/manifest.json`. +- Поиск по проекту на предмет старого реестра изображений и неиспользуемых ссылок. +- TypeScript-проверка и самотест движка миксов. + +## Что изменено + +### Фотографии меню + +Создан единый `src/data/menuPhotos.ts` с привязкой по `id` позиции меню. + +- 51 из 51 текущих позиций меню имеют привязанные фотографии. +- Все 51 файла реально существуют в `public/menu`. +- Для авторского чая использованы соответствующие изображения из того же набора; названия файлов исторически отличаются от текущих названий позиций. +- 6 файлов из `public/menu` намеренно не привязаны: это изображения, которым в текущем каталоге нет соответствующей позиции (`photo`, два кальяна и три прочих позиции/сборы). + +Старый `src/data/menuImages.ts` удалён: он ссылался на несуществующие пути `images/tea` и `images/beer` и содержал неиспользуемые fallback-цепочки. + +### Карточки меню + +- Фото добавлены непосредственно в существующие карточки без смены базовой стилистики. +- Добавлена lazy-loading загрузка изображений. +- При ошибке загрузки карточка автоматически возвращается к исходной иконке. +- Кнопка `В корзину` интегрирована в существующую карточку. + +### Корзина и управление количеством + +`src/components/MenuCart.tsx` и `src/pages/MenuPage.tsx` получили косметический рефакторинг UX. + +- На карточке позиции вместо однонаправленной кнопки теперь используется `В корзину`, а после добавления — полноценный степпер `− / количество / +`. +- Уменьшение количества из меню сразу действует на выбранного гостя и удаляет позицию при снижении количества до нуля. +- Выбор активного гостя в меню переведён с выпадающего списка на компактные переключатели. +- В корзине количество гостей задаётся степпером 1–12, без громоздкого select. +- Активный гость в корзине выбирается отдельными кнопками; новые позиции из меню попадают выбранному гостю. +- Позиции по-прежнему группируются по гостям, но карточки стали компактнее и понятнее; перераспределение позиции между гостями сохранено. +- Итоговая сумма, копирование заказа, очистка, `Esc`, блокировка прокрутки и хранение в `localStorage` сохранены. +- Заказ никуда автоматически не отправляется — это рабочий список для оформления у официанта. + +### Очистка проекта + +- Из моделей `teaMenu`, `beerMenu`, `barMenu` убраны старые поля/импорты изображений, которые больше не нужны. +- Удалена дублирующаяся папка `public/images/menu`: приложение её не использовало, а сами актуальные фото находятся в `public/menu`. +- Контейнерный Caddy удалён: Caddy работает на уровне хоста. +- Docker-инфраструктура приложения возвращена отдельным `docker-compose.yml` + `Dockerfile`; HTTPS и доменная маршрутизация остаются на хостовом Caddy. +- В контейнере используется nginx только как HTTP-сервер для собранного Vite `dist`. +- `src/index.css`, `package.json` и `package-lock.json` не изменялись. + +## Проверка + +- Статически проверены изменённые файлы и связи между карточками меню, активным гостем и корзиной. +- `docker-compose.yml` и `Dockerfile` физически присутствуют в финальном архиве. +- Полный `vite build` в этой среде не удалось выполнить: пакетные зависимости из `package-lock.json` не удалось восстановить через npm из-за недоступного registry/cache-пакета, поэтому результат сборки здесь не выдаётся за проверенный. +- Финальный архив не содержит `node_modules` и `dist`; зависимости и production-сборка должны создаваться в целевой среде. + +## Docker runtime +- `docker-compose.yml` restored for the frontend container; Caddy is not containerized. +- `Dockerfile` restored as a multi-stage build: Node builds the Vite app, nginx serves the resulting static files. +- The app is published only on `127.0.0.1:8080`, so host-level Caddy can reverse-proxy to `http://127.0.0.1:8080` without exposing the app directly to the network. +- `nginx.conf` contains SPA fallback and a `/healthz` endpoint. +- `.dockerignore` excludes build/dependency artifacts from the Docker build context. diff --git a/site/Dockerfile b/site/Dockerfile new file mode 100644 index 0000000..d475e84 --- /dev/null +++ b/site/Dockerfile @@ -0,0 +1,23 @@ +# Build the Vite app in Node, then serve the static bundle with a minimal web server. +# NF-20: дайджест-пины базовых образов (воспроизводимая сборка). +FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS build + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +FROM nginx:1.29-alpine@sha256:5616878291a2eed594aee8db4dade5878cf7edcb475e59193904b198d9b830de AS runtime + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD wget --spider --quiet http://127.0.0.1/ || exit 1 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..597ca7b --- /dev/null +++ b/site/index.html @@ -0,0 +1,147 @@ + + + + + + + + НЕОН — кальянный лаунж · Атмосфера, которую видно в темноте + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+

Кальянный лаунж НЕОН в Нягани

+

+ Кальянный лаунж-бар НЕОН (Нягань, проспект Нефтяников, 15) — авторские + миксы и кальяны, PlayStation 5, настольные игры, авторский чай и кофе. + Бронь столов и аренда VIP-зала: +7 (912) 901-72-17. Часы работы: + вт–чт и вс 19:00–01:00, пт–сб 19:00–03:00, понедельник — выходной. +

+ +
+
+ + + + + + + + diff --git a/site/nginx.conf b/site/nginx.conf new file mode 100644 index 0000000..ab4b407 --- /dev/null +++ b/site/nginx.conf @@ -0,0 +1,140 @@ +# Брауз-тормоз на флуд заказами: с одного IP не чаще 10 заказов в минуту +# (burst 10 сглаживает «двойной клик»). Работает ДО запроса в CRM. +limit_req_zone $binary_remote_addr zone=orders_zone:1m rate=10r/m; + +# Общий тормоз на прочее /api/ (NF-04): флуд catalog/stoplist выедал пул БД +# CRM. Легитимный сайт опрашивает эти эндпоинты раз в минуту-десять, +# 120r/m с burst 60 — с большим запасом. Панель персонала ходит на CRM +# напрямую (127.0.0.1:47613) и этот лимит не замечает. +limit_req_zone $binary_remote_addr zone=api_zone:1m rate=120r/m; + +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Реальный IP гостя для лимитеров CRM. Доверие к X-Forwarded-For — + # только 127.0.0.1 и docker-подсеть compose (NF-10): раньше верили всем + # RFC1918-пирам, и LAN-клиент при экспоузе сайта мог вращать per-IP + # лимитеры подделкой заголовка. Подсеть зафиксирована в + # docker-compose.yml (networks.default.ipam) — при смене её там + # обновите и здесь. + set_real_ip_from 127.0.0.1; + set_real_ip_from 172.18.0.0/16; + real_ip_header X-Forwarded-For; + real_ip_recursive on; + + # single-file сборка (~750 KB html) без gzip = лишний трафик гостям + gzip on; + gzip_comp_level 5; + gzip_min_length 1024; + gzip_vary on; + gzip_types text/css application/javascript application/json image/svg+xml application/xml text/plain; + + server_tokens off; + + # Базовые заголовки безопасности. Внимание nginx: location со своим + # add_header (например /media/) НЕ наследует их — там заголовки повторены. + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options DENY always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + # CSP: бандл инлайнен в single-file index.html (поэтому 'unsafe-inline' у + # script-src), плюс Метрика/GA и Google Fonts. object/base/frame-ancestors + # наглухо закрыты — внедрённый в страницу тег не подгрузит и не отправит. + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://mc.yandex.ru https://yastatic.net https://www.googletagmanager.com https://www.google-analytics.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: https://mc.yandex.ru https://www.google-analytics.com https://www.googletagmanager.com; connect-src 'self' https://mc.yandex.ru https://www.google-analytics.com https://analytics.google.com; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always; + + # Заказы и стоп-лист живут в CRM-контейнере (см. docker-compose.yml). + # Точный location = /api/orders ниже переопределяет этот для POST заказов + # со своей зоной (приоритет nginx: точный бьёт префиксный). + location /api/ { + limit_req zone=api_zone burst=60 nodelay; + limit_req_status 429; + proxy_pass http://crm:47613; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_connect_timeout 5s; + proxy_read_timeout 15s; + } + + # Точка создания заказа — отдельный тормоз поверх лимитеров CRM + # (nginx отвечает 429 раньше, чем запрос дойдёт до Python). + location = /api/orders { + limit_req zone=orders_zone burst=10 nodelay; + limit_req_status 429; + proxy_pass http://crm:47613; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_connect_timeout 5s; + proxy_read_timeout 15s; + } + + # Входная точка SPA: ТОЛЬКО no-cache — в single-file сборке внутри + # index.html инлайнен весь бандл, закэшировать его на 7 дней = гости + # не увидят обновлений сайта до конца срока кэша. (Точное совпадение + # бьёт регэксп-локейшен расширений ниже; прочие .html честно 404.) + location = /index.html { + add_header Cache-Control "no-cache"; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options DENY always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://mc.yandex.ru https://yastatic.net https://www.googletagmanager.com https://www.google-analytics.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: https://mc.yandex.ru https://www.google-analytics.com https://www.googletagmanager.com; connect-src 'self' https://mc.yandex.ru https://www.google-analytics.com https://analytics.google.com; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always; + } + + # SEO-файлы: точный Content-Type и кэш не больше часа + # (types {} сбрасывает mime.types — иначе .xml ушёл бы как text/xml) + location = /robots.txt { + types { } + default_type text/plain; + expires 1h; + } + location = /sitemap.xml { + types { } + default_type application/xml; + expires 1h; + } + + # Загруженные владельцем фото позиций — из CRM (/media/.). + # ^~ обязателен: без него регэксп-локейшен расширений (*.jpg, *.avif…) + # перехватывал бы запросы, и все фото отдавали 404 вместо прокси в CRM. + location ^~ /media/ { + proxy_pass http://crm:47613; + proxy_http_version 1.1; + proxy_set_header Host $host; + expires 7d; + add_header Cache-Control "public, max-age=604800"; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options DENY always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'" always; + } + + # Vite SPA fallback: client-side routes should resolve to index.html. + location / { + try_files $uri $uri/ /index.html; + } + + location = /healthz { + access_log off; + add_header Content-Type text/plain; + return 200 'ok\n'; + } + + # Файлы с расширениями отдаются только с диска: несуществующие — честный + # 404, а не index.html (soft-404 вреден для поисковиков). SPA-fallback + # выше работает только для путей без расширения. + location ~* \.(?:css|js|mjs|cjs|json|map|webmanifest|txt|xml|svg|ico|png|jpg|jpeg|gif|webp|avif|jfif|woff2?|ttf|otf|eot|mp4|webm|mp3|wav|ogg|pdf|html)$ { + expires 7d; + add_header Cache-Control "public, max-age=604800"; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options DENY always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'" always; + try_files $uri =404; + } +} diff --git a/site/package-lock.json b/site/package-lock.json new file mode 100644 index 0000000..f3d7aea --- /dev/null +++ b/site/package-lock.json @@ -0,0 +1,2655 @@ +{ + "name": "react-vite-tailwind", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "react-vite-tailwind", + "version": "0.0.0", + "dependencies": { + "clsx": "2.1.1", + "embla-carousel-autoplay": "^8.6.0", + "embla-carousel-react": "^8.6.0", + "lucide-react": "^1.34.0", + "react": "19.2.6", + "react-dom": "19.2.6", + "tailwind-merge": "3.4.0" + }, + "devDependencies": { + "@tailwindcss/vite": "4.1.17", + "@types/node": "22.19.17", + "@types/react": "19.2.7", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "5.1.1", + "tailwindcss": "4.1.17", + "tsx": "^4.23.15", + "typescript": "5.9.3", + "vite": "7.3.7", + "vite-plugin-singlefile": "2.3.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.47", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", + "integrity": "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.0.tgz", + "integrity": "sha512-70TeIFezKKy65LgAVyQh+w94/gjWhvPWaLaGGeMEgVrPkQhuj/M5bAYYZzIFUj9Y69oHyTm5Um/R6gcLh4A8JA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.0.tgz", + "integrity": "sha512-YC86tYIHK6M1IV+wbzO+Bxk8RCBr6ZyWYgWxUCzaZD8mc8rrFoIJDNzDrkHBYRc/wKdrsIXmm6/F7NzrAO+OrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.0.tgz", + "integrity": "sha512-oI+ECtUcli0y0fi4xpW82GdPIXdTkI8G8DSjG2LRuw09fPAGykaWYH/hXxiKuTxiAjiPSTIIuYUqof5Z2hShWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.0.tgz", + "integrity": "sha512-NwV+1s7TiKrMe4owHyKB/dTLD7ZJD0YEBEhIz+hvav1Cu1GReJjF+rsdNwjzENQeIAbE/CoNiaAc5Vz2h5DPAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.0.tgz", + "integrity": "sha512-tWtHBTu5gOPK4u4Urtk4qAHW3zZ9rQAmbssO8gp7ELvGTGI3aCiq6NqyTQ0PCIg7KbHJF2UkGDDs77YZGxfjCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.0.tgz", + "integrity": "sha512-2qPoJiwTvtHQ27NnYvTnsgk8laXWYuVmNESG8WFZBcEPKLfZ3I27qBJarjVRQtwGeYyRfq5ZowHXih9lm2BItw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.0.tgz", + "integrity": "sha512-FQwsTRvLNuHoTdICABJQfbPUSEueISGmnpT06tXTMpfprf5NiKLSXKA0A+w45wJnCmZAnzgqBwbt6ARFuyOi5w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.0.tgz", + "integrity": "sha512-BBVTXziw8mY1a4ZbWME9tZyfzqXCDPqaC7Z3heQ29p5dkvXzwL0NwelO8zLa8c3RBKvl3YTuSnBgsBhYBtwjIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.0.tgz", + "integrity": "sha512-w2Iyy9+RqKwx3d9qWMKsJg0FfRBsY0/pXNv0mCQ3ueRvJI6+QAScfD4nrMlzFLs2HNVW6Ew+mtZfDl9b7Ew5/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.0.tgz", + "integrity": "sha512-YK++KtrFRHYE0P6/RtYEAy9t8F37znP+K03RrIuLPYOL6SVlObRumf/0OE4V/h63xL9DwkWbNssZfmA9hawuDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.0.tgz", + "integrity": "sha512-aBfOG6fP7YkkPmTqPwufRJeFyz7WPpECv9XNbnsk9+vg7rxdih0lbtEel7jcRng4LZrrmU3FfitCFyEj4BWDWg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.0.tgz", + "integrity": "sha512-LGaHEOeHNAag9VuS1Crs5DFg4RrU9MPi2nVnNJk9DTePx/B6RRYKVmrIXt2h7YOJlwjaFJ6lwtFDliZxScTLrQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.0.tgz", + "integrity": "sha512-jClvk+J0FC3b7Udvegiw5/4hErbHtmsNsQgENnKXDWtNCJXsJYZH5WURvu7imDOO38xYml24eeh5x3A04ppwCw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.0.tgz", + "integrity": "sha512-0OJlaGK+8+B777Ql5okIpD7ua5Ro9+VB9Ve0OKa28OQJZ1RbuUBVNHK/e3pr4BROqsyPl1JrPO1ZxJseCNffcA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.0.tgz", + "integrity": "sha512-Ygsx+HoNH7afwi1bTIXbnTvVnsO+zurPLSYxybV1hHFVU72OWOCl6v05ql/z0hkpAPx+DK7Kn9Bi7MayCcjLTA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.0.tgz", + "integrity": "sha512-pDQxtMGb+OvG3fLwR2OkZlSd47hW+kWg4BYMG/++sR6RqorQccwPTDsxda5hPwiIeIErAnCF9ma3SAU06bdQtQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.0.tgz", + "integrity": "sha512-0BnUG9mS8I4SSHr3XsxVhuCMEiu+rX61xxZF5vujso4LaiAGFZFxvDjg6Xn6tLPNTUAfuCvQYas4LMQMVsKRSQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.0.tgz", + "integrity": "sha512-Adu/VttB1dpPNW+FEacrZ+xVm9tFty84+RrFzsqlFaPxoJB+9XXyDGtp5dCOoBwGBIEVH0To7lExFXEx0BIF4A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.0.tgz", + "integrity": "sha512-NQ3bDvjUbFKmP23671xUlXtKmqVsUBd6M4PQCvbmNtOy06hnQIdKHy8oG/6S3R/S6He1JgPk6A5VT+prAJMYEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.0.tgz", + "integrity": "sha512-u2eDAl4+0aFvA13GxlGBtTI3SS3sdgwgtV0HyjZ0QaQVCgNE+jqNGey+GtxWiq+wxr/UycAx/OnfJzApCFamvA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.0.tgz", + "integrity": "sha512-XvRb5vfW3wAZQ+ZUG21AnHHDKtNcw99eigzEhjr//NZ3u7SoBaPP0seSc7FgP7p1epAEdAoZckMW9WY/+4w70w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.0.tgz", + "integrity": "sha512-iZPmniy4kNBf5yo2RezbkYNNK5HPbXE9+g+twnbqSng7dtLEJy1SKoxiE/ni4FDacjyuZpEeb9U054N4EoKHYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.0.tgz", + "integrity": "sha512-mFBBd+LF37fnE8JnYUOH+imj0aPFPK30vpar4ehJkgnLj9sZn8ZxiRENmLtgIwxK7TC8klF6N57fxdNBwQoqOA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.0.tgz", + "integrity": "sha512-ujeqEY3B+zbGn3Z4Q03cUBG/LGWnBJncVT36WER31LcOsQk9+1dmINKKtvmmfChUvRbK1G0R8OhMWFgHgaZtAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.0.tgz", + "integrity": "sha512-hncn90N4sOky0L2LKE5oESKLbxCPeVo4eLA2LSMoDzM+879ml4WSr+Rr4DWknNIVVvS1Hirkc9hx02W6YxS8rQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz", + "integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.17" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz", + "integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.17", + "@tailwindcss/oxide-darwin-arm64": "4.1.17", + "@tailwindcss/oxide-darwin-x64": "4.1.17", + "@tailwindcss/oxide-freebsd-x64": "4.1.17", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", + "@tailwindcss/oxide-linux-x64-musl": "4.1.17", + "@tailwindcss/oxide-wasm32-wasi": "4.1.17", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz", + "integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz", + "integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz", + "integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz", + "integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz", + "integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz", + "integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz", + "integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz", + "integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz", + "integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz", + "integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.6.0", + "@emnapi/runtime": "^1.6.0", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.0.7", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz", + "integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz", + "integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.17.tgz", + "integrity": "sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.17", + "@tailwindcss/oxide": "4.1.17", + "tailwindcss": "4.1.17" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", + "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.1.tgz", + "integrity": "sha512-WQfkSw0QbQ5aJ2CHYw23ZGkqnRwqKHD/KYsMeTkZzPT4Jcf0DcBxBtwMJxnu6E7oxw5+JC6ZAiePgh28uJ1HBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.47", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.19.tgz", + "integrity": "sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.415", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.415.tgz", + "integrity": "sha512-958V+Kbhtgz+SxXeEVKBjrlKRBIDAYvUJfwhjxMZ5S6ut9jAl7l9ZKBkBrvjyjZE36PabLUo2L8kEeV5O4vgJg==", + "dev": true, + "license": "ISC" + }, + "node_modules/embla-carousel": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", + "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", + "license": "MIT" + }, + "node_modules/embla-carousel-autoplay": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-autoplay/-/embla-carousel-autoplay-8.6.0.tgz", + "integrity": "sha512-OBu5G3nwaSXkZCo1A6LTaFMZ8EpkYbwIaH+bPqdBnDGQ2fh4+NbzjXjs2SktoPNKCtflfVMc75njaDHOYXcrsA==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, + "node_modules/embla-carousel-react": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz", + "integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==", + "license": "MIT", + "dependencies": { + "embla-carousel": "8.6.0", + "embla-carousel-reactive-utils": "8.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/embla-carousel-reactive-utils": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz", + "integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.34.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.34.0.tgz", + "integrity": "sha512-vnjGJNI7Htk5+oWW8gXGuaLgwgAb0T6/iZbBrp9JCfRFwdNWZ0YTm3eyxjOLgwN6r8iyAf3UA70zNmBRBNv7yg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.0.tgz", + "integrity": "sha512-T5vnZ2y4QqC3/4P+w2+JO+Q/OVdnPsv4XcSYJYMEn0R9/jjl5AgLwO9LAZMzP2lN71O6pypn91rB7lDstUkfrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.0", + "@rollup/rollup-android-arm64": "4.63.0", + "@rollup/rollup-darwin-arm64": "4.63.0", + "@rollup/rollup-darwin-x64": "4.63.0", + "@rollup/rollup-freebsd-arm64": "4.63.0", + "@rollup/rollup-freebsd-x64": "4.63.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.0", + "@rollup/rollup-linux-arm-musleabihf": "4.63.0", + "@rollup/rollup-linux-arm64-gnu": "4.63.0", + "@rollup/rollup-linux-arm64-musl": "4.63.0", + "@rollup/rollup-linux-loong64-gnu": "4.63.0", + "@rollup/rollup-linux-loong64-musl": "4.63.0", + "@rollup/rollup-linux-ppc64-gnu": "4.63.0", + "@rollup/rollup-linux-ppc64-musl": "4.63.0", + "@rollup/rollup-linux-riscv64-gnu": "4.63.0", + "@rollup/rollup-linux-riscv64-musl": "4.63.0", + "@rollup/rollup-linux-s390x-gnu": "4.63.0", + "@rollup/rollup-linux-x64-gnu": "4.63.0", + "@rollup/rollup-linux-x64-musl": "4.63.0", + "@rollup/rollup-openbsd-x64": "4.63.0", + "@rollup/rollup-openharmony-arm64": "4.63.0", + "@rollup/rollup-win32-arm64-msvc": "4.63.0", + "@rollup/rollup-win32-ia32-msvc": "4.63.0", + "@rollup/rollup-win32-x64-gnu": "4.63.0", + "@rollup/rollup-win32-x64-msvc": "4.63.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwind-merge": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", + "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", + "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", + "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-singlefile": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/vite-plugin-singlefile/-/vite-plugin-singlefile-2.3.3.tgz", + "integrity": "sha512-XVnGH0QzbOa8fxRSsHdCarVN1BSBXNi7uLMQYlrGRN5apdHkk62XQWRJhVever0lnfuyBkwn+kvVChdm/OoOUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">18.0.0" + }, + "peerDependencies": { + "rollup": "^4.59.0", + "vite": "^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/site/package.json b/site/package.json new file mode 100644 index 0000000..62648ee --- /dev/null +++ b/site/package.json @@ -0,0 +1,35 @@ +{ + "name": "react-vite-tailwind", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build && tsx scripts/prerender.tsx", + "prerender": "tsx scripts/prerender.tsx", + "preview": "vite preview", + "export:crm": "tsx scripts/export-crm-seed.ts", + "test:engine": "tsx scripts/engine-selftest.ts" + }, + "dependencies": { + "clsx": "2.1.1", + "embla-carousel-autoplay": "^8.6.0", + "embla-carousel-react": "^8.6.0", + "lucide-react": "^1.34.0", + "react": "19.2.6", + "react-dom": "19.2.6", + "tailwind-merge": "3.4.0" + }, + "devDependencies": { + "@tailwindcss/vite": "4.1.17", + "@types/node": "22.19.17", + "@types/react": "19.2.7", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "5.1.1", + "tailwindcss": "4.1.17", + "tsx": "^4.23.15", + "typescript": "5.9.3", + "vite": "7.3.7", + "vite-plugin-singlefile": "2.3.3" + } +} diff --git a/site/public/apple-touch-icon.png b/site/public/apple-touch-icon.png new file mode 100644 index 0000000..bf62377 Binary files /dev/null and b/site/public/apple-touch-icon.png differ diff --git a/site/public/favicon-16x16.png b/site/public/favicon-16x16.png new file mode 100644 index 0000000..fd13776 Binary files /dev/null and b/site/public/favicon-16x16.png differ diff --git a/site/public/favicon-32x32.png b/site/public/favicon-32x32.png new file mode 100644 index 0000000..aca641d Binary files /dev/null and b/site/public/favicon-32x32.png differ diff --git a/site/public/favicon-48x48.png b/site/public/favicon-48x48.png new file mode 100644 index 0000000..c324afa Binary files /dev/null and b/site/public/favicon-48x48.png differ diff --git a/site/public/favicon.ico b/site/public/favicon.ico new file mode 100644 index 0000000..4c00f35 Binary files /dev/null and b/site/public/favicon.ico differ diff --git a/site/public/favicon.svg b/site/public/favicon.svg new file mode 100644 index 0000000..b202ff7 --- /dev/null +++ b/site/public/favicon.svg @@ -0,0 +1 @@ +Н \ No newline at end of file diff --git a/site/public/images/games/fifa25.jfif b/site/public/images/games/fifa25.jfif new file mode 100644 index 0000000..f1984d4 Binary files /dev/null and b/site/public/images/games/fifa25.jfif differ diff --git a/site/public/images/games/gow.jpg b/site/public/images/games/gow.jpg new file mode 100644 index 0000000..a599df4 Binary files /dev/null and b/site/public/images/games/gow.jpg differ diff --git a/site/public/images/games/gowr.jpg b/site/public/images/games/gowr.jpg new file mode 100644 index 0000000..6a752d2 Binary files /dev/null and b/site/public/images/games/gowr.jpg differ diff --git a/site/public/images/games/mk1.jpg b/site/public/images/games/mk1.jpg new file mode 100644 index 0000000..1e8945f Binary files /dev/null and b/site/public/images/games/mk1.jpg differ diff --git a/site/public/images/games/mod.jpg b/site/public/images/games/mod.jpg new file mode 100644 index 0000000..9788407 Binary files /dev/null and b/site/public/images/games/mod.jpg differ diff --git a/site/public/images/games/over.jpg b/site/public/images/games/over.jpg new file mode 100644 index 0000000..25003fa Binary files /dev/null and b/site/public/images/games/over.jpg differ diff --git a/site/public/images/games/smr.jpg b/site/public/images/games/smr.jpg new file mode 100644 index 0000000..11d7471 Binary files /dev/null and b/site/public/images/games/smr.jpg differ diff --git a/site/public/images/games/ufc5.jfif b/site/public/images/games/ufc5.jfif new file mode 100644 index 0000000..fffe20b Binary files /dev/null and b/site/public/images/games/ufc5.jfif differ diff --git a/site/public/images/hero-bg.jpg b/site/public/images/hero-bg.jpg new file mode 100644 index 0000000..e2b16c1 Binary files /dev/null and b/site/public/images/hero-bg.jpg differ diff --git a/site/public/images/hookah-smoke.jpg b/site/public/images/hookah-smoke.jpg new file mode 100644 index 0000000..348b66e Binary files /dev/null and b/site/public/images/hookah-smoke.jpg differ diff --git a/site/public/images/interior-bar.jpg b/site/public/images/interior-bar.jpg new file mode 100644 index 0000000..d0fabbc Binary files /dev/null and b/site/public/images/interior-bar.jpg differ diff --git a/site/public/images/interior-sofas.jpg b/site/public/images/interior-sofas.jpg new file mode 100644 index 0000000..a1260c9 Binary files /dev/null and b/site/public/images/interior-sofas.jpg differ diff --git a/site/public/images/menu-bg.jpg b/site/public/images/menu-bg.jpg new file mode 100644 index 0000000..4b9ddae Binary files /dev/null and b/site/public/images/menu-bg.jpg differ diff --git a/site/public/images/og-cover.jpg b/site/public/images/og-cover.jpg new file mode 100644 index 0000000..ce73bb1 Binary files /dev/null and b/site/public/images/og-cover.jpg differ diff --git a/site/public/images/vip-room.jpg b/site/public/images/vip-room.jpg new file mode 100644 index 0000000..feffc13 Binary files /dev/null and b/site/public/images/vip-room.jpg differ diff --git a/site/public/menu/3-korochki.avif b/site/public/menu/3-korochki.avif new file mode 100644 index 0000000..33ca041 Binary files /dev/null and b/site/public/menu/3-korochki.avif differ diff --git a/site/public/menu/adrenaline-rush-absolyutnaya-energiya.avif b/site/public/menu/adrenaline-rush-absolyutnaya-energiya.avif new file mode 100644 index 0000000..8423304 Binary files /dev/null and b/site/public/menu/adrenaline-rush-absolyutnaya-energiya.avif differ diff --git a/site/public/menu/alpen-gold.avif b/site/public/menu/alpen-gold.avif new file mode 100644 index 0000000..71c3196 Binary files /dev/null and b/site/public/menu/alpen-gold.avif differ diff --git a/site/public/menu/amerikano.avif b/site/public/menu/amerikano.avif new file mode 100644 index 0000000..22c9aa6 Binary files /dev/null and b/site/public/menu/amerikano.avif differ diff --git a/site/public/menu/bud.avif b/site/public/menu/bud.avif new file mode 100644 index 0000000..04ed50f Binary files /dev/null and b/site/public/menu/bud.avif differ diff --git a/site/public/menu/burn-originalnyy.avif b/site/public/menu/burn-originalnyy.avif new file mode 100644 index 0000000..dbd3eb4 Binary files /dev/null and b/site/public/menu/burn-originalnyy.avif differ diff --git a/site/public/menu/burn-so-vkusom-mango.avif b/site/public/menu/burn-so-vkusom-mango.avif new file mode 100644 index 0000000..7e14e6c Binary files /dev/null and b/site/public/menu/burn-so-vkusom-mango.avif differ diff --git a/site/public/menu/burn-yabloko-kivi.avif b/site/public/menu/burn-yabloko-kivi.avif new file mode 100644 index 0000000..9a33c6a Binary files /dev/null and b/site/public/menu/burn-yabloko-kivi.avif differ diff --git a/site/public/menu/chernyy-chay-s-chabretsom.avif b/site/public/menu/chernyy-chay-s-chabretsom.avif new file mode 100644 index 0000000..1d28c43 Binary files /dev/null and b/site/public/menu/chernyy-chay-s-chabretsom.avif differ diff --git a/site/public/menu/chernyy-chay.avif b/site/public/menu/chernyy-chay.avif new file mode 100644 index 0000000..d46066c Binary files /dev/null and b/site/public/menu/chernyy-chay.avif differ diff --git a/site/public/menu/chipsy-leys.avif b/site/public/menu/chipsy-leys.avif new file mode 100644 index 0000000..fc30092 Binary files /dev/null and b/site/public/menu/chipsy-leys.avif differ diff --git a/site/public/menu/chizkeyk-nyu-york-klubnichnyy.avif b/site/public/menu/chizkeyk-nyu-york-klubnichnyy.avif new file mode 100644 index 0000000..49d87aa Binary files /dev/null and b/site/public/menu/chizkeyk-nyu-york-klubnichnyy.avif differ diff --git a/site/public/menu/chizkeyk-nyu-york-malina.avif b/site/public/menu/chizkeyk-nyu-york-malina.avif new file mode 100644 index 0000000..9a8cb22 Binary files /dev/null and b/site/public/menu/chizkeyk-nyu-york-malina.avif differ diff --git a/site/public/menu/chizkeyk-nyu-york.avif b/site/public/menu/chizkeyk-nyu-york.avif new file mode 100644 index 0000000..84c3e1e Binary files /dev/null and b/site/public/menu/chizkeyk-nyu-york.avif differ diff --git a/site/public/menu/corona-extra.avif b/site/public/menu/corona-extra.avif new file mode 100644 index 0000000..bd4d117 Binary files /dev/null and b/site/public/menu/corona-extra.avif differ diff --git a/site/public/menu/el-capulco.avif b/site/public/menu/el-capulco.avif new file mode 100644 index 0000000..0555298 Binary files /dev/null and b/site/public/menu/el-capulco.avif differ diff --git a/site/public/menu/erzmann.avif b/site/public/menu/erzmann.avif new file mode 100644 index 0000000..e61803e Binary files /dev/null and b/site/public/menu/erzmann.avif differ diff --git a/site/public/menu/feykhoa.avif b/site/public/menu/feykhoa.avif new file mode 100644 index 0000000..2d9017a Binary files /dev/null and b/site/public/menu/feykhoa.avif differ diff --git a/site/public/menu/hoegaarden.avif b/site/public/menu/hoegaarden.avif new file mode 100644 index 0000000..135140c Binary files /dev/null and b/site/public/menu/hoegaarden.avif differ diff --git a/site/public/menu/kalyan-lyuboy-kreposti.avif b/site/public/menu/kalyan-lyuboy-kreposti.avif new file mode 100644 index 0000000..027f7aa Binary files /dev/null and b/site/public/menu/kalyan-lyuboy-kreposti.avif differ diff --git a/site/public/menu/kapuchino.avif b/site/public/menu/kapuchino.avif new file mode 100644 index 0000000..222aac2 Binary files /dev/null and b/site/public/menu/kapuchino.avif differ diff --git a/site/public/menu/kartofel-fri.avif b/site/public/menu/kartofel-fri.avif new file mode 100644 index 0000000..3a669c3 Binary files /dev/null and b/site/public/menu/kartofel-fri.avif differ diff --git a/site/public/menu/khrustim-team.avif b/site/public/menu/khrustim-team.avif new file mode 100644 index 0000000..969a3ee Binary files /dev/null and b/site/public/menu/khrustim-team.avif differ diff --git a/site/public/menu/klubnika-kivi.avif b/site/public/menu/klubnika-kivi.avif new file mode 100644 index 0000000..2c8bdb4 Binary files /dev/null and b/site/public/menu/klubnika-kivi.avif differ diff --git a/site/public/menu/kozel-svetlyy.avif b/site/public/menu/kozel-svetlyy.avif new file mode 100644 index 0000000..efda2fa Binary files /dev/null and b/site/public/menu/kozel-svetlyy.avif differ diff --git a/site/public/menu/kozel-temnyy.avif b/site/public/menu/kozel-temnyy.avif new file mode 100644 index 0000000..9e91b24 Binary files /dev/null and b/site/public/menu/kozel-temnyy.avif differ diff --git a/site/public/menu/krone-blanche.avif b/site/public/menu/krone-blanche.avif new file mode 100644 index 0000000..93a5fd1 Binary files /dev/null and b/site/public/menu/krone-blanche.avif differ diff --git a/site/public/menu/krylyshki-baffalo.avif b/site/public/menu/krylyshki-baffalo.avif new file mode 100644 index 0000000..52c12ee Binary files /dev/null and b/site/public/menu/krylyshki-baffalo.avif differ diff --git a/site/public/menu/lukovye-koltsa.avif b/site/public/menu/lukovye-koltsa.avif new file mode 100644 index 0000000..d00af88 Binary files /dev/null and b/site/public/menu/lukovye-koltsa.avif differ diff --git a/site/public/menu/malinovyy-s-imbirem.avif b/site/public/menu/malinovyy-s-imbirem.avif new file mode 100644 index 0000000..f820f73 Binary files /dev/null and b/site/public/menu/malinovyy-s-imbirem.avif differ diff --git a/site/public/menu/mangovyy-ray.avif b/site/public/menu/mangovyy-ray.avif new file mode 100644 index 0000000..cc8c563 Binary files /dev/null and b/site/public/menu/mangovyy-ray.avif differ diff --git a/site/public/menu/manifest.json b/site/public/menu/manifest.json new file mode 100644 index 0000000..10542be --- /dev/null +++ b/site/public/menu/manifest.json @@ -0,0 +1,287 @@ +[ + { + "file": "menu/photo.avif", + "title": "photo", + "source": "https://media.dodostatic.com/image/11eeb86b25350d0eadad0aec31155793.avif" + }, + { + "file": "menu/kalyan-lyuboy-kreposti.avif", + "title": "Кальян любой крепости", + "source": "https://media.dodostatic.com/image/019c413a46397891b34db8e49083915b.avif" + }, + { + "file": "menu/retro-kalyan-khalil-mamoon.avif", + "title": "Ретро кальян: khalil mamoon", + "source": "https://media.dodostatic.com/image/019c4813920f7666900e24d50a0a9ea2.avif" + }, + { + "file": "menu/pryanyy-apelsin.avif", + "title": "Пряный апельсин", + "source": "https://media.dodostatic.com/image/019c413cfc6071fea8a2c4ec8b641fb9.avif" + }, + { + "file": "menu/myatnaya-ezhevika.avif", + "title": "Мятная ежевика", + "source": "https://media.dodostatic.com/image/019c2b9f74757209a2aa263b65df27e5.avif" + }, + { + "file": "menu/malinovyy-s-imbirem.avif", + "title": "Малиновый с имбирем", + "source": "https://media.dodostatic.com/image/019c2b9fdc00768ba05ff316f8d622cd.avif" + }, + { + "file": "menu/myatnaya-malina.avif", + "title": "Мятная малина", + "source": "https://media.dodostatic.com/image/019c2f32f55071f0ba37559a8e3b0206.avif" + }, + { + "file": "menu/osennyaya-oblepikha.avif", + "title": "Осенняя облепиха", + "source": "https://media.dodostatic.com/image/019c281096fe7710809269443a7225fd.avif" + }, + { + "file": "menu/yagodnyy-vzryv.avif", + "title": "Ягодный взрыв", + "source": "https://media.dodostatic.com/image/019c20ad560979c1b2074a1e1acf60a3.avif" + }, + { + "file": "menu/vishnevyy-sad.avif", + "title": "Вишневый сад", + "source": "https://media.dodostatic.com/image/019c2f31bcf875acbceb7a6f61ae0194.avif" + }, + { + "file": "menu/mangovyy-ray.avif", + "title": "Манговый рай", + "source": "https://media.dodostatic.com/image/019c41528cfc78dd81f72f40ce843aae.avif" + }, + { + "file": "menu/staryy-melnik-svetloe.avif", + "title": "Старый мельник светлое", + "source": "https://media.dodostatic.com/image/019c20aedd9d7883abc396bff921d760.avif" + }, + { + "file": "menu/staryy-melnik-temnoe.avif", + "title": "Старый мельник темное", + "source": "https://media.dodostatic.com/image/019c20af48177999b63855e09d42230e.avif" + }, + { + "file": "menu/kozel-svetlyy.avif", + "title": "Kozel светлый", + "source": "https://media.dodostatic.com/image/019c20afbec970edb4dab7fcbe931230.avif" + }, + { + "file": "menu/kozel-temnyy.avif", + "title": "Kozel темный", + "source": "https://media.dodostatic.com/image/019c20b0aed478129986b27612ce598b.avif" + }, + { + "file": "menu/bud.avif", + "title": "Bud", + "source": "https://media.dodostatic.com/image/019c20b1083d72cc939ad919afe83989.avif" + }, + { + "file": "menu/stella.avif", + "title": "Stella", + "source": "https://media.dodostatic.com/image/019c20ae14e3769b9cef85f063c0ea80.avif" + }, + { + "file": "menu/krone-blanche.avif", + "title": "Krone Blanche", + "source": "https://media.dodostatic.com/image/019c20b1705470b699f9622d41cbbe21.avif" + }, + { + "file": "menu/erzmann.avif", + "title": "Erzmann", + "source": "https://media.dodostatic.com/image/019c5205b1fb7623b40922be846d1be5.avif" + }, + { + "file": "menu/el-capulco.avif", + "title": "El Capulco", + "source": "https://media.dodostatic.com/image/019c21d0b739773e812f5335fd98e5c5.avif" + }, + { + "file": "menu/spatten.avif", + "title": "Spatten", + "source": "https://media.dodostatic.com/image/019c20b2fa6a72fca73c653d52e9d1c6.avif" + }, + { + "file": "menu/hoegaarden.avif", + "title": "Hoegaarden", + "source": "https://media.dodostatic.com/image/019c20b37d0278ef88419f1879912261.avif" + }, + { + "file": "menu/corona-extra.avif", + "title": "Corona Extra", + "source": "https://media.dodostatic.com/image/019c20b3d8677935bd60a789260a7144.avif" + }, + { + "file": "menu/redd-s.avif", + "title": "Redd's", + "source": "https://media.dodostatic.com/image/019c20b66c7a7948a337569742166027.avif" + }, + { + "file": "menu/pablo-s-s-sokom-layma.avif", + "title": "Pablo's с соком лайма", + "source": "https://media.dodostatic.com/image/019c388bd52575f5841f61a9d7b324a7.avif" + }, + { + "file": "menu/lukovye-koltsa.avif", + "title": "Луковые кольца", + "source": "https://media.dodostatic.com/image/019c5cf1ac96718980e6e71591fc11d7.avif" + }, + { + "file": "menu/kartofel-fri.avif", + "title": "Картофель Фри", + "source": "https://media.dodostatic.com/image/019c41687d447256a1d04a957f905d4d.avif" + }, + { + "file": "menu/krylyshki-baffalo.avif", + "title": "Крылышки Баффало", + "source": "https://media.dodostatic.com/image/019c48143b66725cb9a3680801523673.avif" + }, + { + "file": "menu/syrnye-palochki.avif", + "title": "Сырные палочки", + "source": "https://media.dodostatic.com/image/019c48148e8073bab97dc8caa6100c9b.avif" + }, + { + "file": "menu/stripsy.avif", + "title": "Стрипсы", + "source": "https://media.dodostatic.com/image/019c4814f0ed7520802b4f729d2236ee.avif" + }, + { + "file": "menu/nagetsy.avif", + "title": "Нагетсы", + "source": "https://media.dodostatic.com/image/019c4815a75175099263d2b610c0ba71.avif" + }, + { + "file": "menu/chernyy-chay.avif", + "title": "Черный чай", + "source": "https://media.dodostatic.com/image/019c20b4459874dab1f68c8e2dab7bab.avif" + }, + { + "file": "menu/chernyy-chay-s-chabretsom.avif", + "title": "Черный чай с чабрецом", + "source": "https://media.dodostatic.com/image/019c4816ce22743ea114cce7cba1c18d.avif" + }, + { + "file": "menu/molochnyy-ulun.avif", + "title": "Молочный Улун", + "source": "https://media.dodostatic.com/image/019c20b4bcbb729d865667412da59cea.avif" + }, + { + "file": "menu/amerikano.avif", + "title": "Американо", + "source": "https://media.dodostatic.com/image/019c9f5ef3d2785a9728fdf2e30663a3.avif" + }, + { + "file": "menu/kapuchino.avif", + "title": "Капучино", + "source": "https://media.dodostatic.com/image/019c9f60418470678bccabd479e205fd.avif" + }, + { + "file": "menu/chizkeyk-nyu-york.avif", + "title": "Чизкейк Нью-Йорк", + "source": "https://media.dodostatic.com/image/019c28113de37748aaf16491fc5cb061.avif" + }, + { + "file": "menu/chizkeyk-nyu-york-klubnichnyy.avif", + "title": "Чизкейк Нью-Йорк клубничный", + "source": "https://media.dodostatic.com/image/019c335d507571838b4855a5f5e6c79a.avif" + }, + { + "file": "menu/chizkeyk-nyu-york-malina.avif", + "title": "Чизкейк Нью-Йорк Малина", + "source": "https://media.dodostatic.com/image/019c2811f09e70d6b6bff47e85e69808.avif" + }, + { + "file": "menu/tort-krasnyy-barkhat.avif", + "title": "Торт Красный бархат", + "source": "https://media.dodostatic.com/image/019c20b57c56743896b879e290de3a9a.avif" + }, + { + "file": "menu/tort-medovik.avif", + "title": "Торт Медовик", + "source": "https://media.dodostatic.com/image/019c20b5ef56790ebeb1f747e79d02f5.avif" + }, + { + "file": "menu/alpen-gold.avif", + "title": "Alpen gold", + "source": "https://media.dodostatic.com/image/019c5cf23ec977548b241b9ac6c14df5.avif" + }, + { + "file": "menu/milka.avif", + "title": "Milka", + "source": "https://media.dodostatic.com/image/019c5cf2aa3f71eeb997c74b4cea9f9d.avif" + }, + { + "file": "menu/chipsy-leys.avif", + "title": "Чипсы Leys", + "source": "https://media.dodostatic.com/image/019d61b7f4247566a6e64be7736f3389.avif" + }, + { + "file": "menu/3-korochki.avif", + "title": "3 Корочки", + "source": "https://media.dodostatic.com/image/019c52063b3177debd91e3afab378ba5.avif" + }, + { + "file": "menu/khrustim-team.avif", + "title": "Хрустим team", + "source": "https://media.dodostatic.com/image/019c9f943dbf72be84d4e741078dd41c.avif" + }, + { + "file": "menu/natakhtari-grusha.avif", + "title": "НАТАХТАРИ ГРУША", + "source": "https://media.dodostatic.com/image/019c38923a277682909a25bf8749f841.avif" + }, + { + "file": "menu/natakhtari-feykhoa.avif", + "title": "НАТАХТАРИ ФЕЙХОА", + "source": "https://media.dodostatic.com/image/019c41407e7b7804bff17d671c715123.avif" + }, + { + "file": "menu/natakhtari-saperavi.avif", + "title": "НАТАХТАРИ САПЕРАВИ", + "source": "https://media.dodostatic.com/image/019c414a38e07168bd4b646b9fa2ed4e.avif" + }, + { + "file": "menu/klubnika-kivi.avif", + "title": "Клубника Киви", + "source": "https://media.dodostatic.com/image/019c4817cf2e73f2bfacb43c0706eabf.avif" + }, + { + "file": "menu/feykhoa.avif", + "title": "Фейхоа", + "source": "https://media.dodostatic.com/image/019c4818bd6e76779f4a8df44cb1d8b2.avif" + }, + { + "file": "menu/burn-originalnyy.avif", + "title": "BURN Оригинальный", + "source": "https://media.dodostatic.com/image/019c481b4eb27671ac6f73da19a0c793.avif" + }, + { + "file": "menu/burn-yabloko-kivi.avif", + "title": "BURN Яблоко Киви", + "source": "https://media.dodostatic.com/image/019c520715f07505919d92eab9d0c67d.avif" + }, + { + "file": "menu/burn-so-vkusom-mango.avif", + "title": "BURN Со вкусом МАНГО", + "source": "https://media.dodostatic.com/image/019c5208afea73c3ada99f755b97c9b2.avif" + }, + { + "file": "menu/adrenaline-rush-absolyutnaya-energiya.avif", + "title": "Adrenaline RUSH Абсолютная ЭНЕРГИЯ", + "source": "https://media.dodostatic.com/image/019c520a306d769e9e0592c5c087567f.avif" + }, + { + "file": "menu/probkovyy-sbor.avif", + "title": "Пробковый сбор", + "source": "https://media.dodostatic.com/image/019c9f56a5df7489ac257e70bd892a4b.avif" + }, + { + "file": "menu/porcha-imuschestva.avif", + "title": "Порча имущества", + "source": "https://media.dodostatic.com/image/019c9f570847798ebf8992ddd71b59c7.avif" + } +] \ No newline at end of file diff --git a/site/public/menu/milka.avif b/site/public/menu/milka.avif new file mode 100644 index 0000000..1a517ee Binary files /dev/null and b/site/public/menu/milka.avif differ diff --git a/site/public/menu/molochnyy-ulun.avif b/site/public/menu/molochnyy-ulun.avif new file mode 100644 index 0000000..7e5c1fc Binary files /dev/null and b/site/public/menu/molochnyy-ulun.avif differ diff --git a/site/public/menu/myatnaya-ezhevika.avif b/site/public/menu/myatnaya-ezhevika.avif new file mode 100644 index 0000000..6d0410c Binary files /dev/null and b/site/public/menu/myatnaya-ezhevika.avif differ diff --git a/site/public/menu/myatnaya-malina.avif b/site/public/menu/myatnaya-malina.avif new file mode 100644 index 0000000..6e6f4db Binary files /dev/null and b/site/public/menu/myatnaya-malina.avif differ diff --git a/site/public/menu/nagetsy.avif b/site/public/menu/nagetsy.avif new file mode 100644 index 0000000..e8e63a0 Binary files /dev/null and b/site/public/menu/nagetsy.avif differ diff --git a/site/public/menu/natakhtari-feykhoa.avif b/site/public/menu/natakhtari-feykhoa.avif new file mode 100644 index 0000000..c1ef3b5 Binary files /dev/null and b/site/public/menu/natakhtari-feykhoa.avif differ diff --git a/site/public/menu/natakhtari-grusha.avif b/site/public/menu/natakhtari-grusha.avif new file mode 100644 index 0000000..3680485 Binary files /dev/null and b/site/public/menu/natakhtari-grusha.avif differ diff --git a/site/public/menu/natakhtari-saperavi.avif b/site/public/menu/natakhtari-saperavi.avif new file mode 100644 index 0000000..ae4cf46 Binary files /dev/null and b/site/public/menu/natakhtari-saperavi.avif differ diff --git a/site/public/menu/osennyaya-oblepikha.avif b/site/public/menu/osennyaya-oblepikha.avif new file mode 100644 index 0000000..8758af5 Binary files /dev/null and b/site/public/menu/osennyaya-oblepikha.avif differ diff --git a/site/public/menu/pablo-s-s-sokom-layma.avif b/site/public/menu/pablo-s-s-sokom-layma.avif new file mode 100644 index 0000000..d58f6f7 Binary files /dev/null and b/site/public/menu/pablo-s-s-sokom-layma.avif differ diff --git a/site/public/menu/photo.avif b/site/public/menu/photo.avif new file mode 100644 index 0000000..bdbe8d2 Binary files /dev/null and b/site/public/menu/photo.avif differ diff --git a/site/public/menu/porcha-imuschestva.avif b/site/public/menu/porcha-imuschestva.avif new file mode 100644 index 0000000..4c9e3e9 Binary files /dev/null and b/site/public/menu/porcha-imuschestva.avif differ diff --git a/site/public/menu/probkovyy-sbor.avif b/site/public/menu/probkovyy-sbor.avif new file mode 100644 index 0000000..3539da1 Binary files /dev/null and b/site/public/menu/probkovyy-sbor.avif differ diff --git a/site/public/menu/pryanyy-apelsin.avif b/site/public/menu/pryanyy-apelsin.avif new file mode 100644 index 0000000..ba125be Binary files /dev/null and b/site/public/menu/pryanyy-apelsin.avif differ diff --git a/site/public/menu/redd-s.avif b/site/public/menu/redd-s.avif new file mode 100644 index 0000000..8571280 Binary files /dev/null and b/site/public/menu/redd-s.avif differ diff --git a/site/public/menu/retro-kalyan-khalil-mamoon.avif b/site/public/menu/retro-kalyan-khalil-mamoon.avif new file mode 100644 index 0000000..f1dfa88 Binary files /dev/null and b/site/public/menu/retro-kalyan-khalil-mamoon.avif differ diff --git a/site/public/menu/spatten.avif b/site/public/menu/spatten.avif new file mode 100644 index 0000000..28a96c4 Binary files /dev/null and b/site/public/menu/spatten.avif differ diff --git a/site/public/menu/staryy-melnik-svetloe.avif b/site/public/menu/staryy-melnik-svetloe.avif new file mode 100644 index 0000000..b84d76d Binary files /dev/null and b/site/public/menu/staryy-melnik-svetloe.avif differ diff --git a/site/public/menu/staryy-melnik-temnoe.avif b/site/public/menu/staryy-melnik-temnoe.avif new file mode 100644 index 0000000..8164db5 Binary files /dev/null and b/site/public/menu/staryy-melnik-temnoe.avif differ diff --git a/site/public/menu/stella.avif b/site/public/menu/stella.avif new file mode 100644 index 0000000..52502ef Binary files /dev/null and b/site/public/menu/stella.avif differ diff --git a/site/public/menu/stripsy.avif b/site/public/menu/stripsy.avif new file mode 100644 index 0000000..f0ac59b Binary files /dev/null and b/site/public/menu/stripsy.avif differ diff --git a/site/public/menu/syrnye-palochki.avif b/site/public/menu/syrnye-palochki.avif new file mode 100644 index 0000000..86b47d8 Binary files /dev/null and b/site/public/menu/syrnye-palochki.avif differ diff --git a/site/public/menu/tort-krasnyy-barkhat.avif b/site/public/menu/tort-krasnyy-barkhat.avif new file mode 100644 index 0000000..b82f3e3 Binary files /dev/null and b/site/public/menu/tort-krasnyy-barkhat.avif differ diff --git a/site/public/menu/tort-medovik.avif b/site/public/menu/tort-medovik.avif new file mode 100644 index 0000000..798090b Binary files /dev/null and b/site/public/menu/tort-medovik.avif differ diff --git a/site/public/menu/vishnevyy-sad.avif b/site/public/menu/vishnevyy-sad.avif new file mode 100644 index 0000000..7a3fdcd Binary files /dev/null and b/site/public/menu/vishnevyy-sad.avif differ diff --git a/site/public/menu/yagodnyy-vzryv.avif b/site/public/menu/yagodnyy-vzryv.avif new file mode 100644 index 0000000..8607952 Binary files /dev/null and b/site/public/menu/yagodnyy-vzryv.avif differ diff --git a/site/public/robots.txt b/site/public/robots.txt new file mode 100644 index 0000000..bee2ce2 --- /dev/null +++ b/site/public/robots.txt @@ -0,0 +1,5 @@ +User-agent: * +Disallow: /api/ +Allow: / + +Sitemap: https://neon-bar86.ru/sitemap.xml diff --git a/site/public/sitemap.xml b/site/public/sitemap.xml new file mode 100644 index 0000000..9e981d2 --- /dev/null +++ b/site/public/sitemap.xml @@ -0,0 +1,8 @@ + + + + https://neon-bar86.ru/ + 2026-09-29 + 1.0 + + diff --git a/site/scripts/add-former-name.mjs b/site/scripts/add-former-name.mjs new file mode 100644 index 0000000..bca3a82 --- /dev/null +++ b/site/scripts/add-former-name.mjs @@ -0,0 +1,143 @@ +// Патч: под словесным знаком «НЕОН» появляется подпись «(ex Shelby)» — +// прежнее название бара. Подпись живёт до конца 2026 года: с 1 января 2027 +// клиент прячет её сам (useEffect), пересборка не требуется. +// Скрипт не идемпотентен: повторный запуск откажется работать. +import { readFileSync, writeFileSync, copyFileSync } from "node:fs"; + +const HEADER = "src/components/Header.tsx"; +const FOOTER = "src/components/Footer.tsx"; + +const die = (msg) => { + console.error("PATCH_FAIL: " + msg); + process.exit(1); +}; + +// ---------- Header.tsx ---------- +let src = readFileSync(HEADER, "utf8"); +copyFileSync(HEADER, HEADER + ".bak"); + +const MARK = "export function Logo("; +const first = src.indexOf(MARK); +if (first === -1) die("Logo declaration not found in Header.tsx"); +if (src.indexOf(MARK, first + 1) !== -1) die("Logo declared more than once"); + +// конец списка параметров: скобочный баланс от первой ( после MARK +let i = src.indexOf("(", first); +let parenDepth = 0; +for (; i < src.length; i++) { + if (src[i] === "(") parenDepth++; + else if (src[i] === ")") { + parenDepth--; + if (parenDepth === 0) { + i++; + break; + } + } +} +// пропускаем пробелы до тела функции +while (i < src.length && /\s/.test(src[i])) i++; +if (src[i] !== "{") die("Logo body brace not found"); + +// тело функции: баланс фигурных скобок +let depth = 0; +let end = -1; +for (; i < src.length; i++) { + if (src[i] === "{") depth++; + else if (src[i] === "}") { + depth--; + if (depth === 0) { + end = i; + break; + } + } +} +if (end === -1) die("Logo body end not found"); +const original = src.slice(first, end + 1); +if (!original.includes("") || !original.includes("ОН")) { + die("Logo body does not look like the wordmark: " + JSON.stringify(original.slice(0, 240))); +} +console.log("LOGO_FOUND: " + original.length + " chars, href=" + (original.includes('#home') ? "#home" : "?")); + +const replacement = `const FORMER_NAME_DEADLINE = new Date("2027-01-01T00:00:00").getTime(); + +export function Logo({ className }: { className?: string }) { + // «(ex Shelby)» — прежнее название бара; подпись видна до конца 2026 года, + // после новогодней ночи клиент прячет её сам, пересборка не требуется. + // Изначально true, чтобы пререндеренный HTML и первая клиентская отрисовка + // совпадали (без hydration-мисматча); эффект убирает надпись после дедлайна. + const [showFormerName, setShowFormerName] = useState(true); + + useEffect(() => { + if (Date.now() >= FORMER_NAME_DEADLINE) setShowFormerName(false); + }, []); + + return ( + + + + НЕ + + + ОН + + + {showFormerName && ( + + (ex Shelby) + + )} + + ); +}`; + +src = src.slice(0, first) + replacement + src.slice(end + 1); + +// импорты useState/useEffect, если их ещё нет до объявления Logo +const beforeLogo = src.slice(0, src.indexOf("export function Logo")); +if (!/\buseEffect\b/.test(beforeLogo)) { + if (/import \{[^}]*\} from "react";/.test(src)) { + src = src.replace(/import \{([^}]*)\} from "react";/, (m, names) => { + const names2 = names + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + for (const n of ["useState", "useEffect"]) { + if (!names2.includes(n)) names2.push(n); + } + return `import { ${names2.join(", ")} } from "react";`; + }); + } else { + src = `import { useEffect, useState } from "react";\n` + src; + } + console.log("REACT_IMPORT_PATCHED"); +} else { + console.log("REACT_IMPORT_ALREADY_THERE"); +} + +writeFileSync(HEADER, src); +console.log("HEADER_PATCHED"); + +// ---------- Footer.tsx ---------- +const footer = readFileSync(FOOTER, "utf8"); +if (/ { + const r = await fetch(BASE + path, { + ...opts, + headers: { "Content-Type": "application/json", ...(opts.headers || {}) }, + signal: AbortSignal.timeout(8000), + }); + const text = await r.text(); + let data = null; + try { + data = JSON.parse(text); + } catch { + data = null; + } + return { status: r.status, data }; +}; + +const login = await j("/api/login", { + method: "POST", + body: JSON.stringify({ login: CRM_LOGIN, password: CRM_PASSWORD }), +}); +if (login.status !== 200 || !login.data?.token) { + console.log("E2E_LOGIN_FAIL status=" + login.status); + process.exit(1); +} +const token = login.data.token; + +const before = await j("/api/stoplist"); +const beforeIds = before.data?.stopped_tobacco ?? []; + +const patch = await j(`/api/tobacco/${ID}`, { + method: "PATCH", + headers: { "X-Auth-Token": token }, + body: JSON.stringify({ stopped: true }), +}); + +const after = await j("/api/stoplist"); +const afterIds = after.data?.stopped_tobacco ?? []; + +console.log( + `E2E login=${login.status} role=${login.data?.role ?? login.data?.user?.role ?? "?"} ` + + `before=${beforeIds.length} patch=${patch.status} after=${afterIds.length} ` + + `contains=${afterIds.includes(ID) ? 1 : 0}`, +); diff --git a/site/scripts/engine-selftest.ts b/site/scripts/engine-selftest.ts new file mode 100644 index 0000000..6bc4510 --- /dev/null +++ b/site/scripts/engine-selftest.ts @@ -0,0 +1,293 @@ +/* Мини-тесты движка миксов. Запуск: npm run test:engine (tsx) */ + +import { + CATEGORY_LABELS, + FLAVORS, + PROFILES, + type FlavorCategory, + type FlavorStrength, + type ProfileId, +} from "../src/data/flavors"; +import { + buildFlavorVariations, + buildFocusList, + encodeShareLink, + generateFreshMixSet, + generateMixSet, + getFlavor, + isColdFlavor, + mulberry32, + parseShareLink, + randomSeed, + validateMix, + type GeneratedMix, + type GenerateOptions, + type PriorityFocus, + type SharePayload, +} from "../src/lib/mixEngine"; + +const profiles = Object.keys(PROFILES) as ProfileId[]; +const strengths: FlavorStrength[] = ["light", "medium", "strong"]; +const categories = Object.keys(CATEGORY_LABELS) as FlavorCategory[]; +const warmFlavors = FLAVORS.filter((f) => !isColdFlavor(f)); +const warmCategories = categories.filter((c) => c !== "cooling"); + +const failures: string[] = []; + +const stats = { + total: 0, + fallback: 0, + unverified: 0, + pairRepeats: 0, + shortSets: 0, + twoPart: 0, + threePart: 0, + withFrost: 0, + byProfile: {} as Record, + byStrength: {} as Record, +}; + +function covers(mix: GeneratedMix, focus: PriorityFocus): boolean { + return mix.items.some((i) => { + const f = getFlavor(i.flavorId)!; + return focus.kind === "flavor" ? f.id === focus.id : f.category === focus.category; + }); +} + +function structuralChecks(mix: GeneratedMix, seed: number) { + if (mix.items.length < 2 || mix.items.length > 3) + failures.push(`seed=${seed}: длина ${mix.items.length}`); + const sum = mix.items.reduce((s, i) => s + i.percent, 0); + if (sum !== 100) failures.push(`seed=${seed}: сумма ${sum}`); + + const cold = mix.items.filter((i) => isColdFlavor(getFlavor(i.flavorId)!)); + if (cold.length > 1) failures.push(`seed=${seed}: несколько холодков`); + cold.forEach((c) => { + if (c.percent > 15) failures.push(`seed=${seed}: холодок ${c.percent}%`); + }); + + // «Гипотеза для кальянщика» может нарушать правила качества — она помечена + if (mix.unverified) return; + const [base, add] = mix.items.map((i) => getFlavor(i.flavorId)!); + if (base.intensity >= 4 && add.intensity >= 4) + failures.push(`seed=${seed}: основа и добавка оба ≥4`); +} + +function runSet(profile: ProfileId, opts: GenerateOptions, seed: number) { + const { mixes } = generateMixSet(profile, opts, seed); + const label = `seed=${seed} ${profile}/${opts.strength}/cold=${opts.cooling}`; + if (mixes.length < 3) stats.shortSets += 1; + + // В одной выдаче составы уникальны (жёстко), пары — по возможности + const signatures = new Set(); + const pairs = new Set(); + for (const mix of mixes) { + if (signatures.has(mix.signature)) failures.push(`${label}: повтор состава ${mix.signature}`); + signatures.add(mix.signature); + pairs.add(mix.pairKey); + } + stats.pairRepeats += mixes.length - pairs.size; + + const focusList = buildFocusList(opts); + + for (const mix of mixes) { + stats.total += 1; + stats.byProfile[profile] = (stats.byProfile[profile] ?? 0) + 1; + stats.byStrength[opts.strength] = (stats.byStrength[opts.strength] ?? 0) + 1; + if (mix.items.length === 2) stats.twoPart += 1; + if (mix.items.length === 3) stats.threePart += 1; + if (mix.items.some((i) => isColdFlavor(getFlavor(i.flavorId)!))) stats.withFrost += 1; + if (mix.curated) stats.fallback += 1; + if (mix.unverified) stats.unverified += 1; + + // Приоритет: каждый микс содержит хотя бы один приоритетный вкус/категорию + if (focusList.length > 0 && !mix.curated && !focusList.some((f) => covers(mix, f))) + failures.push(`${label}: в миксе нет приоритета [${mix.items.map((i) => i.flavorId)}]`); + + // Исключения всегда сильнее: ни один микс не содержит стоп-лист + for (const item of mix.items) { + const f = getFlavor(item.flavorId)!; + if (opts.excludedFlavorIds.includes(f.id)) failures.push(`${label}: ${f.id} из стоп-листа`); + if (opts.excludedCategories.includes(f.category) && !mix.curated) + failures.push(`${label}: категория ${f.category} из стоп-листа`); + } + + if (mix.curated) { + // кураторская заготовка не может быть гипотезой: она проверена мастером + if (mix.unverified) failures.push(`${label}: заготовка помечена гипотезой`); + const structural = validateMix(mix.items, profile, opts, true); + if (!structural.ok) failures.push(`${label} curated: ${structural.errors.join("; ")}`); + } else if (!mix.unverified) { + const full = validateMix(mix.items, profile, opts); + if (!full.ok) failures.push(`${label}: ${full.errors.join("; ")}`); + } + structuralChecks(mix, seed); + } + + // Несколько приоритетов распределяются по карточкам + if (focusList.length > 1 && !mixes.some((m) => m.curated)) { + const covered = focusList.filter((f) => mixes.some((m) => covers(m, f))).length; + const expected = Math.min(focusList.length, mixes.length); + if (covered < expected) + failures.push(`${label}: приоритеты не распределены (${covered}/${expected})`); + } +} + +const base = (strength: FlavorStrength, cooling: boolean): GenerateOptions => ({ + strength, + cooling, + excludedCategories: [], + excludedFlavorIds: [], +}); + +/* Сценарий А: все профили × крепости × холодок, без стоп-листа. + Кураторские заготовки («Проверен мастером») — легитимная часть выдачи. */ +for (const profile of profiles) + for (const strength of strengths) + for (const cooling of [false, true]) + for (let n = 0; n < 3; n++) runSet(profile, base(strength, cooling), randomSeed()); + +/* Сценарий Б: случайные параметры + исключённая категория */ +for (let n = 0; n < 60; n++) { + const seed = randomSeed(); + const opts = base(strengths[(seed >>> 2) % 3], seed % 2 === 0); + if (n % 4 === 0) opts.excludedCategories = [categories[(seed >>> 4) % categories.length]]; + runSet(profiles[seed % profiles.length], opts, seed); +} + +/* Сценарий В: один приоритетный вкус — для каждого вкуса, с холодком и без */ +for (const flavor of warmFlavors) { + const strength = (flavor.strength === "any" ? "medium" : flavor.strength) as FlavorStrength; + for (const cooling of [false, true]) { + runSet("fresh", { ...base(strength, cooling), priorityFlavorIds: [flavor.id] }, randomSeed()); + } +} + +/* Сценарий Г: несколько приоритетных вкусов (2–4 штуки) */ +for (let n = 0; n < 40; n++) { + const seed = randomSeed(); + const rng = mulberry32(seed); + const count = 2 + Math.floor(rng() * 3); + const picked = new Set(); + while (picked.size < count) picked.add(warmFlavors[Math.floor(rng() * warmFlavors.length)].id); + runSet( + profiles[seed % profiles.length], + { ...base(strengths[(seed >>> 3) % 3], seed % 2 === 0), priorityFlavorIds: [...picked] }, + seed, + ); +} + +/* Сценарий Д: приоритетные категории (+ иногда вкус) */ +for (let n = 0; n < 40; n++) { + const seed = randomSeed(); + const rng = mulberry32(seed); + const cats = new Set(); + const count = 1 + Math.floor(rng() * 2); + while (cats.size < count) cats.add(warmCategories[Math.floor(rng() * warmCategories.length)]); + const opts: GenerateOptions = { + ...base(strengths[(seed >>> 3) % 3], seed % 2 === 0), + priorityCategories: [...cats], + }; + if (n % 3 === 0) opts.priorityFlavorIds = [warmFlavors[seed % warmFlavors.length].id]; + runSet(profiles[seed % profiles.length], opts, seed); +} + +/* Сценарий Е: вариации на каждый вкус × крепость. + Составы без долей уникальны: у холодного вкуса и узких стоп-листов + уникальных наборов может быть меньше 6, но не меньше 3. */ +let variationCount = 0; +for (const flavor of FLAVORS) { + for (const strength of strengths) { + const variants = buildFlavorVariations(flavor.id, strength, 123456789); + variationCount += variants.length; + if (variants.length < 3) + failures.push(`вариаций для ${flavor.id}/${strength}: ${variants.length}, меньше 3`); + if (new Set(variants.map((v) => v.signature)).size !== variants.length) + failures.push(`повторы вариаций ${flavor.id}/${strength}`); + for (const v of variants) { + if (!v.items.some((i) => i.flavorId === flavor.id)) + failures.push(`вариация ${flavor.id}/${strength} без целевого вкуса`); + const structural = validateMix(v.items, v.profile, base(strength, false), true); + if (!structural.ok && !v.unverified) + failures.push(`вариация ${flavor.id}/${strength}: ${structural.errors.join("; ")}`); + } + } +} + +/* Детерминизм и воспроизводимость «свежей» выдачи по seed */ +const detOpts: GenerateOptions = { + ...base("strong", true), + priorityFlavorIds: ["white-grape", "honey-melon"], + priorityCategories: ["citrus"], +}; +const deterministic = + JSON.stringify(generateMixSet("fresh", detOpts, 424242)) === + JSON.stringify(generateMixSet("fresh", detOpts, 424242)); +if (!deterministic) failures.push("детерминизм нарушен"); + +const fresh = generateFreshMixSet("fresh", detOpts, []); +const reproduced = + JSON.stringify(generateMixSet("fresh", detOpts, fresh.seed).mixes) === JSON.stringify(fresh.mixes); +if (!reproduced) failures.push("seed «свежей» выдачи не воспроизводит подборку"); + +/* Анти-повтор между выдачами (мягкий, считаем статистику) */ +let crossRepeats = 0; +let history: string[] = []; +for (let n = 0; n < 20; n++) { + const res = generateFreshMixSet("fresh", base("strong", false), history); + crossRepeats += res.mixes.filter((m) => history.includes(m.pairKey)).length; + history = res.newHistory; +} + +/* Share: v2 туда-обратно + совместимость со старыми v1-ссылками */ +const payload: SharePayload = { + seed: 123456, + profile: "fresh", + strength: "medium", + cooling: true, + excludedCategories: ["spices"], + excludedFlavorIds: ["cookie-uc"], + priorityCategories: ["berries"], + priorityFlavorIds: ["green-day", "pinkman"], +}; +const link = encodeShareLink(payload); +const roundTrip = parseShareLink(link); +if (JSON.stringify(roundTrip) !== JSON.stringify(payload)) + failures.push(`share v2: ${link} → ${JSON.stringify(roundTrip)}`); + +const legacy = parseShareLink("#mix=v1:777:berry:strong:0:spices"); +if ( + !legacy || + legacy.seed !== 777 || + legacy.excludedCategories.join() !== "spices" || + legacy.priorityFlavorIds.length !== 0 +) + failures.push("share v1 больше не разбирается"); +if (parseShareLink("#mix=v2:1:unknown:strong:0::::") !== null) + failures.push("share: принят неизвестный профиль"); + +/* Статистика */ +const avgLength = (stats.twoPart * 2 + stats.threePart * 3) / Math.max(1, stats.total); +console.log("=== Self-test движка миксов ==="); +console.log(`Всего миксов: ${stats.total}`); +console.log(`Кураторских fallback: ${stats.fallback}`); +console.log(`Гипотез «спроси мастера»: ${stats.unverified}`); +console.log(`Повторов пар в выдаче: ${stats.pairRepeats} (составы при этом разные)`); +console.log(`Неполных выдач (<3 миксов): ${stats.shortSets}`); +console.log(`2-компонентных: ${stats.twoPart}, 3-компонентных: ${stats.threePart}`); +console.log(`Средняя длина: ${avgLength.toFixed(2)} вкуса`); +console.log(`С холодком: ${stats.withFrost}`); +console.log(`Вариаций на все вкусы: ${variationCount} (уникальные составы)`); +console.log(`Повторов пар между выдачами: ${crossRepeats} за 20 генераций`); +console.log("По профилям:", stats.byProfile); +console.log("По крепости:", stats.byStrength); +console.log(`Детерминизм seed: ${deterministic ? "OK" : "FAIL"}`); +console.log(`Воспроизводимость по seed: ${reproduced ? "OK" : "FAIL"}`); +console.log(`Share v2 / v1: ${roundTrip && legacy ? "OK" : "FAIL"} ${link}`); + +if (failures.length > 0) { + console.error(`\n❌ Провалено проверок: ${failures.length}`); + failures.slice(0, 25).forEach((f) => console.error(" - " + f)); + process.exit(1); +} +console.log("\n✅ Все миксы прошли валидацию"); diff --git a/site/scripts/export-crm-seed.ts b/site/scripts/export-crm-seed.ts new file mode 100644 index 0000000..623cd01 --- /dev/null +++ b/site/scripts/export-crm-seed.ts @@ -0,0 +1,144 @@ +/** + * Экспорт меню и табаков сайта в seed-JSON для CRM (crm/seed/*.json). + * + * Запуск из папки site: npm run export:crm + * CRM при старте сеет этими данными таблицы menu_items / tobacco_flavors, + * библиотеку игр (games.json, обложки -> seed/covers/) и отдаёт + * авторские миксы сайта во вкладку «Миксы» (mixes.json). + * После изменения меню на сайте — перегенерируй файлы и пересобери CRM. + */ +import { cpSync, existsSync, mkdirSync, readdirSync, renameSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { barMenuItems } from "../src/data/barMenu"; +import { beerList } from "../src/data/beerMenu"; +import { authorTeas } from "../src/data/teaMenu"; +import { + boardGameDifficultyLabels, + boardGames, + hookahMixes, + ps5Games, + tobaccoFlavors, +} from "../src/data/catalog"; + +// Атомарная запись (F-6): обрыв экспорта не оставит битый seed — рестарт CRM +// с повреждённым файлом роняется fail-closed'ом и не вычистит каталог. +const outDir = resolve(dirname(fileURLToPath(import.meta.url)), "../../crm/seed"); + +const writeSeedJson = (name: string, data: unknown) => { + const target = resolve(outDir, name); + const tmp = target + ".tmp"; + writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", "utf-8"); + renameSync(tmp, target); +}; +const siteRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const gamesImagesDir = resolve(siteRoot, "public/images/games"); + +const menu = [ + ...authorTeas.map((tea) => ({ + id: tea.id, + name: tea.name, + category: tea.category, + price: tea.price, + portion: tea.volume, + })), + ...beerList.map((beer) => ({ + id: beer.id, + name: beer.name, + category: beer.category, + price: beer.price, + portion: beer.volume, + })), + ...barMenuItems.map((item) => ({ + id: item.id, + name: item.name, + category: item.category, + price: item.price, + portion: item.portion ?? null, + })), +]; + +// Стоп-лист в CRM живёт на уровне отдельных вкусов, а не брендов: +// экспортируем все вкусы каталога (включая «Добавку» — её вкусы тоже +// участвуют в конструкторе миксов). +const tobacco = tobaccoFlavors + .map((f) => ({ + id: f.id, + brand: f.brand, + flavor: f.name, + note: f.note ?? null, + strength: f.strength, + cooling: f.cooling, + })) + .sort( + (a, b) => + a.brand.localeCompare(b.brand, "ru") || a.flavor.localeCompare(b.flavor, "ru"), + ); + +mkdirSync(outDir, { recursive: true }); +writeSeedJson("menu.json", menu); +writeSeedJson("tobacco.json", tobacco); + +// ------------------------------------------------------------------ игры +// PS5: note = жанр и игроки; обложка копируется в crm/seed/covers/ +// (.jfif переименовывается в .jpg — у CRM mimetype для .jfif не настроен). +// Настолки: note = категория, игроки, время, сложность. +const coversDir = resolve(outDir, "covers"); +mkdirSync(coversDir, { recursive: true }); + +const coverFile = (image: string): string | null => { + if (!image) return null; + const srcName = image.split("/").pop() ?? ""; + const src = resolve(gamesImagesDir, srcName); + if (!existsSync(src)) return null; + const dstName = srcName.toLowerCase().endsWith(".jfif") + ? srcName.slice(0, -5) + ".jpg" + : srcName; + cpSync(src, resolve(coversDir, dstName)); + return dstName; +}; + +const games = [ + ...ps5Games.map((g) => ({ + seed_id: g.id, + kind: "ps5", + title: g.name, + note: `${g.genre} · до ${g.maxPlayers} игрок${g.maxPlayers === 1 ? "а" : "ов"}`, + cover: coverFile(g.image), + })), + ...boardGames.map((g) => ({ + seed_id: `board-${g.id}`, + kind: "board", + title: g.name, + note: `${g.category} · ${g.minPlayers}–${g.maxPlayers} игроков · ${g.duration} · ` + + `${boardGameDifficultyLabels[g.difficulty].toLowerCase()}${g.hit ? " · хит" : ""}`, + cover: null, + difficulty: g.difficulty, + icon: g.icon, + accent: g.accent, + })), +]; + +// ------------------------------------------------------------------ миксы сайта +// Авторские миксы из каталога сайта: CRM показывает их во вкладке «Миксы» +// только на чтение — на сайте они рендерятся сами, дубли в mix_recipes не нужны. +const siteMixes = hookahMixes.map((m) => ({ + id: m.id, + name: m.name, + note: m.desc, + strength: m.strength, + cooling: m.cooling, + flavor_tags: m.flavorTags, + price: m.price, + featured: m.featured ?? false, +})); + +writeSeedJson("games.json", games); +writeSeedJson("mixes.json", siteMixes); + +const coversCopied = readdirSync(coversDir).length; +console.log( + `menu.json: ${menu.length} позиций, tobacco.json: ${tobacco.length} вкусов,` + + ` games.json: ${games.length} игр (обложек скопировано: ${coversCopied}),` + + ` mixes.json: ${siteMixes.length} миксов -> ${outDir}`, +); diff --git a/site/scripts/make-favicon.py b/site/scripts/make-favicon.py new file mode 100644 index 0000000..8b00321 --- /dev/null +++ b/site/scripts/make-favicon.py @@ -0,0 +1,51 @@ +# Генератор favicon-набора сайта НЕОН: python scripts/make-favicon.py +# Запускается вручную (нужен Pillow: pip install pillow). Рисует фирменный +# логотип «Н» и складывает файлы в public/ — Vite копирует их в корень +# сборки, nginx отдаёт /favicon.ico поисковикам (Яндекс/Google забирают +# иконку именно с адреса /favicon.ico, data-URI из они не читают). +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +ROOT = Path(__file__).resolve().parent.parent +OUT = ROOT / "public" + +BG = (10, 10, 15, 255) # #0A0A0F +FG = (0, 240, 255, 255) # #00F0FF +FONT = r"C:\Windows\Fonts\ariblk.ttf" # Arial Black — как в SVG-логотипе +RADIUS_RATIO = 14 / 64 # скругление углов из SVG (rx=14 при 64) + + +def draw_badge(size: int, rounded: bool) -> Image.Image: + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + d = ImageDraw.Draw(img) + if rounded: + d.rounded_rectangle([0, 0, size - 1, size - 1], + radius=round(size * RADIUS_RATIO), fill=BG) + else: + # apple-touch-icon: без прозрачных углов — iOS сам скругляет маской + d.rectangle([0, 0, size, size], fill=BG) + font = ImageFont.truetype(FONT, round(size * 34 / 64)) + d.text((size / 2, size * 0.53), "Н", font=font, fill=FG, anchor="mm") + return img + + +OUT.mkdir(exist_ok=True) + +# вектор — браузеры с поддержкой SVG-иконок берут его, файл не зависит от шрифтов +(OUT / "favicon.svg").write_text( + "" + "" + "Н", + encoding="utf-8", +) + +master = draw_badge(512, rounded=True) +for px in (48, 32, 16): + master.resize((px, px), Image.LANCZOS).save(OUT / f"favicon-{px}x{px}.png") +# favicon.ico: 16+32+48 в одном файле — его запрашивают поисковики напрямую +master.save(OUT / "favicon.ico", sizes=[(16, 16), (32, 32), (48, 48)]) +draw_badge(180, rounded=False).save(OUT / "apple-touch-icon.png") +print("OK: favicon.ico, favicon.svg, favicon-16x16.png, favicon-32x32.png,", + "favicon-48x48.png, apple-touch-icon.png ->", OUT) diff --git a/site/scripts/patch-stoplist-mixes.mjs b/site/scripts/patch-stoplist-mixes.mjs new file mode 100644 index 0000000..852da6b --- /dev/null +++ b/site/scripts/patch-stoplist-mixes.mjs @@ -0,0 +1,575 @@ +// Патч «больше миксов из табаков сайта + стоп-лист миксов». +// 1) mixEngine: generateShowcaseMixes принимает исключённые вкусы и берёт +// до 2 миксов на комбинацию «профиль × крепость × холодок»; +// + withStopWarnings/STOP_LIST_WARNING (помечает миксы со стоп-табаком). +// 2) catalog: блок витрины переносится ПОСЛЕ tobaccoFlavors, витрина +// собирается только из табаков, реально доступных на сайте. +// 3) MixesPage: стоп-лист CRM — микс уходит в стоп-лист (бейдж, гашение, +// красная подсветка стоп-вкусов, предупреждение на карточке). +// 4) AiMixPage: выдача и микс мастера перепроверяются на стоп-табак +// (share-восстановление и мастер-подбор не применяли стоп-лист). +// 5) HeroActions: русское множественное для счётчика миксов. +// Каждый якорь проверяется на точное число вхождений; при расхождении — +// die() без записи файла. Повторный запуск: пропускает уже пропатченное. +import { readFileSync, writeFileSync, copyFileSync, rmSync, existsSync } from "node:fs"; + +const die = (msg) => { + console.error("PATCH_FAIL: " + msg); + process.exit(1); +}; +const rep = (src, from, to, label) => { + const parts = src.split(from); + if (parts.length - 1 !== 1) die(`${label}: found ${parts.length - 1}, expected 1`); + return parts.join(to); +}; + +/* ------------------------- 1. mixEngine.ts ------------------------- */ +{ + const FILE = "src/lib/mixEngine.ts"; + let src = readFileSync(FILE, "utf8"); + if (src.includes("withStopWarnings")) { + console.log("1 mixEngine: ALREADY_PATCHED"); + } else { + copyFileSync(FILE, FILE + ".bak"); + + src = rep( + src, + "export interface ShowcaseMix {", + [ + "/* ---------------- CRM stop-list: warnings in results ------------------- */", + "", + "/** Warning on a mix card whose tobacco has gone into the CRM stop-list. */", + 'export const STOP_LIST_WARNING =', + ' "Some tobaccos in the composition are in the stop-list — the hookah lounge attendant will pick a replacement.";', + "", + "/**", + " * Marks mixes whose composition contains a stopped tobacco (matching by", + " * the engine flavor's catalogId). If there is nothing to change, returns", + " * the same references — safe to call on every stop-list poll.", + " */", + "export function withStopWarnings(", + " mixes: T[],", + " stoppedCatalogIds: ReadonlySet,", + "): T[] {", + " if (stoppedCatalogIds.size === 0) return mixes;", + " let changed = false;", + " const next = mixes.map((mix) => {", + " const hit = mix.items.some((item) => {", + " const flavor = byId.get(item.flavorId);", + " return !!flavor && stoppedCatalogIds.has(flavor.catalogId ?? flavor.id);", + " });", + " if (!hit || mix.warning === STOP_LIST_WARNING) return mix;", + " changed = true;", + " return { ...mix, warning: STOP_LIST_WARNING };", + " });", + " return changed ? next : mixes;", + "}", + "", + "export interface ShowcaseMix {", + ].join("\n"), + "mixEngine: helpers before ShowcaseMix", + ); + + src = rep( + src, + "export function generateShowcaseMixes(): ShowcaseMix[] {", + "export function generateShowcaseMixes(excludedFlavorIds: string[] = []): ShowcaseMix[] {", + "mixEngine: showcase signature", + ); + + src = rep( + src, + [ + " excludedCategories: [],", + " excludedFlavorIds: [],", + " }, seed++);", + " const pick = mixes.find((m) => !m.unverified && !seen.has(m.signature));", + " if (!pick) continue;", + " seen.add(pick.signature);", + " let name = pick.name;", + " if (seenNames.has(name)) {", + " name = v.cooling ? `${name} · с холодком` : `${name} · без холодка`;", + " let n = 2;", + " while (seenNames.has(name)) {", + " name = `${pick.name} · вариант ${n}`;", + " n += 1;", + " }", + " }", + " seenNames.add(name);", + " out.push({ mix: { ...pick, name }, strength: v.level, cooling: v.cold });", + ].join("\n"), + [ + " excludedCategories: [],", + " excludedFlavorIds,", + " }, seed++);", + " // до двух миксов на комбинацию «профиль × крепость × холодок»:", + " // витрина заметно больше, составы по-прежнему уникальны и без гипотез", + " const picks = mixes", + " .filter((m) => !m.unverified && !seen.has(m.signature))", + " .slice(0, 2);", + " for (const pick of picks) {", + " seen.add(pick.signature);", + " let name = pick.name;", + " if (seenNames.has(name)) {", + " name = v.cooling ? `${name} · с холодком` : `${name} · без холодка`;", + " let n = 2;", + " while (seenNames.has(name)) {", + " name = `${pick.name} · вариант ${n}`;", + " n += 1;", + " }", + " }", + " seenNames.add(name);", + " out.push({ mix: { ...pick, name }, strength: v.level, cooling: v.cold });", + " }", + ].join("\n"), + "mixEngine: showcase picks", + ); + + writeFileSync(FILE, src); + console.log("1 mixEngine: PATCHED"); + } +} + +/* -------------------------- 2. catalog.ts -------------------------- */ +{ + const FILE = "src/data/catalog.ts"; + let src = readFileSync(FILE, "utf8"); + if (src.includes("siteUnavailableFlavorIds")) { + console.log("2 catalog: ALREADY_PATCHED"); + } else { + copyFileSync(FILE, FILE + ".bak"); + + src = rep( + src, + 'import { CATEGORY_LABELS } from "./flavors";', + 'import { CATEGORY_LABELS, FLAVORS } from "./flavors";', + "catalog: FLAVORS import", + ); + + // вырезаем блок витрины (до tobaccoFlavors ещё не инициализирован) + const BLOCK_START = "/** Витрина: генерации движка по всем профилям/крепостям/холодку. */"; + const BLOCK_END = + ' "Цены указаны за один кальян. Крепкие миксы — 1 800 ₽, остальные — 1 400 ₽.";'; + const startIdx = src.indexOf(BLOCK_START); + if (startIdx === -1) die("catalog: showcase block start not found"); + if (src.indexOf(BLOCK_START, startIdx + 1) !== -1) die("catalog: block start not unique"); + const endIdx = src.indexOf(BLOCK_END, startIdx); + if (endIdx === -1) die("catalog: showcase block end not found"); + const blockEnd = endIdx + BLOCK_END.length; + let block = src.slice(startIdx, blockEnd); + + block = rep( + block, + "const showcase = generateShowcaseMixes();", + [ + "/** Табаки, которых нет на сайте (или они помечены недоступными), в витрину", + " * не берём: каждый микс собран из реального ассортимента «Вкусов». */", + "const siteUnavailableFlavorIds: string[] = FLAVORS.filter((f) => {", + " const t = tobaccoFlavors.find((x) => x.id === (f.catalogId ?? f.id));", + " return !!t && !t.available;", + "}).map((f) => f.id);", + "", + "/** Витрина: генерации движка по всем профилям/крепостям/холодку. */", + "const showcase = generateShowcaseMixes(siteUnavailableFlavorIds);", + ].join("\n"), + "catalog: showcase call", + ); + + // удаляем блок из старого места + src = src.slice(0, startIdx) + src.slice(blockEnd); + + // вставляем перед секцией PlayStation 5 + const psIdx = src.indexOf("/* PlayStation 5"); + if (psIdx === -1) die("catalog: PS5 section not found"); + const boxStart = src.lastIndexOf("/*", psIdx - 1); + if (boxStart === -1) die("catalog: PS5 comment box not found"); + src = src.slice(0, boxStart) + block + "\n" + src.slice(boxStart); + + writeFileSync(FILE, src); + console.log("2 catalog: PATCHED (block moved, " + block.length + " chars)"); + } +} + +/* ------------------------ 3. MixesPage.tsx ------------------------- */ +{ + const FILE = "src/pages/MixesPage.tsx"; + let src = readFileSync(FILE, "utf8"); + if (src.includes("markedCards")) { + console.log("3 MixesPage: ALREADY_PATCHED"); + } else { + copyFileSync(FILE, FILE + ".bak"); + + src = rep( + src, + 'import { useCustomCatalog, type CustomMix } from "../lib/crm";', + 'import { useCustomCatalog, useStoplist, type CustomMix } from "../lib/crm";', + "MixesPage: crm import", + ); + + src = rep( + src, + [ + " /** вкус-холодок (синяя точка и роль «Холодок») */", + " cold: boolean;", + "}", + ].join("\n"), + [ + " /** вкус-холодок (синяя точка и роль «Холодок») */", + " cold: boolean;", + " /** табак в каталоге сайта — по нему сверяется стоп-лист CRM */", + " catalogId: string | null;", + " /** этот вкус ушёл в стоп-лист */", + " stopped: boolean;", + "}", + ].join("\n"), + "MixesPage: UnifiedItem", + ); + + src = rep( + src, + [ + " items: UnifiedItem[];", + "}", + ].join("\n"), + [ + " items: UnifiedItem[];", + " /** хотя бы один вкус состава в стоп-листе CRM — карточка гасится */", + " stopped: boolean;", + "}", + ].join("\n"), + "MixesPage: UnifiedMix", + ); + + src = rep( + src, + [ + " return {", + " title: flavor?.name ?? item.flavorId,", + " sub: flavor && flavor.name !== translation ? translation : null,", + " cold: !!flavor && isColdFlavor(flavor),", + " };", + ].join("\n"), + [ + " return {", + " title: flavor?.name ?? item.flavorId,", + " sub: flavor && flavor.name !== translation ? translation : null,", + " cold: !!flavor && isColdFlavor(flavor),", + " catalogId: flavor ? flavor.catalogId ?? flavor.id : null,", + " stopped: false,", + " };", + ].join("\n"), + "MixesPage: itemsFromAi", + ); + + src = rep( + src, + [ + " return {", + " title: f.flavor,", + " sub: f.brand,", + " cold: !!siteFlavor && isColdFlavor(siteFlavor),", + " };", + ].join("\n"), + [ + " return {", + " title: f.flavor,", + " sub: f.brand,", + " cold: !!siteFlavor && isColdFlavor(siteFlavor),", + " catalogId: f.id,", + " stopped: false,", + " };", + ].join("\n"), + "MixesPage: itemsFromMaster", + ); + + src = rep( + src, + " items: itemsFromAi(mix),\n}));", + " items: itemsFromAi(mix),\n stopped: false,\n}));", + "MixesPage: aiCards stopped", + ); + + src = rep( + src, + " items: itemsFromMaster(mix),\n })),", + " items: itemsFromMaster(mix),\n stopped: false,\n })),", + "MixesPage: masterCards stopped", + ); + + src = rep( + src, + " const catalog = useCustomCatalog();", + " const catalog = useCustomCatalog();\n const stoplist = useStoplist();", + "MixesPage: useStoplist hook", + ); + + src = rep( + src, + " const allCards = useMemo(() => [...masterCards, ...aiCards], [masterCards]);", + [ + " const allCards = useMemo(() => [...masterCards, ...aiCards], [masterCards]);", + "", + " // стоп-лист CRM: микс с остановленным табаком уходит в стоп-лист —", + " // карточка гасится, стоп-вкусы в составе подсвечиваются красным", + " const markedCards = useMemo(", + " () =>", + " allCards.map((card) => {", + " const items = card.items.map((item) => ({", + " ...item,", + " stopped: item.catalogId != null && stoplist.tobacco.has(item.catalogId),", + " }));", + " return { ...card, items, stopped: items.some((item) => item.stopped) };", + " }),", + " [allCards, stoplist.tobacco],", + " );", + ].join("\n"), + "MixesPage: markedCards", + ); + + src = rep( + src, + " return allCards.filter((m) => {", + " return markedCards.filter((m) => {", + "MixesPage: filtered source", + ); + + src = rep( + src, + " }, [allCards, query, strength, cooling, flavor]);", + " }, [markedCards, query, strength, cooling, flavor]);", + "MixesPage: filtered deps", + ); + + src = rep( + src, + " Показано {filtered.length} из {allCards.length}", + " Показано {filtered.length} из {markedCards.length}", + "MixesPage: counter line", + ); + + src = rep( + src, + [ + ' : "hover:border-neon-violet/40 hover:shadow-[0_18px_50px_-24px_rgba(176,38,255,0.5)]",', + " )}", + ].join("\n"), + [ + ' : "hover:border-neon-violet/40 hover:shadow-[0_18px_50px_-24px_rgba(176,38,255,0.5)]",', + ' card.stopped && "opacity-70 saturate-[0.65]",', + " )}", + ].join("\n"), + "MixesPage: card dim", + ); + + src = rep( + src, + [ + ' {isMaster ? "Микс от мастера" : "Микс от ИИ"}', + " ", + " {card.tags.map((tag) => (", + ].join("\n"), + [ + ' {isMaster ? "Микс от мастера" : "Микс от ИИ"}', + " ", + " {card.stopped && (", + ' ', + " В стоп-листе", + " ", + " )}", + " {card.tags.map((tag) => (", + ].join("\n"), + "MixesPage: stop badge", + ); + + src = rep( + src, + " style={{ background: segmentGradient(i, item.cold) }}", + [ + " style={{", + " background: item.stopped", + ' ? "linear-gradient(90deg,#f87171,#ef4444)"', + " : segmentGradient(i, item.cold),", + " }}", + ].join("\n"), + "MixesPage: red dot", + ); + + src = rep( + src, + '{item.title}', + '{item.title}', + "MixesPage: red title", + ); + + src = rep( + src, + " {/* крепость · холодок · цена */}", + [ + " {card.stopped && (", + '

', + " Часть табаков из состава сейчас в стоп-листе — кальянщик подберёт замену.", + "

", + " )}", + "", + " {/* крепость · холодок · цена */}", + ].join("\n"), + "MixesPage: stop warning line", + ); + + writeFileSync(FILE, src); + console.log("3 MixesPage: PATCHED"); + } +} + +/* ------------------------ 4. AiMixPage.tsx ------------------------- */ +{ + const FILE = "src/pages/AiMixPage.tsx"; + let src = readFileSync(FILE, "utf8"); + if (src.includes("withMasterStopNote")) { + console.log("4 AiMixPage: ALREADY_PATCHED"); + } else { + copyFileSync(FILE, FILE + ".bak"); + + src = rep( + src, + '} from "../lib/mixEngine";', + " withStopWarnings,\n} from \"../lib/mixEngine\";", + "AiMixPage: engine import", + ); + + src = rep( + src, + "function pickMasterMix(", + [ + "/** Предупреждение о стоп-табаке в миксе мастера: дописывается в note его", + " * карточки (note рендерится на карточке «Микс от мастера» в выдаче).", + " * Идемпотентно и обратимо: табак вернулся из стопа — note откатывается. */", + 'const MASTER_STOP_NOTE =', + ' "⚠ В составе есть табак из стоп-листа — мастер подберёт замену.";', + "", + "function withMasterStopNote(mix: CustomMix, stopped: ReadonlySet): CustomMix {", + " const hit = mix.flavors.some((f) => stopped.has(f.id));", + ' const base = (mix.note ?? "").split(MASTER_STOP_NOTE)[0].trim();', + " if (!hit) {", + ' return base === (mix.note ?? "").trim() ? mix : { ...mix, note: base || null };', + " }", + ' const next = [base, MASTER_STOP_NOTE].filter(Boolean).join(" ");', + ' return next === (mix.note ?? "").trim() ? mix : { ...mix, note: next };', + "}", + "", + "function pickMasterMix(", + ].join("\n"), + "AiMixPage: withMasterStopNote", + ); + + src = rep( + src, + [ + " [currentOptions, stopExcludedIds],", + " );", + ].join("\n"), + [ + " [currentOptions, stopExcludedIds],", + " );", + "", + " /* Выдача и микс мастера перепроверяются на стоп-табак при каждом обновлении", + " стоп-листа: восстановление из share-ссылки собирается без стоп-исключений", + " (у каждого получателя свой стоп-лист), поэтому состав может содержать", + " остановленный табак — тогда на карточке появляется предупреждение. */", + ' const stopKey = useMemo(() => [...stoplist.tobacco].sort().join("|"), [stoplist]);', + " useEffect(() => {", + ' const stopped = new Set(stopKey ? stopKey.split("|") : []);', + " setResults((prev) => (prev ? withStopWarnings(prev, stopped) : prev));", + " setMasterPick((prev) => (prev ? withMasterStopNote(prev, stopped) : prev));", + " }, [stopKey]);", + ].join("\n"), + "AiMixPage: stopKey effect", + ); + + src = rep( + src, + " setResults(mixes);", + " setResults(withStopWarnings(mixes, stoplist.tobacco));", + "AiMixPage: finish results", + ); + + src = rep( + src, + " setResults(generateMixSet(shared.profile, options, shared.seed).mixes);", + " setResults(", + " withStopWarnings(generateMixSet(shared.profile, options, shared.seed).mixes, stoplist.tobacco),", + " );", + "AiMixPage: share restore", + ); + + src = rep( + src, + " setMasterPick(pickMasterMix(catalog.mixes, currentOptions, runProfile, seed));", + " setMasterPick(", + " withMasterStopNote(pickMasterMix(catalog.mixes, currentOptions, runProfile, seed), stoplist.tobacco),", + " );", + "AiMixPage: finish master pick", + ); + + src = rep( + src, + " setMasterPick(pickMasterMix(catalog.mixes, lastRun.options, lastRun.profile, lastRun.seed));", + " setMasterPick(", + " withMasterStopNote(pickMasterMix(catalog.mixes, lastRun.options, lastRun.profile, lastRun.seed), stoplist.tobacco),", + " );", + "AiMixPage: catalog-load master pick", + ); + + writeFileSync(FILE, src); + console.log("4 AiMixPage: PATCHED"); + } +} + +/* ----------------------- 5. HeroActions.tsx ------------------------ */ +{ + const FILE = "src/components/HeroActions.tsx"; + let src = readFileSync(FILE, "utf8"); + if (src.includes("mixPlural")) { + console.log("5 HeroActions: ALREADY_PATCHED"); + } else { + copyFileSync(FILE, FILE + ".bak"); + + src = rep( + src, + "const chips: Chip[] = [", + [ + "/* 51 микс / 52 микса / 55 миксов — русское множественное для счётчика */", + "const mixPlural = (n: number): string => {", + " const mod10 = n % 10;", + " const mod100 = n % 100;", + ' if (mod10 === 1 && mod100 !== 11) return `${n} микс`;', + ' if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return `${n} микса`;', + " return `${n} миксов`;", + "};", + "", + "const chips: Chip[] = [", + ].join("\n"), + "HeroActions: mixPlural helper", + ); + + src = rep( + src, + " { href: \"#/mixes\", icon: Sparkles, label: `${hookahMixes.length} миксов`, tone: \"violet\" },", + ' { href: "#/mixes", icon: Sparkles, label: mixPlural(hookahMixes.length), tone: "violet" },', + "HeroActions: chip label", + ); + + writeFileSync(FILE, src); + console.log("5 HeroActions: PATCHED"); + } +} + +/* зачистка временных диагностических скриптов */ +for (const junk of ["scripts/diag-flavor-map.ts", "scripts/inspect-aimix.mjs"]) { + if (existsSync(junk)) { + rmSync(junk); + console.log("removed: " + junk); + } +} + +console.log("PATCH_OK"); diff --git a/site/scripts/prerender.tsx b/site/scripts/prerender.tsx new file mode 100644 index 0000000..c7cdcaf --- /dev/null +++ b/site/scripts/prerender.tsx @@ -0,0 +1,69 @@ +/** + * Пререндер лендинга в статический HTML (SEO). + * + * Работает БЕЗ браузера: react-dom/server рендерит в Node, готовый + * DOM вставляется между маркерами в dist/index.html. + * useEffect в SSR не исполняется, поэтому вызовов /api/* в снапшоте нет — + * страница полностью статична; стоп-лист подмешивается уже на клиенте. + * + * Снапшот заменяется React (createRoot) при монтировании — SPA работает + * как раньше. Повторные запуски идемпотентны (маркеры сохраняются). + * + * Ошибка рендера не ломает сборку: остаётся статический fallback-блок + * (H1 + SEO-абзац), в консоль пишется предупреждение. + * + * Запуск: npm run build (vite build && tsx scripts/prerender.tsx) + */ +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import * as React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import App from "../src/App"; + +const here = dirname(fileURLToPath(import.meta.url)); +const distIndex = join(here, "..", "dist", "index.html"); + +// флаг для src/lib/prerender.ts (секции рендерятся «видимыми») — до рендера +(globalThis as { __NEON_PRERENDER__?: boolean }).__NEON_PRERENDER__ = true; +// App читает document.title в useRef — минимальный полифилл для Node +if (typeof (globalThis as { document?: unknown }).document === "undefined") { + (globalThis as { document?: unknown }).document = { title: "" }; +} + +const START = ""; +const END = ""; + +if (!existsSync(distIndex)) { + console.error("[prerender] dist/index.html не найден — сначала vite build"); + process.exit(1); +} + +const html = readFileSync(distIndex, "utf-8"); +if (!html.includes(START) || !html.includes(END)) { + console.warn("[prerender] маркеры SEO:FALLBACK_* не найдены — снапшот не вставлен"); + process.exit(0); +} + +try { + const snapshot = renderToStaticMarkup(); + + if (!snapshot.includes(" — похоже, отрендерилась не главная"); + } + + const updated = html.replace( + new RegExp(`${START}[\\s\\S]*?${END}`), + `${START}${snapshot}${END}`, + ); + writeFileSync(distIndex, updated, "utf-8"); + console.log( + `[prerender] снапшот вставлен: ${Math.round(snapshot.length / 1024)} KB внутрь #root`, + ); +} catch (error) { + console.warn( + "[prerender] рендер не удался, в dist остался статический fallback:", + error instanceof Error ? error.message : error, + ); + process.exit(0); +} diff --git a/site/scripts/repair-stoplist-mixes.mjs b/site/scripts/repair-stoplist-mixes.mjs new file mode 100644 index 0000000..2eeb916 --- /dev/null +++ b/site/scripts/repair-stoplist-mixes.mjs @@ -0,0 +1,417 @@ +// Починка патча стоп-листа миксов: многострочные замены первого скрипта +// записались урезанными. Здесь каждый блок собирается из ОДНОСТРОЧНЫХ +// литералов (устойчиво к искажению записи), каждый фикс идемпотентен. +import { readFileSync, writeFileSync, copyFileSync, existsSync } from "node:fs"; + +const L = (...lines) => lines.join("\n"); +const log = []; +const ensureBak = (f) => { if (!existsSync(f + ".bak")) copyFileSync(f, f + ".bak"); }; +const save = (f, src) => { ensureBak(f); writeFileSync(f, src); }; + +function fix(src, tag, has, re, to) { + if (src.includes(has)) { log.push(tag + "=skip"); return src; } + if (!re.test(src)) { log.push(tag + "=NOMATCH"); return src; } + log.push(tag + "=ok"); + return src.replace(re, () => to); +} + +/* --------------------------- mixEngine.ts --------------------------- */ +{ + const F = "src/lib/mixEngine.ts"; + let s = readFileSync(F, "utf8"); + + s = fix(s, "E1-signature", + "generateShowcaseMixes(excludedFlavorIds", + /export function generateShowcaseMixes\(\): ShowcaseMix\[\] \{/, + "export function generateShowcaseMixes(excludedFlavorIds: string[] = []): ShowcaseMix[] {"); + + s = fix(s, "E2-helpers", + "withStopWarnings", + /export interface ShowcaseMix \{/, + L( + "/* ---------------- CRM stop-list: предупреждения в выдаче ------------------- */", + "", + "/** Предупреждение на карточке микса, чей табак ушёл в стоп-лист CRM. */", + "export const STOP_LIST_WARNING =", + ' "В составе есть табак из стоп-листа — кальянщик подберёт замену.";', + "", + "/**", + " * Помечает миксы, в составе которых есть табак из стоп-листа CRM", + " * (сверка по catalogId вкуса движка). Если менять нечего, возвращает", + " * те же ссылки — безопасно вызывать на каждом опросе стоп-листа.", + " */", + "export function withStopWarnings(", + " mixes: T[],", + " stoppedCatalogIds: ReadonlySet,", + "): T[] {", + " if (stoppedCatalogIds.size === 0) return mixes;", + " let changed = false;", + " const next = mixes.map((mix) => {", + " const hit = mix.items.some((item) => {", + " const flavor = byId.get(item.flavorId);", + " return !!flavor && stoppedCatalogIds.has(flavor.catalogId ?? flavor.id);", + " });", + " if (!hit || mix.warning === STOP_LIST_WARNING) return mix;", + " changed = true;", + " return { ...mix, warning: STOP_LIST_WARNING };", + " });", + " return changed ? next : mixes;", + "}", + "", + "export interface ShowcaseMix {", + )); + + s = fix(s, "E3-opts", + "excludedFlavorIds,\n }, seed++);", + /excludedCategories: \[\],\n excludedFlavorIds: \[\],\n \}, seed\+\+\);/, + "excludedCategories: [],\n excludedFlavorIds,\n }, seed++);"); + + s = fix(s, "E3-picks", + "const picks = mixes", + /const pick = mixes\.find\(\(m\) => !m\.unverified && !seen\.has\(m\.signature\)\);[\s\S]*?out\.push\(\{ mix: \{ \.\.\.pick, name \}, strength: v\.level, cooling: v\.cold \}\);/, + L( + "const picks = mixes", + " .filter((m) => !m.unverified && !seen.has(m.signature))", + " .slice(0, 2);", + " for (const pick of picks) {", + " seen.add(pick.signature);", + " let name = pick.name;", + " if (seenNames.has(name)) {", + " name = v.cooling ? `${name} · с холодком` : `${name} · без холодка`;", + " let n = 2;", + " while (seenNames.has(name)) {", + " name = `${pick.name} · вариант ${n}`;", + " n += 1;", + " }", + " }", + " seenNames.add(name);", + " out.push({ mix: { ...pick, name }, strength: v.level, cooling: v.cold });", + " }", + )); + + save(F, s); + console.log("mixEngine: " + log.join(" ")); + log.length = 0; +} + +/* ---------------------------- catalog.ts ---------------------------- */ +{ + const F = "src/data/catalog.ts"; + const s = readFileSync(F, "utf8"); + const okCall = s.includes("generateShowcaseMixes(siteUnavailableFlavorIds)"); + const okImport = s.includes('CATEGORY_LABELS, FLAVORS } from "./flavors"'); + const okOrder = s.indexOf("const tobaccoFlavors") !== -1 && + s.indexOf("const showcase = generateShowcaseMixes") > s.indexOf("const tobaccoFlavors"); + console.log("catalog: call=" + (okCall ? 1 : 0) + " import=" + (okImport ? 1 : 0) + " order=" + (okOrder ? 1 : 0)); + if (!okCall || !okImport || !okOrder) die("catalog needs manual repair"); +} + +/* --------------------------- MixesPage.tsx -------------------------- */ +{ + const F = "src/pages/MixesPage.tsx"; + let s = readFileSync(F, "utf8"); + + s = fix(s, "M1-import", + 'import { useCustomCatalog, useStoplist, type CustomMix } from "../lib/crm";', + /import \{ useCustomCatalog, type CustomMix \} from "\.\.\/lib\/crm";/, + 'import { useCustomCatalog, useStoplist, type CustomMix } from "../lib/crm";'); + + s = fix(s, "M2-item-fields", + "catalogId: string | null;", + /( \/\*\* вкус-холодок \(синяя точка и роль «Холодок»\) \*\/\n cold: boolean;\n\})/, + L( + " /** вкус-холодок (синяя точка и роль «Холодок») */", + " cold: boolean;", + " /** табак в каталоге сайта — по нему сверяется стоп-лист CRM */", + " catalogId: string | null;", + " /** этот вкус ушёл в стоп-лист */", + " stopped: boolean;", + "}", + )); + + s = fix(s, "M3-mix-field", + "карточка гасится", + /( items: UnifiedItem\[\];\n\})/, + L( + " items: UnifiedItem[];", + " /** хотя бы один вкус состава в стоп-листе CRM — карточка гасится */", + " stopped: boolean;", + "}", + )); + + s = fix(s, "M4-items-ai", + "catalogId: flavor ? flavor.catalogId ?? flavor.id : null,", + /( cold: !!flavor && isColdFlavor\(flavor\),\n \};)/, + L( + " cold: !!flavor && isColdFlavor(flavor),", + " catalogId: flavor ? flavor.catalogId ?? flavor.id : null,", + " stopped: false,", + " };", + )); + + s = fix(s, "M5-items-master", + " catalogId: f.id,", + /( cold: !!siteFlavor && isColdFlavor\(siteFlavor\),\n \};)/, + L( + " cold: !!siteFlavor && isColdFlavor(siteFlavor),", + " catalogId: f.id,", + " stopped: false,", + " };", + )); + + s = fix(s, "M6-aicards", + " items: itemsFromAi(mix),\n stopped: false,\n}));", + /( items: itemsFromAi\(mix\),\n\}\)\);)/, + " items: itemsFromAi(mix),\n stopped: false,\n}));"); + + s = fix(s, "M7-mastercards", + " items: itemsFromMaster(mix),\n stopped: false,\n })),", + /( items: itemsFromMaster\(mix\),\n \}\)\),)/, + " items: itemsFromMaster(mix),\n stopped: false,\n })),"); + + s = fix(s, "M8-hook", + " const stoplist = useStoplist();", + /( const catalog = useCustomCatalog\(\);)/, + " const catalog = useCustomCatalog();\n const stoplist = useStoplist();"); + + s = fix(s, "M9-marked", + "const markedCards = useMemo(", + /( const allCards = useMemo\(\(\) => \[\.\.\.masterCards, \.\.\.aiCards\], \[masterCards\]\);)/, + L( + " const allCards = useMemo(() => [...masterCards, ...aiCards], [masterCards]);", + "", + " // стоп-лист CRM: микс с остановленным табаком уходит в стоп-лист —", + " // карточка гасится, стоп-вкусы в составе подсвечиваются красным", + " const markedCards = useMemo(", + " () =>", + " allCards.map((card) => {", + " const items = card.items.map((item) => ({", + " ...item,", + " stopped: item.catalogId != null && stoplist.tobacco.has(item.catalogId),", + " }));", + " return { ...card, items, stopped: items.some((item) => item.stopped) };", + " }),", + " [allCards, stoplist.tobacco],", + " );", + )); + + s = fix(s, "M10-filter", + "return markedCards.filter((m) => {", + /return allCards\.filter\(\(m\) => \{/, + "return markedCards.filter((m) => {"); + + s = fix(s, "M11-deps", + "[markedCards, query, strength, cooling, flavor]", + /\}, \[allCards, query, strength, cooling, flavor\]\);/, + "}, [markedCards, query, strength, cooling, flavor]);"); + + s = fix(s, "M12-counter", + "из {markedCards.length}", + /из \{allCards\.length\}/, + "из {markedCards.length}"); + + s = fix(s, "M13-dim", + 'card.stopped && "opacity-70 saturate-[0.65]",', + /(: "hover:border-neon-violet\/40 hover:shadow-\[0_18px_50px_-24px_rgba\(176,38,255,0\.5\)\]",\n \)\})/, + ': "hover:border-neon-violet/40 hover:shadow-[0_18px_50px_-24px_rgba(176,38,255,0.5)]",\n card.stopped && "opacity-70 saturate-[0.65]",\n )}'); + + s = fix(s, "M14-badge", + "bg-red-400/12", + /(\{isMaster \? "Микс от мастера" : "Микс от ИИ"\}\n <\/span>)/, + L( + '{isMaster ? "Микс от мастера" : "Микс от ИИ"}', + " ", + " {card.stopped && (", + ' ', + " В стоп-листе", + " ", + " )}", + )); + + s = fix(s, "M15-dot", + '"linear-gradient(90deg,#f87171,#ef4444)"', + /style=\{\{ background: segmentGradient\(i, item\.cold\) \}\}/, + 'style={{ background: item.stopped ? "linear-gradient(90deg,#f87171,#ef4444)" : segmentGradient(i, item.cold) }}'); + + s = fix(s, "M16-title", + 'item.stopped && "text-red-300"', + /\{item\.title\}<\/span>/, + '{item.title}'); + + s = fix(s, "M17-warning", + "Часть табаков из состава сейчас в стоп-листе", + /(\{\/\* крепость · холодок · цена \*\/\})/, + L( + " {card.stopped && (", + '

', + " Часть табаков из состава сейчас в стоп-листе — кальянщик подберёт замену.", + "

", + " )}", + "", + " {/* крепость · холодок · цена */}", + )); + + save(F, s); + console.log("MixesPage: " + log.join(" ")); + log.length = 0; +} + +/* --------------------------- AiMixPage.tsx -------------------------- */ +{ + const F = "src/pages/AiMixPage.tsx"; + let s = readFileSync(F, "utf8"); + + s = fix(s, "A1-import", + " withStopWarnings,\n} from", + /\} from "\.\.\/lib\/mixEngine";/, + ' withStopWarnings,\n} from "../lib/mixEngine";'); + + s = fix(s, "A2-note-fn", + "function withMasterStopNote", + /function pickMasterMix\(/, + L( + "/** Предупреждение о стоп-табаке в миксе мастера: дописывается в note его", + " * карточки (note рендерится на карточке «Микс от мастера» в выдаче).", + " * Идемпотентно и обратимо: табак вернулся из стопа — note откатывается. */", + "const MASTER_STOP_NOTE =", + ' "⚠ В составе есть табак из стоп-листа — мастер подберёт замену.";', + "", + "function withMasterStopNote(mix: CustomMix, stopped: ReadonlySet): CustomMix {", + " const hit = mix.flavors.some((f) => stopped.has(f.id));", + ' const base = (mix.note ?? "").split(MASTER_STOP_NOTE)[0].trim();', + " if (!hit) {", + ' return base === (mix.note ?? "").trim() ? mix : { ...mix, note: base || null };', + " }", + ' const next = [base, MASTER_STOP_NOTE].filter(Boolean).join(" ");', + ' return next === (mix.note ?? "").trim() ? mix : { ...mix, note: next };', + "}", + "", + "function pickMasterMix(", + )); + + s = fix(s, "A3-stopkey", + "const stopKey = useMemo(", + /\[currentOptions, stopExcludedIds\],\n \);/, + L( + "[currentOptions, stopExcludedIds],", + " );", + "", + " /* Выдача и микс мастера перепроверяются на стоп-табак при каждом обновлении", + " стоп-листа: восстановление из share-ссылки собирается без стоп-исключений", + " (у каждого получателя свой стоп-лист), поэтому состав может содержать", + " остановленный табак — тогда на карточке появляется предупреждение. */", + ' const stopKey = useMemo(() => [...stoplist.tobacco].sort().join("|"), [stoplist]);', + " useEffect(() => {", + ' const stopped = new Set(stopKey ? stopKey.split("|") : []);', + " setResults((prev) => (prev ? withStopWarnings(prev, stopped) : prev));", + " setMasterPick((prev) => (prev ? withMasterStopNote(prev, stopped) : prev));", + " }, [stopKey]);", + )); + + s = fix(s, "A4-finish-results", + "setResults(withStopWarnings(mixes, stoplist.tobacco));", + /setResults\(mixes\);/, + "setResults(withStopWarnings(mixes, stoplist.tobacco));"); + + s = fix(s, "A5-share", + "withStopWarnings(generateMixSet(shared.profile, options, shared.seed).mixes, stoplist.tobacco)", + /setResults\(generateMixSet\(shared\.profile, options, shared\.seed\)\.mixes\);/, + L( + "setResults(", + " withStopWarnings(generateMixSet(shared.profile, options, shared.seed).mixes, stoplist.tobacco),", + " );", + )); + + // первый патч урезал замену до «setResults(» — чиним урезанный вариант + if (!s.includes("withStopWarnings(generateMixSet(shared.profile")) { + const re5b = /setResults\(\s*\n\s*setLastRun\(\{ seed: shared\.seed, profile: shared\.profile, options \}\);/; + if (re5b.test(s)) { + s = s.replace(re5b, () => + L( + "setResults(", + " withStopWarnings(generateMixSet(shared.profile, options, shared.seed).mixes, stoplist.tobacco),", + " );", + " setLastRun({ seed: shared.seed, profile: shared.profile, options });", + )); + log.push("A5b-share-truncated=ok"); + } else { + log.push("A5b-share-truncated=NOMATCH"); + const i = s.indexOf("setLastRun({ seed: shared.seed"); + console.log("CTX5: " + JSON.stringify(s.slice(Math.max(0, i - 300), i + 120))); + } + } + + s = fix(s, "A6-finish-master", + "withMasterStopNote(pickMasterMix(catalog.mixes, currentOptions, runProfile, seed)", + /setMasterPick\(\s*\n\s*setHistory\(newHistory\);/, + L( + "setMasterPick(", + " withMasterStopNote(pickMasterMix(catalog.mixes, currentOptions, runProfile, seed), stoplist.tobacco),", + " );", + " setHistory(newHistory);", + )); + + s = fix(s, "A7-load-master", + "withMasterStopNote(pickMasterMix(catalog.mixes, lastRun.options", + /setMasterPick\(pickMasterMix\(catalog\.mixes, lastRun\.options, lastRun\.profile, lastRun\.seed\)\);/, + L( + "setMasterPick(", + " withMasterStopNote(pickMasterMix(catalog.mixes, lastRun.options, lastRun.profile, lastRun.seed), stoplist.tobacco),", + " );", + )); + + // первый патч урезал и эту замену — чиним урезанный вариант + if (!s.includes("withMasterStopNote(pickMasterMix(catalog.mixes, lastRun.options")) { + const re7b = /setMasterPick\(\s*\n\s*\}, \[catalog\.mixes, lastRun, masterPick\]\);/; + if (re7b.test(s)) { + s = s.replace(re7b, () => + L( + "setMasterPick(", + " withMasterStopNote(pickMasterMix(catalog.mixes, lastRun.options, lastRun.profile, lastRun.seed), stoplist.tobacco),", + " );", + " }, [catalog.mixes, lastRun, masterPick]);", + )); + log.push("A7b-load-master-truncated=ok"); + } else { + log.push("A7b-load-master-truncated=NOMATCH"); + const j = s.indexOf("[catalog.mixes, lastRun, masterPick]"); + console.log("CTX7: " + JSON.stringify(s.slice(Math.max(0, j - 300), j + 60))); + } + } + + save(F, s); + console.log("AiMixPage: " + log.join(" ")); + log.length = 0; +} + +/* ------------------------- HeroActions.tsx -------------------------- */ +{ + const F = "src/components/HeroActions.tsx"; + let s = readFileSync(F, "utf8"); + + s = fix(s, "H1-helper", + "const mixPlural = (n: number): string => {", + /const chips: Chip\[\] = \[/, + L( + "/* 51 микс / 52 микса / 55 миксов — русское множественное для счётчика */", + "const mixPlural = (n: number): string => {", + " const mod10 = n % 10;", + " const mod100 = n % 100;", + " if (mod10 === 1 && mod100 !== 11) return `${n} микс`;", + " if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return `${n} микса`;", + " return `${n} миксов`;", + "};", + "", + "const chips: Chip[] = [", + )); + + s = fix(s, "H2-label", + "label: mixPlural(hookahMixes.length)", + /label: `\$\{hookahMixes\.length\} миксов`/, + "label: mixPlural(hookahMixes.length)"); + + save(F, s); + console.log("HeroActions: " + log.join(" ")); +} + +console.log("REPAIR_DONE"); diff --git a/site/scripts/rusify-stoplist-warning.mjs b/site/scripts/rusify-stoplist-warning.mjs new file mode 100644 index 0000000..1c8ed52 --- /dev/null +++ b/site/scripts/rusify-stoplist-warning.mjs @@ -0,0 +1,57 @@ +// Русификация блока STOP_LIST_WARNING в mixEngine (первый патч записал +// его с английским текстом). Только строки, счётчики проверяются. +import { readFileSync, writeFileSync, copyFileSync, existsSync } from "node:fs"; + +const F = "src/lib/mixEngine.ts"; +let s = readFileSync(F, "utf8"); +if (!existsSync(F + ".bak2")) copyFileSync(F, F + ".bak2"); + +const rep = (from, to, label) => { + const parts = s.split(from); + if (parts.length - 1 !== 1) die(`${label}: found ${parts.length - 1}, expected 1`); + s = parts.join(to); + console.log(label + "=ok"); +}; +function die(msg) { + console.error("RUS_FAIL: " + msg); + process.exit(1); +} + +rep( + "/* ---------------- CRM stop-list: warnings in results ------------------- */", + "/* ------------------- Стоп-лист CRM: предупреждения в выдаче ---------------- */", + "section-comment", +); + +rep( + "/** Warning on a mix card whose tobacco has gone into the CRM stop-list. */", + "/** Предупреждение на карточке микса, чей табак ушёл в стоп-лист CRM. */", + "const-comment", +); + +rep( + " * Marks mixes whose composition contains a stopped tobacco (matching by", + " * Помечает миксы, в составе которых есть табак из стоп-листа CRM", + "jsdoc-1", +); + +rep( + " * the engine flavor's catalogId). If there is nothing to change, returns", + " * (сверка по catalogId вкуса движка). Если менять нечего, возвращает", + "jsdoc-2", +); + +rep( + " * the same references — safe to call on every stop-list poll.", + " * те же ссылки — безопасно вызывать на каждом опросе стоп-листа.", + "jsdoc-3", +); + +rep( + '"Some tobaccos in the composition are in the stop-list — the hookah lounge attendant will pick a replacement.";', + '"В составе есть табак из стоп-листа — кальянщик подберёт замену.";', + "warning-text", +); + +writeFileSync(F, s); +console.log("RUS_OK"); diff --git a/site/scripts/showcase-first.ts b/site/scripts/showcase-first.ts new file mode 100644 index 0000000..a2fd556 --- /dev/null +++ b/site/scripts/showcase-first.ts @@ -0,0 +1,13 @@ +// Первый витринный микс: имя и catalogId табаков состава (для e2e стоп-листа). +import { hookahMixes } from "../src/data/catalog"; +import { FLAVORS } from "../src/data/flavors"; + +const first = hookahMixes[0]; +const ids = first.items + .map((i) => { + const f = FLAVORS.find((x) => x.id === i.flavorId); + return f ? (f.catalogId ?? f.id) : null; + }) + .filter((x): x is string => !!x); +console.log("FIRST_MIX=" + first.name); +console.log("ITEM_IDS=" + ids.join(",")); diff --git a/site/src/App.tsx b/site/src/App.tsx new file mode 100644 index 0000000..0d57bc1 --- /dev/null +++ b/site/src/App.tsx @@ -0,0 +1,178 @@ +import { useEffect, useRef, useState } from "react"; +import Preloader from "./components/Preloader"; +import AgeGate from "./components/AgeGate"; +import Header from "./components/Header"; +import Hero from "./components/Hero"; +import AtmosphereCarousel from "./components/AtmosphereCarousel"; +import MenuCarousel from "./components/MenuCarousel"; +import VipCalculator from "./components/VipCalculator"; +import Features from "./components/Features"; +import Contacts from "./components/Contacts"; +import Footer from "./components/Footer"; +import FlavorsPage from "./pages/FlavorsPage"; +import GamesPage from "./pages/GamesPage"; +import MixesPage from "./pages/MixesPage"; +import AiMixPage from "./pages/AiMixPage"; +import BoardGamesPage from "./pages/BoardGamesPage"; +import MenuPage from "./pages/MenuPage"; +import TrackOrderPage from "./pages/TrackOrderPage"; +import { useHashLocation } from "./hooks/useHashLocation"; +import { isPrerender } from "./lib/prerender"; + +type CatalogPage = "flavors" | "games" | "mixes" | "ai-mix" | "board-games" | "menu" | "track" | null; + +function getCatalogPage(hash: string): CatalogPage { + if (hash === "#/flavors") return "flavors"; + if (hash === "#/games") return "games"; + if (hash === "#/mixes") return "mixes"; + if (hash === "#/ai-mix" || hash === "#/mix-builder") return "ai-mix"; + if (hash.startsWith("#mix=")) return "ai-mix"; // share-ссылка на генерацию + if (hash === "#/board-games") return "board-games"; + if (hash === "#/menu" || hash === "#/tea") return "menu"; + if (hash.startsWith("#/track")) return "track"; // #/track и #/track?code=XXX + return null; +} + +export default function App() { + const [loaded, setLoaded] = useState(false); + const hash = useHashLocation(); + const catalogPage = getCatalogPage(hash); + const isCatalog = catalogPage !== null; + const wasCatalog = useRef(isCatalog); + const homeTitle = useRef(document.title); + // в пререндер-снапшоте рендерим финальное видимое состояние страницы + const prerender = isPrerender(); + const started = loaded || prerender; + + useEffect(() => { + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + const t = setTimeout(() => { + document.body.style.overflow = previousOverflow; + setLoaded(true); + }, 2450); + return () => { + clearTimeout(t); + document.body.style.overflow = previousOverflow; + }; + }, []); + + useEffect(() => { + if (catalogPage === "flavors") { + document.title = "Вкусы табака | НЕОН, Нягань"; + } else if (catalogPage === "games") { + document.title = "Игры PS5 | НЕОН, Нягань"; + } else if (catalogPage === "mixes") { + document.title = "Миксы кальянов | НЕОН, Нягань"; + } else if (catalogPage === "ai-mix") { + document.title = "Автоподбор микса | НЕОН, Нягань"; + } else if (catalogPage === "board-games") { + document.title = "Настольные игры | НЕОН, Нягань"; + } else if (catalogPage === "menu") { + document.title = "Меню бара: Авторский чай и Пивная карта | НЕОН, Нягань"; + } else if (catalogPage === "track") { + document.title = "Статус заказа | НЕОН, Нягань"; + } else { + document.title = homeTitle.current; + } + }, [catalogPage]); + + useEffect(() => { + if (!loaded) return; + const returningToHome = wasCatalog.current && !isCatalog; + wasCatalog.current = isCatalog; + + if (catalogPage === "flavors") { + window.scrollTo({ top: 0, behavior: "instant" }); + document.getElementById("flavors-title")?.focus({ preventScroll: true }); + return; + } + + if (catalogPage === "games") { + window.scrollTo({ top: 0, behavior: "instant" }); + document.getElementById("games-title")?.focus({ preventScroll: true }); + return; + } + + if (catalogPage === "mixes") { + window.scrollTo({ top: 0, behavior: "instant" }); + document.getElementById("mixes-title")?.focus({ preventScroll: true }); + return; + } + + if (catalogPage === "ai-mix") { + window.scrollTo({ top: 0, behavior: "instant" }); + document.getElementById("ai-mix-title")?.focus({ preventScroll: true }); + return; + } + + if (catalogPage === "board-games") { + window.scrollTo({ top: 0, behavior: "instant" }); + document.getElementById("board-games-title")?.focus({ preventScroll: true }); + return; + } + + if (catalogPage === "menu") { + window.scrollTo({ top: 0, behavior: "instant" }); + document.getElementById("menu-page-title")?.focus({ preventScroll: true }); + return; + } + + if (catalogPage === "track") { + window.scrollTo({ top: 0, behavior: "instant" }); + document.getElementById("track-title")?.focus({ preventScroll: true }); + return; + } + + // The landing section mounts after the hash changes when leaving the catalog. + const section = document.getElementById(hash.slice(1) || "home"); + if (!section) return; + const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + section.scrollIntoView({ + block: "start", + behavior: returningToHome || reducedMotion ? "instant" : "smooth", + }); + if (returningToHome) { + section.tabIndex = -1; + section.focus({ preventScroll: true }); + } + }, [hash, catalogPage, isCatalog, loaded]); + + return ( +
+
+ {/* в пререндер-снапшоте прелоадер и age-gate не попадают в HTML: + бот должен видеть финальную страницу, а не оверлеи */} + {!prerender && } + {!prerender && } +
+
+ {catalogPage === "flavors" ? ( + + ) : catalogPage === "games" ? ( + + ) : catalogPage === "mixes" ? ( + + ) : catalogPage === "ai-mix" ? ( + + ) : catalogPage === "board-games" ? ( + + ) : catalogPage === "menu" ? ( + + ) : catalogPage === "track" ? ( + + ) : ( + <> + + + + + + + + )} +
+
+
+ ); +} diff --git a/site/src/components/AgeGate.tsx b/site/src/components/AgeGate.tsx new file mode 100644 index 0000000..26d48e3 --- /dev/null +++ b/site/src/components/AgeGate.tsx @@ -0,0 +1,110 @@ +import { useEffect, useRef, useState } from "react"; + +const STORAGE_KEY = "neon-age-confirmed"; + +function readConfirmed(): boolean { + try { + return localStorage.getItem(STORAGE_KEY) === "yes"; + } catch { + return false; + } +} + +export default function AgeGate({ ready }: { ready: boolean }) { + const [confirmed, setConfirmed] = useState(readConfirmed); + const [declined, setDeclined] = useState(false); + const confirmButtonRef = useRef(null); + const open = ready && !confirmed; + + useEffect(() => { + if (!open) return; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + confirmButtonRef.current?.focus({ preventScroll: true }); + return () => { + document.body.style.overflow = previousOverflow; + }; + }, [open]); + + if (!open) return null; + + const accept = () => { + try { + localStorage.setItem(STORAGE_KEY, "yes"); + } catch { + // даже без localStorage пускаем — окно просто покажется при следующем визите + } + setConfirmed(true); + }; + + return ( +
+