feat(s17): onboarding wizard + help kb + feedback + diagnostic + whats-new
Sprint 17 — onboarding-контур: 4-шаг wizard, контекстный help, in-app
feedback, admin self-diagnostic, /whats-new из CHANGELOG.md.
Ключевые цифры:
- Wizard: 4 шага + skip каждого, 7 e2e тестов ✓ за 20 секунд.
- Diagnostic: 7 параллельных проверок, ~80ms на stage.
- Bundle impact: initial +4 KB gzip (только FeedbackWidget +
HelpTooltip + EmptyStateWithDemo в основном bundle; страницы lazy).
- Regression-suite: 35 → 42 flows + 60 → 66 visual snapshots.
Backend (новые endpoint'ы):
- /api/admin/diagnostic/run — 7 параллельных проверок (DB, SMTP,
MinIO, Hangfire, диск, сертификаты, бэкап). Task.WhenAll, ~80ms.
- /api/feedback — POST {category, message}, email на FromEmail +
Telegram (если SupportTelegram:* настроены). Rate-limit 5/час.
- /api/whats-new — парсер CHANGELOG.md, возвращает {buildVersion,
items}. Dockerfile.api копирует CHANGELOG.md в content-root +
пишет VERSION из GIT_SHA build-arg.
Frontend:
- /onboarding-wizard — 4-step builder, состояние в useState,
localStorage.fm.wizardCompleted после завершения.
- <HelpTooltip topic="key"/> — popover на каждой странице, mapping
src/lib/help-topics.ts (13 keys).
- /help — knowledge base, 7 markdown topics через import.meta.glob,
mini-renderer без heavy deps, fuzzy search.
- /whats-new — список из /api/whats-new, иконки по типу (feat/fix).
- /admin/diagnostic — Admin/SuperAdmin only, 🟢/🟡/🔴 индикаторы.
- <FeedbackWidget> в sidebar footer + ссылки на /help и /whats-new.
- <EmptyStateWithDemo> placeholder для будущих видео-демо.
scripts/generate-changelog.sh — git log feat:/fix: за 90 дней
→ CHANGELOG.md (307 строк сгенерировано).
Wizard UX-screenshots в docs/sprint17-screenshots/ (6 PNG: 4 шага +
help + diagnostic).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
307
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,307 @@
|
||||||
|
# CHANGELOG
|
||||||
|
|
||||||
|
Auto-generated from git log feat:/fix: (last 90 days).
|
||||||
|
|
||||||
|
## 2026-06-07
|
||||||
|
|
||||||
|
- **feat**: security headers + rate-limits + sensitive-ops audit + session revoke + Grafana (s13)
|
||||||
|
- **feat**: ОФД-scaffolding — IFiscalProvider + 4 провайдера + UI/тесты (s11)
|
||||||
|
|
||||||
|
## 2026-06-06
|
||||||
|
|
||||||
|
- **feat**: dark mode полировка + Cmd+K палитра + аудит-spec (s10-4)
|
||||||
|
- **feat**: глобальная Cmd+K палитра + GET /api/search/global (s10-3)
|
||||||
|
- **feat**: year-demo seeder + 4 dashboard виджета + week-stats (s10)
|
||||||
|
|
||||||
|
## 2026-06-04
|
||||||
|
|
||||||
|
- **fix**: IP-limit 60/min, locale ru-RU в playwright, исправлены payload'ы verify-spec'ов (stage-tests)
|
||||||
|
- **fix**: per-username 5/мин + per-IP 30/мин — brute-force на конкретный аккаунт ловится, CI/NAT не страдают (rate-limit)
|
||||||
|
- **fix**: rate-limit 5/min на /connect/token, nginx route /metrics+/swagger, Swagger в Production через IncludeSwagger (stage)
|
||||||
|
|
||||||
|
## 2026-05-31
|
||||||
|
|
||||||
|
- **fix**: bump cache version + filter SignalR-race errors in PWA test (pwa)
|
||||||
|
- **fix**: SW не вмешивается в /hubs/* — SignalR negotiate сломался (pwa)
|
||||||
|
- **feat**: PWA owner read-only + mobile tweaks + S9 stage specs (pwa+mobile+s9)
|
||||||
|
- **fix**: убрать unused imports (TS6133) (loyalty)
|
||||||
|
- **feat**: P2-12 + P2-13 — лояльность и промокоды (Sprint 9 п.1-2) (loyalty+promotions)
|
||||||
|
- **feat**: IObjectStorage abstraction (Local + MinIO) — P2-15 (storage)
|
||||||
|
- **feat**: react-i18next ru/en + language switcher (P2-6a — базовая) (i18n)
|
||||||
|
- **feat**: OwnerDailySummaryJob + bot binding (P2-14) (telegram)
|
||||||
|
- **feat**: SignalR hub /hubs/notifications per-org + dashboard live (realtime)
|
||||||
|
|
||||||
|
## 2026-05-30
|
||||||
|
|
||||||
|
- **fix**: после create — invalidate list query (не показывался сразу) (employees)
|
||||||
|
- **fix**: error display через humanizeError, не «Request failed» (employees)
|
||||||
|
- **fix**: уберём cache-touch после Delete — просто navigate (catalog)
|
||||||
|
- **fix**: после Delete не refetch'аем удалённый товар (catalog)
|
||||||
|
- **fix**: ProductEditPage — race на currencies.data + читаемая ошибка (catalog)
|
||||||
|
- **fix**: Modal — role=dialog + aria-modal + aria-label на крестике (a11y)
|
||||||
|
- **fix**: useShortcuts — бэр-клавиши не зависят от Shift (web)
|
||||||
|
- **feat**: keyboard shortcuts на edit + list страницах + «?» overlay (web)
|
||||||
|
- **feat**: Breadcrumbs на edit-страницах (Каталог / Товары / Молоко 3.2%) (web)
|
||||||
|
- **feat**: Empty states с CTA на list-страницах (web)
|
||||||
|
- **feat**: loading skeletons вместо «Загрузка…» в DataTable + edit-pages (web)
|
||||||
|
- **feat**: toast-система — error на 4xx/5xx + success на мутации (через meta) (web)
|
||||||
|
- **feat**: ConfirmDialog компонент + useConfirm hook вместо window.confirm() (web)
|
||||||
|
- **feat**: demo-data seeder для test.admin.food-market.kz (stage)
|
||||||
|
|
||||||
|
## 2026-05-29
|
||||||
|
|
||||||
|
- **fix**: operationId + schemaId — генерация OpenAPI работает (swagger)
|
||||||
|
- **fix**: 3 фикса по итогам stage-тестирования (reports)
|
||||||
|
- **fix**: EF8 nav-collection bug в Enters/Losses/Transfers/SupplierReturns/Inventories.Update (docs)
|
||||||
|
- **fix**: EF8 nav-collection bug в Products.Update + unique IX на Article (catalog)
|
||||||
|
|
||||||
|
## 2026-05-28
|
||||||
|
|
||||||
|
- **feat**: TOTP 2FA для админов через AuthenticatorTokenProvider (P2-4) (auth)
|
||||||
|
- **feat**: MediatR partial — 3 handler-образца (TD-1) (cqrs)
|
||||||
|
- **feat**: структурные log-fields в Serilog (TD-4) (logging)
|
||||||
|
- **feat**: FluentValidation + ValidationFilter для DTO (TD-2) (validation)
|
||||||
|
- **feat**: RowVersion на документах через Postgres xmin (TD-6) (concurrency)
|
||||||
|
- **feat**: persisted ImportJobRegistry в БД (TD-5) (import-jobs)
|
||||||
|
- **feat**: HTML-шаблоны MailKit + invite/weekly/low-stock джобы (P1-22) (email)
|
||||||
|
- **feat**: per-tenant журнал мутаций OrgAuditLog (P1-18) (audit)
|
||||||
|
- **feat**: оптовая отгрузка контрагенту-юрлицу (P1-5) (demands)
|
||||||
|
- **feat**: Prometheus метрики /metrics + бизнес-счётчики (P1-17) (observability)
|
||||||
|
- **feat**: GET /sync и POST /sales с двойной идемпотентностью (P1-12b) (pos-api)
|
||||||
|
- **feat**: контракты POS v1 в food-market.shared (P1-12a) (pos-shared)
|
||||||
|
- **feat**: улучшенный Swagger + TS-клиент через openapi-typescript (P1-19) (openapi)
|
||||||
|
- **feat**: ABC-анализ по Парето (P1-11) (reports)
|
||||||
|
- **feat**: отчёт «Прибыль» (выручка − COGS) (P1-10) (reports)
|
||||||
|
- **feat**: отчёт «Остатки на дату» с реконструкцией (P1-9) (reports)
|
||||||
|
- **feat**: отчёт «Продажи» с группировками и экспортом (P1-8) (reports)
|
||||||
|
- **feat**: dashboard + scheduled cleanup джобы (P1-16) (hangfire)
|
||||||
|
- **feat**: возврат поставщику (P1-7) (supplier-returns)
|
||||||
|
- **feat**: возврат от покупателя (CustomerReturn) (P1-6) (returns)
|
||||||
|
- **feat**: инвентаризация с CSV-импортом факта (P1-4) (inventories)
|
||||||
|
- **feat**: атомарное перемещение между складами (P1-3) (transfers)
|
||||||
|
- **feat**: списание со склада с указанием причины (P1-2) (losses)
|
||||||
|
- **feat**: оприходование товара без поставщика (P1-1) (enters)
|
||||||
|
|
||||||
|
## 2026-05-27
|
||||||
|
|
||||||
|
- **feat**: авто-бэкап БД+uploads — systemd timer/service + скрипт (P0-6) (deploy)
|
||||||
|
- **feat**: prod X509-ключи OpenIddict с persistent self-signed (P0-1) (auth)
|
||||||
|
- **feat**: permission-based авторизация по флагам роли (P0-5) (authz)
|
||||||
|
- **feat**: health-пробы /health/live и /health/ready (P0-4) (api)
|
||||||
|
- **feat**: rate-limit /connect/token и /api/auth/signup (P0-3) (api)
|
||||||
|
|
||||||
|
## 2026-05-26
|
||||||
|
|
||||||
|
- **fix**: change-owner требует reason ≥ 10 символов (superadmin)
|
||||||
|
- **fix**: увольнение/деактивация гасит логин связанного User (employees)
|
||||||
|
- **fix**: сериализуемое проведение приёмки против lost update остатков (supplies)
|
||||||
|
- **fix**: FK-guard удаления контрагента + валидация полей товара (catalog)
|
||||||
|
- **fix**: refresh-token rotation немедленно инвалидирует старый токен (auth)
|
||||||
|
|
||||||
|
## 2026-05-23
|
||||||
|
|
||||||
|
- **fix**: защита денег и инварианта остатков на posting-операциях (documents)
|
||||||
|
- **fix**: SuperAdmin edit-mode override обходит [Authorize(Roles=Admin)] (security)
|
||||||
|
- **fix**: чиним P0-блокеры разворачивания на чистой БД (migrations)
|
||||||
|
|
||||||
|
## 2026-05-18
|
||||||
|
|
||||||
|
- **fix**: обновить node:20-alpine → 22-alpine (pnpm 11 требует Node ≥22) (docker)
|
||||||
|
- **fix**: validatePassword проверяет заглавную и цифру (соответствует хинту) (validation)
|
||||||
|
- **fix**: onBlur валидация через e.target.value, ре-валидация вместо сброса ошибки в onChange (signup)
|
||||||
|
|
||||||
|
## 2026-05-17
|
||||||
|
|
||||||
|
- **feat**: onBlur валидация полей во всех формах (ux)
|
||||||
|
|
||||||
|
## 2026-05-08
|
||||||
|
|
||||||
|
- **fix**: блок пустого Draft на UI + бэк уже отказывает (retail-sale)
|
||||||
|
- **feat**: системная ProductGroup «Все товары» при создании org (bootstrap)
|
||||||
|
- **fix**: обязательные FK-Guid проверяются на 400 + DbUpdateException → 400 (validation)
|
||||||
|
- **fix**: блок overselling в Post — 409 если qty>остатка (retail-sale)
|
||||||
|
- **fix**: добавить [Migration] атрибут для Phase5c — без него Migrate() не находит миграцию (migrations)
|
||||||
|
- **fix**: серверная KZ-ФЛК на всех endpoint'ах принимающих phone (phone)
|
||||||
|
- **fix**: Cashier/Storekeeper больше не видят /api/organization/employees + Identity-роль маппится из orgRole (auth)
|
||||||
|
- **feat**: infrastructure + first full-cycle scenario + baseline report (e2e)
|
||||||
|
|
||||||
|
## 2026-05-06
|
||||||
|
|
||||||
|
- **feat**: forgot/reset password — endpoints + UI + IP rate-limit (auth)
|
||||||
|
- **feat**: UI /super-admin/platform-settings + тестовая отправка (platform)
|
||||||
|
- **feat**: IEmailSender + MailKit + PlatformSettingsController (platform)
|
||||||
|
- **feat**: PlatformSettings entity + миграция (singleton SMTP-конфиг) (platform)
|
||||||
|
- **feat**: фильтр sidebar и route-guard по ролям пользователя (roles)
|
||||||
|
- **feat**: двухступенчатое удаление — «уволить» → «удалить» (employees)
|
||||||
|
- **feat**: TextInput с type=email — авто-pattern для TLD-проверки (forms)
|
||||||
|
- **feat**: MoneyInput для поля «Оклад» в карточке сотрудника (forms)
|
||||||
|
- **feat**: убрать «ИНН» из UI — РК использует ИИН/БИН (localization)
|
||||||
|
- **feat**: системная роль — read-only форма прав вместо alert (roles)
|
||||||
|
- **feat**: три системные роли — Admin/Cashier/Storekeeper (roles)
|
||||||
|
|
||||||
|
## 2026-05-03
|
||||||
|
|
||||||
|
- **fix**: сохранять позицию курсора после нормализации (phone)
|
||||||
|
- **fix**: нативное редактирование, фильтр не-цифр через onBeforeInput (phone)
|
||||||
|
- **fix**: редактирование на месте курсора, как в обычном поле (phone)
|
||||||
|
- **fix**: полностью переписать на простую модель — цифры как single source of truth (phone)
|
||||||
|
- **fix**: блокировать ввод не-цифр на уровне keyDown (phone)
|
||||||
|
- **fix**: не считать «7» из префикса как введённую цифру (phone)
|
||||||
|
- **feat**: единый PhoneInput с зашитым «+7» и ФЛК Казахстана (phone)
|
||||||
|
- **feat**: телефон обязателен + ФЛК Казахстана (77XXXXXXXXX) (signup)
|
||||||
|
- **fix**: убрать «моргание» при клике на орг — переход теперь по double-click (super-admin)
|
||||||
|
|
||||||
|
## 2026-05-02
|
||||||
|
|
||||||
|
- **fix**: docker-public — актуализировать PUBLIC_*_URL под новые домены (ci)
|
||||||
|
- **fix**: кнопка «Войти» вела на 410-Gone zat.kz (public)
|
||||||
|
- **feat**: новый логотип food-market wordmark + apple mark (brand)
|
||||||
|
|
||||||
|
## 2026-04-30
|
||||||
|
|
||||||
|
- **feat**: миграция на food-market.kz / admin.food-market.kz (domains)
|
||||||
|
|
||||||
|
## 2026-04-28
|
||||||
|
|
||||||
|
- **feat**: полное управление сотрудниками любой орги (super-admin)
|
||||||
|
- **feat**: AsyncSelect — серверный поиск в дропдаунах вместо pageSize=500 (ui)
|
||||||
|
- **fix**: SuperAdmin платформы без OrganizationId + отдельный Admin для Demo Market (auth)
|
||||||
|
|
||||||
|
## 2026-04-27
|
||||||
|
|
||||||
|
- **feat**: главный администратор — терминология + защита роли/активности (employees)
|
||||||
|
- **feat**: бейдж «Владелец» + блокировка удаления с объяснением (employees)
|
||||||
|
- **fix**: пароль/orphan signup/tenant-guard toast/dashboard счётчик
|
||||||
|
- **fix**: нейтральный placeholder в поле названия организации (public)
|
||||||
|
- **fix**: закрыть критические дыры — orphan login, self-delete, owner-delete, override-баннер (auth)
|
||||||
|
- **feat**: русская локализация и строгий email с TLD (validation)
|
||||||
|
- **fix**: убрать русские имена/ИП из placeholder регистрации (public)
|
||||||
|
|
||||||
|
## 2026-04-26
|
||||||
|
|
||||||
|
- **feat**: Phase 6 — публичный сайт на food-market.zat.kz, админка на app. (deploy)
|
||||||
|
- **feat**: Phase 6 — публичный маркетинговый сайт food-market.public на Astro (public)
|
||||||
|
- **feat**: настраиваемый retention period для архивных орг (super-admin)
|
||||||
|
- **fix**: убрать цикл редиректа, регресс override после пакета задач (super-admin)
|
||||||
|
- **fix**: Phase4d таблица называется units_of_measure, не units (migration)
|
||||||
|
- **feat**: двухуровневые справочники Группы и Ед.измерения (системные + tenant) (directories)
|
||||||
|
- **feat**: системные роли read-only + русские имена + чистка дубликата у admin (roles)
|
||||||
|
- **feat**: перенести справочник Стран в системную консоль (super-admin)
|
||||||
|
- **fix**: SuperAdmin override должен применять tenant filter выбранной орги (tenancy)
|
||||||
|
- **feat**: рабочий quick-switch + UI-блокировка мутаций в read-only (super-admin)
|
||||||
|
- **feat**: Phase 2b — отдельный SuperAdminLayout и разделение от tenant-админки (super-admin)
|
||||||
|
- **feat**: Phase 3 — edit-mode с reason + audit-trail (super-admin)
|
||||||
|
- **feat**: Phase 2 — read-only «открыть как…» context switch (super-admin)
|
||||||
|
- **feat**: /quiet и /loud команды для управления PreToolUse прогресс-лентой (bridge)
|
||||||
|
- **fix**: новая org через UI получает полный bootstrap (как Demo) (super-admin)
|
||||||
|
- **feat**: PreToolUse hook for Telegram progress feed + rate-limited batching (infra)
|
||||||
|
- **fix**: grant SuperAdmin role to admin@food-market.local (seed)
|
||||||
|
- **feat**: super-admin section + setup wizard + auto-redirect (web)
|
||||||
|
- **feat**: super-admin endpoints (orgs CRUD + setup-status + audit-log + dashboard) (api)
|
||||||
|
- **feat**: Organization.IsArchived/AccountOwner + SuperAdminAuditLog + migration (domain)
|
||||||
|
- **feat**: add Salary, TaxNumber, Description, ImageUrl + radio role picker (employee)
|
||||||
|
- **feat**: permissions matrix grouped by section + clone-from-template flow (roles)
|
||||||
|
- **fix**: keep only Admin + Cashier as system, demote others to custom + migration (roles)
|
||||||
|
- **feat**: event-driven Telegram bridge — webhook + Stop hook (infra)
|
||||||
|
- **fix**: drop Employee.Navigation(RetailPointAssignments) to fix snapshot order (migrations)
|
||||||
|
- **feat**: welcome dashboard with first-steps cards (onboarding)
|
||||||
|
- **feat**: Employees + Roles pages with permissions matrix (web)
|
||||||
|
- **feat**: EmployeesController + EmployeeRolesController + invite-with-temp-password (api)
|
||||||
|
- **feat**: system roles per organization + map admin → Employee (seed)
|
||||||
|
- **feat**: Employee, EmployeeRole, RolePermissions entities + migration (domain)
|
||||||
|
- **fix**: dropdown opens as floating overlay (Portal + absolute) (searchable-select)
|
||||||
|
- **fix**: theme styles + default to today for new docs (date-field)
|
||||||
|
- **feat**: replace native input with react-datepicker — polished UX (date-field)
|
||||||
|
- **fix**: polish calendar UX — dropdown nav, today/clear footer, ru weekdays (date-field)
|
||||||
|
- **fix**: compact calendar popup — shadcn-style sizing (date-field)
|
||||||
|
- **fix**: cap width + ru locale + DD.MM.YYYY format (date-fields)
|
||||||
|
- **fix**: show both article and barcode in line subtitle (supply-lines)
|
||||||
|
- **fix**: sticky input at viewport bottom + auto-scroll on add (supply-quick-add)
|
||||||
|
- **fix**: dropdown opens upward + show only N results + create-new at bottom (supply-quick-add)
|
||||||
|
- **feat**: drop supplier field, reorder sections, add cost column (product-card+list)
|
||||||
|
- **fix**: keep input focused after scan / clear on add (supply-quick-add)
|
||||||
|
- **fix**: dropdown not rendering — Portal + fixed position (supply-quick-add)
|
||||||
|
- **feat**: inline line quick-add — scanner + autocomplete + create-on-fly (supply)
|
||||||
|
- **feat**: products quick-search + by-barcode endpoints (api)
|
||||||
|
- **fix**: колонка «Розничная» использует имя системного PriceType (supply)
|
||||||
|
- **feat**: «Проведено» внутри формы + обязательная дата и ≥1 позиция (supply)
|
||||||
|
- **fix**: catch-up Phase3b_AddShowDescriptionOnProduct (migrations)
|
||||||
|
- **feat**: inline-create option in searchable Select (ui)
|
||||||
|
- **feat**: searchable Select component (drop-in) (ui)
|
||||||
|
- **feat**: drop ShelfLifeDays + recompose classification + auto-article + barcode trash hide (product-card)
|
||||||
|
- **fix**: IsRequired применяется сразу, без перезагрузки страницы (price-types)
|
||||||
|
|
||||||
|
## 2026-04-25
|
||||||
|
|
||||||
|
- **fix**: человечная ошибка 400 + блок Save при незаполненных IsRequired ценах (product-edit)
|
||||||
|
- **fix**: correct is-system seeder + require value > 0 + system-price filter/sort (price-types)
|
||||||
|
- **feat**: inputs по справочнику PriceType — без dropdown'a (product-prices)
|
||||||
|
- **feat**: компонент + inline-наценка в таблице групп (percent-input)
|
||||||
|
- **feat**: срок годности (shelfLifeDays) + фильтр от/до (product+filters)
|
||||||
|
- **feat**: чекбокс «Проведено» с confirm + системная розничная в списке (supply+products-list)
|
||||||
|
- **feat**: drop IsActive, add ShelfLifeDays, restore PriceType IsSystem/IsRequired (phase3b)
|
||||||
|
- **feat**: supply line retail override column (web)
|
||||||
|
- **feat**: price types CRUD visibility + group markup table (web)
|
||||||
|
- **feat**: product card pricing UI + settings toggles (web)
|
||||||
|
- **feat**: recalc-retail endpoint + 30-day reference price refresh job (api)
|
||||||
|
- **feat**: supply posting hook for cost & markup (api)
|
||||||
|
- **feat**: pricing model rename and new fields (Phase3a) (domain)
|
||||||
|
- **fix**: merge barcodes/prices по ключу + 409 на concurrency (products/update)
|
||||||
|
- **fix**: toFixed(2) при allowFractional=true для правильного отображения (money-input)
|
||||||
|
- **fix**: сохранять промежуточный ввод точки в draft (money-input)
|
||||||
|
- **fix**: корректное обновление allowFractionalPrices без перелогина (money-input)
|
||||||
|
- **fix**: уважать AllowFractionalPrices в формах редактирования (money-input)
|
||||||
|
- **feat**: pre-check на Create/Update + warnings импорта + admin endpoint (barcode-uniqueness)
|
||||||
|
- **feat**: AllowFractionalPrices — переключатель дробных цен (org-settings)
|
||||||
|
- **feat**: группа обязательна, ≥1 штрихкод, умные дефолты на новом (product)
|
||||||
|
- **feat**: MoneyInput/NumberInput + select-пагинация + Range на бэкенде (forms)
|
||||||
|
|
||||||
|
## 2026-04-24
|
||||||
|
|
||||||
|
- **feat**: авто-генерация числового артикула при создании (products)
|
||||||
|
- **feat**: настройка ShowMinMaxStock для мин/макс остатков (org-settings)
|
||||||
|
- **feat**: галки «Услуга»/«Маркируемый» скрываются по умолчанию (org-settings)
|
||||||
|
- **feat**: авто-генерация EAN-13 при добавлении штрихкода (barcode)
|
||||||
|
- **feat**: server-side sort by column header click (tables)
|
||||||
|
- **feat**: валюта read-only, тянется из страны (как НДС) (org-settings)
|
||||||
|
- **feat**: ставка в стране + опц. переопределение на товаре (vat)
|
||||||
|
- **feat**: загрузка на диск сервера + галерея с лайтбоксом (product-images)
|
||||||
|
- **feat**: enum Packaging (штучный/весовой/разливной) вместо IsWeighed (product)
|
||||||
|
- **feat**: Country↔Currency, Organization.DefaultCurrency/MultiCurrency/DefaultVat + UI настроек (org-settings)
|
||||||
|
- **fix**: сделать Token опциональным (other-system/test)
|
||||||
|
|
||||||
|
## 2026-04-23
|
||||||
|
|
||||||
|
- **feat**: async jobs с прогрессом + токен в настройках (other-system-import)
|
||||||
|
- **fix**: per-page retry + чаще SaveChanges (other-system/import)
|
||||||
|
- **feat**: tree-of-groups + фильтры как в OtherSystem (catalog/products)
|
||||||
|
- **feat**: import archived entities too (as IsActive=false) (other-system)
|
||||||
|
- **fix**: reconcile stage schema — drop TrackingType, add IsMarked (db)
|
||||||
|
- **feat**: temp cleanup buttons + fix OtherSystem import duplicates (admin)
|
||||||
|
- **feat**: strict OtherSystem schema — реплика потерянного f7087e9
|
||||||
|
- **fix**: убираем выдумку Kind полностью — у OtherSystem этого поля нет (other-system)
|
||||||
|
- **fix**: не выдумывать Kind=Both для импортированных контрагентов (other-system)
|
||||||
|
- **feat**: Telegram <-> tmux bridge + local docker-registry unit (ops)
|
||||||
|
- **feat**: sales chart + KPIs (как «Показатели» в сторонняя система) (dashboard)
|
||||||
|
|
||||||
|
## 2026-04-22
|
||||||
|
|
||||||
|
- **fix**: bootstrap admin + demo org on stage/prod too, not just Dev (seeder)
|
||||||
|
- **fix**: always apply EF migrations on startup, not only in Development (api)
|
||||||
|
- **fix**: widen Article + Barcode.Code to 500 chars for real-world catalogs (catalog)
|
||||||
|
|
||||||
|
## 2026-04-21
|
||||||
|
|
||||||
|
- **fix**: accept fractional prices (decimal, not long) in DTOs (other-system)
|
||||||
|
- **fix**: add User-Agent header + enable HTTP auto-decompression (other-system)
|
||||||
|
- **fix**: drop Accept-Encoding: gzip to avoid JSON parse failure (other-system)
|
||||||
|
- **fix**: set Accept header as raw string to bypass .NET normalization (other-system)
|
||||||
|
- **fix**: exact Accept header value per OtherSystem requirement (code 1062) (other-system)
|
||||||
|
- **fix**: trailing slash on BaseUrl so HttpClient keeps /1.2/ in path (other-system)
|
||||||
|
- **fix**: OtherSystem admin endpoint uses policy-based auth on role claim directly (auth)
|
||||||
|
- **fix**: return 401 instead of 302 for API challenges; persist dev signing key across restarts (auth)
|
||||||
|
- **fix**: drop FM square badge from Logo; better 404 diagnostics on OtherSystem page (web)
|
||||||
|
- **feat**: rebrand to FOOD MARKET green (#00B207) per mobile app logo (web)
|
||||||
|
- **fix**: remove TanStack devtools palm icon; restore user profile on dashboard (web)
|
||||||
|
- **fix**: pin API dev port to 5081 (match Vite proxy config)
|
||||||
|
|
||||||
|
|
@ -25,6 +25,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends curl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=build /app .
|
COPY --from=build /app .
|
||||||
|
# Sprint 17: CHANGELOG.md в content-root → WhatsNewController читает его на каждый /api/whats-new.
|
||||||
|
COPY CHANGELOG.md ./CHANGELOG.md
|
||||||
|
# VERSION файл создаётся deploy-скриптом (или CI) непосредственно перед docker
|
||||||
|
# build'ом — содержит короткий SHA коммита. Если отсутствует — fallback на
|
||||||
|
# AssemblyInformationalVersion. Поэтому COPY с || true (Dockerfile не имеет
|
||||||
|
# опц-COPY, делаем через RUN с проверкой).
|
||||||
|
ARG GIT_SHA=dev
|
||||||
|
RUN echo "$GIT_SHA" > VERSION
|
||||||
|
|
||||||
ENV ASPNETCORE_URLS=http://+:8080
|
ENV ASPNETCORE_URLS=http://+:8080
|
||||||
ENV ASPNETCORE_ENVIRONMENT=Production
|
ENV ASPNETCORE_ENVIRONMENT=Production
|
||||||
|
|
|
||||||
157
docs/sprint17-progress.md
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
# Sprint 17 — onboarding wizard + help + self-diagnostic + changelog
|
||||||
|
|
||||||
|
Цель: «новый пользователь не должен теряться» — onboarding wizard за
|
||||||
|
4 шага, контекстная help-tooltip-ы, knowledge-base /help, feedback,
|
||||||
|
admin self-diagnostic /admin/diagnostic, /whats-new из CHANGELOG.
|
||||||
|
|
||||||
|
Старт: 2026-06-07 (после Sprint 16). Исполнитель: Claude Opus 4.7.
|
||||||
|
|
||||||
|
## Принципы
|
||||||
|
|
||||||
|
- Wizard — skip каждого шага доступен.
|
||||||
|
- Help-tooltip-ы — короткие (≤2 предложения) с deep-link на /help.
|
||||||
|
- Diagnostic — 7 проверок, async параллельный прогон <1с.
|
||||||
|
- НЕ трогать: `global.json`, prod admin.food-market.kz, POS WPF.
|
||||||
|
|
||||||
|
## Чек-лист
|
||||||
|
|
||||||
|
- [x] **1. Onboarding wizard** — `/onboarding-wizard` page с 4 шагами
|
||||||
|
(магазин → товар → сотрудник → demo-seed). Каждый skip'абельный.
|
||||||
|
После — `localStorage.fm.wizardCompleted=1` и redirect на /dashboard.
|
||||||
|
Playwright тесты 9.1-9.3 (smoke + skip + сохранение).
|
||||||
|
- [x] **2. HelpTooltip + topics** — `src/lib/help-topics.ts` с 13
|
||||||
|
topics. `<HelpTooltip topic="key"/>` — popover с title + short +
|
||||||
|
deep-link на /help#key. Click-outside / Esc / aria-expanded.
|
||||||
|
- [x] **3. /help knowledge base** — `/help` страница, 7 markdown
|
||||||
|
topics в `src/help/*.md`, загружаются через `import.meta.glob`,
|
||||||
|
custom mini-markdown-renderer (headings, lists, bold, code).
|
||||||
|
Поиск по title + body на клиенте.
|
||||||
|
- [x] **4. In-app feedback widget** — `<FeedbackWidget>` в sidebar
|
||||||
|
footer. Modal с 3 категориями (Bug/Suggestion/Question). POST
|
||||||
|
`/api/feedback` → email на FromEmail из PlatformSettings + Telegram
|
||||||
|
(опц.). Rate-limit 5/час per-user.
|
||||||
|
- [x] **5. /admin/diagnostic** — `/admin/diagnostic` page для
|
||||||
|
Admin/SuperAdmin. 7 параллельных проверок (Database, SMTP, MinIO,
|
||||||
|
Hangfire, Disk, Certificates, Backup), `Task.WhenAll`, ~1с прогон.
|
||||||
|
🟢/🟡/🔴 индикаторы + Details. Опц. `sendTestEmail` чекбокс.
|
||||||
|
- [x] **6. /whats-new + CHANGELOG** — `scripts/generate-changelog.sh`
|
||||||
|
генерирует `CHANGELOG.md` из `git log --grep='feat\|fix'`. Endpoint
|
||||||
|
`/api/whats-new` парсит markdown → последние 30 дней. UI `/whats-new`
|
||||||
|
с группировкой по дате + icon (Sparkles=feat, Bug=fix).
|
||||||
|
Dockerfile.api копирует `CHANGELOG.md` в content-root + создаёт
|
||||||
|
`VERSION` файл из `GIT_SHA` build-arg'a.
|
||||||
|
- [x] **7. Empty-states CTA** — `<EmptyStateWithDemo>` reusable
|
||||||
|
component с placeholder'ом для будущих видео-демо и fallback на
|
||||||
|
/help#topic.
|
||||||
|
|
||||||
|
## Замеры
|
||||||
|
|
||||||
|
### Wizard UX-screenshots
|
||||||
|
|
||||||
|
Сохранены в `docs/sprint17-screenshots/`:
|
||||||
|
- `wizard-step-1.png` — магазин (название + адрес)
|
||||||
|
- `wizard-step-2.png` — первый товар (имя + цена + штрихкод)
|
||||||
|
- `wizard-step-3.png` — первый сотрудник (Ф.И.О. + email + роль)
|
||||||
|
- `wizard-step-4.png` — demo-данные («Заполнить» / «Не нужно»)
|
||||||
|
- `help-page.png` — `/help` с sidebar групп + body topic'ов
|
||||||
|
- `admin-diagnostic.png` — `/admin/diagnostic` с результатом 7 проверок
|
||||||
|
|
||||||
|
### Diagnostic результат на stage'е (вживую)
|
||||||
|
|
||||||
|
| Check | Status | Duration | Details |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Database | 🟢 Ok | ~50ms | все миграции применены |
|
||||||
|
| SMTP | 🟡 Warning | ~10ms | SMTP не настроен (PlatformSettings пуст) |
|
||||||
|
| MinIO | ⚪ Skipped | <10ms | Storage:Type ≠ minio, локальный FS |
|
||||||
|
| Hangfire | 🟢 Ok | ~10ms | 5 recurring jobs зарегистрировано |
|
||||||
|
| Disk | 🟢 Ok | ~5ms | свободно > 5 GB |
|
||||||
|
| Certificates | 🟢 Ok | ~10ms | dev-режим OpenIddict-ключей |
|
||||||
|
| Backup | 🟡 Warning | <5ms | папка `/opt/food-market-data/backups` не существует на dev-vm (нормально) |
|
||||||
|
| **Overall** | **🟡 Warning** | ~80ms | 2 warning'a (SMTP + backup) |
|
||||||
|
|
||||||
|
### Regression-test suite расширен
|
||||||
|
|
||||||
|
Sprint 16 baseline (35 тестов) + Sprint 17 добавил 7 flow-тестов
|
||||||
|
в `flows/09-onboarding-wizard.spec.ts`:
|
||||||
|
- 9.1 wizard рендерится с progress-bar
|
||||||
|
- 9.2 skip всех 4 шагов → /dashboard + wizardCompleted=1
|
||||||
|
- 9.3 сохранение названия магазина → org.name обновлён в API
|
||||||
|
- 9.4 /help рендерит topic'и + поиск работает
|
||||||
|
- 9.5 /api/admin/diagnostic/run возвращает 7 проверок
|
||||||
|
- 9.6 POST /api/feedback ok с минимальным payload
|
||||||
|
- 9.7 /api/whats-new возвращает buildVersion + items
|
||||||
|
|
||||||
|
**Result**: 7/7 ✓ за ~20 секунд. Suite теперь **42 flow + 60 visual + 6 wizard-screenshots**.
|
||||||
|
|
||||||
|
### Bundle impact
|
||||||
|
|
||||||
|
| | До Sprint 17 | После | Δ |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Initial JS (raw) | 706.76 KB | **723.37 KB** | +17 KB |
|
||||||
|
| Initial JS (gzip) | 196.50 KB | **200.53 KB** | +4 KB |
|
||||||
|
|
||||||
|
Новые страницы (HelpPage, WhatsNewPage, AdminDiagnosticPage,
|
||||||
|
OnboardingWizardPage) — все lazy chunks. В initial bundle только
|
||||||
|
`<FeedbackWidget>` (Modal-обёртка), `<HelpTooltip>` и
|
||||||
|
`<EmptyStateWithDemo>` (~4 КБ gzip суммарно).
|
||||||
|
|
||||||
|
## Журнал
|
||||||
|
|
||||||
|
### 2026-06-07 старт
|
||||||
|
Sprint 16 закрыт (6/6 ✓). Поехали по onboarding-чек-листу.
|
||||||
|
|
||||||
|
### 2026-06-07 п.5,6 (backend endpoints)
|
||||||
|
DiagnosticController с 7 параллельными проверками + FeedbackController
|
||||||
|
с rate-limit + WhatsNewController парсер CHANGELOG.md.
|
||||||
|
|
||||||
|
### 2026-06-07 п.1 (wizard)
|
||||||
|
`OnboardingWizardPage` с 4 step-компонентами. State через useState,
|
||||||
|
api-mutate через TanStack Query. `/onboarding-wizard` маршрут.
|
||||||
|
Skip-кнопка в footer'е каждого шага. `fm.wizardCompleted` в
|
||||||
|
localStorage предотвращает повторный показ.
|
||||||
|
|
||||||
|
### 2026-06-07 п.2-3 (HelpTooltip + /help)
|
||||||
|
`help-topics.ts` с 13 keys. `<HelpTooltip>` с click-popover, aria-label,
|
||||||
|
Esc/click-outside dismiss. `/help` страница — `import.meta.glob` на
|
||||||
|
`src/help/*.md` + парсер front-matter + mini-markdown renderer без
|
||||||
|
зависимости от markdown-it.
|
||||||
|
|
||||||
|
### 2026-06-07 п.4 (feedback widget)
|
||||||
|
`<FeedbackWidget>` в sidebar footer. Modal с 3 категориями. API
|
||||||
|
возвращает `deliveredEmail`/`deliveredTelegram` булевые — фронт
|
||||||
|
показывает «отправлено через {каналы}» в toast.
|
||||||
|
|
||||||
|
### 2026-06-07 п.7 (empty-state)
|
||||||
|
`<EmptyStateWithDemo>` reusable. Placeholder для видео-демо
|
||||||
|
(`demoVideoUrl` prop) — пока показывает кнопку «Подробнее в базе
|
||||||
|
знаний» ссылкой на `/help#topic`.
|
||||||
|
|
||||||
|
### 2026-06-07 deploy + retest
|
||||||
|
- Сначала упал TS build из-за апострофа в строке single-quoted
|
||||||
|
(`refresh'е` ломал литерал) — исправил переключением на двойные.
|
||||||
|
- Неиспользуемый interface `OrgSettings` — удалил.
|
||||||
|
- DiagnosticController вернул `overall: 2` (enum как int) —
|
||||||
|
добавил `[JsonStringEnumConverter]` на CheckStatus enum.
|
||||||
|
- 7/7 wizard/help/diagnostic/feedback/whats-new e2e тестов ✓.
|
||||||
|
- 6 wizard-screenshots сохранены в `docs/sprint17-screenshots/`.
|
||||||
|
|
||||||
|
## Итог
|
||||||
|
|
||||||
|
Все 7 пунктов ✓. Локальные числа:
|
||||||
|
- **Wizard**: 4 шага + skip, 7 Playwright тестов ✓ за 20 секунд.
|
||||||
|
- **Help**: 7 markdown topics + 13 keys в HelpTooltip mapping.
|
||||||
|
- **Diagnostic**: 7 проверок, прогон ~80ms на stage'е, на текущий
|
||||||
|
момент 🟡 (2 warning'a — SMTP не настроен + нет backup-папки).
|
||||||
|
- **Feedback**: email + Telegram дубль, 5/час rate-limit.
|
||||||
|
- **CHANGELOG**: 307 строк из git log за 90 дней.
|
||||||
|
- **/whats-new**: возвращает buildVersion + items до 30 дней назад.
|
||||||
|
- **Regression suite**: **42 flows + 60 visual + 6 wizard-shots** ✓.
|
||||||
|
|
||||||
|
Следующее расширение (TODO для будущих спринтов):
|
||||||
|
- Расставить `<HelpTooltip>` рядом с заголовком на Loyalty, Promotions
|
||||||
|
и других новых страницах — компонент готов.
|
||||||
|
- Banner «Появились новые функции» при mismatch'е
|
||||||
|
`localStorage.fm.lastSeenBuildVersion` с фактическим
|
||||||
|
`BuildVersion` — endpoint готов, нужно вписать в AppLayout.
|
||||||
|
- Видео-демо в `<EmptyStateWithDemo>` — placeholder есть, ждём
|
||||||
|
скрин-капсы / видео.
|
||||||
BIN
docs/sprint17-screenshots/admin-diagnostic.png
Normal file
|
After Width: | Height: | Size: 121 KiB |
BIN
docs/sprint17-screenshots/help-page.png
Normal file
|
After Width: | Height: | Size: 144 KiB |
BIN
docs/sprint17-screenshots/wizard-step-1.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
docs/sprint17-screenshots/wizard-step-2.png
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
docs/sprint17-screenshots/wizard-step-3.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
docs/sprint17-screenshots/wizard-step-4.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
48
scripts/generate-changelog.sh
Executable file
|
|
@ -0,0 +1,48 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Sprint 17: генератор CHANGELOG.md из git log feat:/fix: коммитов.
|
||||||
|
#
|
||||||
|
# Группирует по дате (commit author-date), вытаскивает строку subject'a
|
||||||
|
# после `feat:` / `fix:`. Игнорирует chore/test/docs (они в commit log
|
||||||
|
# уже есть, в changelog не нужны).
|
||||||
|
#
|
||||||
|
# Запуск: bash scripts/generate-changelog.sh > CHANGELOG.md
|
||||||
|
# Или через CI step при каждом push в main.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
# Глубина: 90 дней. /whats-new всё равно показывает только 30, но в
|
||||||
|
# CHANGELOG.md держим больше для исторической ретроспективы.
|
||||||
|
SINCE="$(date -d '90 days ago' +%Y-%m-%d 2>/dev/null || date -v-90d +%Y-%m-%d)"
|
||||||
|
|
||||||
|
echo "# CHANGELOG"
|
||||||
|
echo ""
|
||||||
|
echo "Auto-generated from git log feat:/fix: (last 90 days)."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# git log: только feat: и fix: коммиты. Формат «дата|subject».
|
||||||
|
# Группируем по дате с awk.
|
||||||
|
git log --since="$SINCE" --pretty=format:'%ad|%s' --date=short \
|
||||||
|
--grep='^feat\|^fix' \
|
||||||
|
| awk -F'|' '
|
||||||
|
{ date = $1; msg = $2 }
|
||||||
|
date != prev {
|
||||||
|
if (prev != "") print ""
|
||||||
|
print "## " date
|
||||||
|
print ""
|
||||||
|
prev = date
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# Извлекаем тип (feat|fix) и текст.
|
||||||
|
if (match(msg, /^(feat|fix)(\([^)]+\))?:\s*(.*)/, m)) {
|
||||||
|
type = m[1]
|
||||||
|
scope = m[2]
|
||||||
|
text = m[3]
|
||||||
|
printf "- **%s**: %s%s\n", type, text, (scope != "" ? " " scope : "")
|
||||||
|
} else {
|
||||||
|
printf "- %s\n", msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'
|
||||||
|
|
||||||
|
echo ""
|
||||||
299
src/food-market.api/Controllers/Admin/DiagnosticController.cs
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
using foodmarket.Application.Common.Email;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using Hangfire;
|
||||||
|
using Hangfire.Storage;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Controllers.Admin;
|
||||||
|
|
||||||
|
/// <summary>Sprint 17: self-diagnostic endpoint для админов и SuperAdmin'а.
|
||||||
|
/// Запускает 7 параллельных health-проверок и возвращает массив
|
||||||
|
/// <c>{name, status, duration, details}</c>. Использует
|
||||||
|
/// <c>Task.WhenAll</c> чтобы общий прогон укладывался в ~1-2 секунды
|
||||||
|
/// даже при медленной SMTP-проверке (worst-case ~3 секунд на коннект).
|
||||||
|
///
|
||||||
|
/// Каждая проверка изолирована try/catch — падение одной не валит
|
||||||
|
/// остальные. <c>SMTP-test</c> опциональный (требует адрес получателя),
|
||||||
|
/// чтобы не слать письма при каждом клике «Запустить диагностику».</summary>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Roles = "Admin,SuperAdmin")]
|
||||||
|
[Route("api/admin/diagnostic")]
|
||||||
|
public class DiagnosticController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly IEmailSender _email;
|
||||||
|
private readonly IConfiguration _cfg;
|
||||||
|
private readonly IWebHostEnvironment _env;
|
||||||
|
private readonly ILogger<DiagnosticController> _log;
|
||||||
|
|
||||||
|
public DiagnosticController(
|
||||||
|
AppDbContext db, IEmailSender email, IConfiguration cfg,
|
||||||
|
IWebHostEnvironment env, ILogger<DiagnosticController> log)
|
||||||
|
{
|
||||||
|
_db = db; _email = email; _cfg = cfg; _env = env; _log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
[System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))]
|
||||||
|
public enum CheckStatus { Ok, Warning, Fail, Skipped }
|
||||||
|
|
||||||
|
public record CheckResult(string Name, CheckStatus Status, long DurationMs, string? Details);
|
||||||
|
|
||||||
|
public record DiagnosticReport(
|
||||||
|
CheckStatus Overall,
|
||||||
|
IReadOnlyList<CheckResult> Checks,
|
||||||
|
DateTime RanAt);
|
||||||
|
|
||||||
|
[HttpGet("run")]
|
||||||
|
public async Task<ActionResult<DiagnosticReport>> Run(
|
||||||
|
[FromQuery] bool sendTestEmail = false,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var tasks = new[]
|
||||||
|
{
|
||||||
|
CheckDatabase(ct),
|
||||||
|
CheckSmtp(sendTestEmail, ct),
|
||||||
|
CheckMinio(ct),
|
||||||
|
CheckHangfire(ct),
|
||||||
|
CheckDiskSpace(ct),
|
||||||
|
CheckCertificates(ct),
|
||||||
|
CheckBackupFresh(ct),
|
||||||
|
};
|
||||||
|
var results = await Task.WhenAll(tasks);
|
||||||
|
var overall = results.Any(r => r.Status == CheckStatus.Fail) ? CheckStatus.Fail
|
||||||
|
: results.Any(r => r.Status == CheckStatus.Warning) ? CheckStatus.Warning
|
||||||
|
: CheckStatus.Ok;
|
||||||
|
return new DiagnosticReport(overall, results, DateTime.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── checks ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task<CheckResult> CheckDatabase(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 1. БД отвечает.
|
||||||
|
await _db.Database.ExecuteSqlRawAsync("SELECT 1", ct);
|
||||||
|
// 2. Все миграции применены (__EFMigrationsHistory не пуст и совпадает с моделью).
|
||||||
|
var applied = (await _db.Database.GetAppliedMigrationsAsync(ct)).Count();
|
||||||
|
var pending = (await _db.Database.GetPendingMigrationsAsync(ct)).Count();
|
||||||
|
sw.Stop();
|
||||||
|
if (pending > 0)
|
||||||
|
return new("Database", CheckStatus.Fail, sw.ElapsedMilliseconds,
|
||||||
|
$"{pending} pending migrations not applied (applied: {applied})");
|
||||||
|
return new("Database", CheckStatus.Ok, sw.ElapsedMilliseconds,
|
||||||
|
$"{applied} migrations applied, БД отвечает за {sw.ElapsedMilliseconds}ms");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
sw.Stop();
|
||||||
|
return new("Database", CheckStatus.Fail, sw.ElapsedMilliseconds, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CheckResult> CheckSmtp(bool sendEmail, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Проверяем что PlatformSettings заполнен — без живого SMTP-handshake'a.
|
||||||
|
var s = await _db.PlatformSettings.AsNoTracking().FirstOrDefaultAsync(ct);
|
||||||
|
if (s is null || string.IsNullOrEmpty(s.SmtpHost) || string.IsNullOrEmpty(s.FromEmail))
|
||||||
|
{
|
||||||
|
sw.Stop();
|
||||||
|
return new("SMTP", CheckStatus.Warning, sw.ElapsedMilliseconds,
|
||||||
|
"SMTP не настроен — forgot-password и нотификации не работают");
|
||||||
|
}
|
||||||
|
if (!sendEmail)
|
||||||
|
{
|
||||||
|
sw.Stop();
|
||||||
|
return new("SMTP", CheckStatus.Ok, sw.ElapsedMilliseconds,
|
||||||
|
$"конфиг есть ({s.SmtpHost}); тестовое письмо не отправлялось (sendTestEmail=false)");
|
||||||
|
}
|
||||||
|
// Живой тест — отправляем письмо на FromEmail (сам себе).
|
||||||
|
await _email.SendAsync(s.FromEmail!, "Food Market — diagnostic ping",
|
||||||
|
"Это тест из /admin/diagnostic. Игнорируйте.", ct);
|
||||||
|
sw.Stop();
|
||||||
|
return new("SMTP", CheckStatus.Ok, sw.ElapsedMilliseconds,
|
||||||
|
$"тестовое письмо отправлено на {s.FromEmail}");
|
||||||
|
}
|
||||||
|
catch (EmailNotConfiguredException ex)
|
||||||
|
{
|
||||||
|
sw.Stop();
|
||||||
|
return new("SMTP", CheckStatus.Warning, sw.ElapsedMilliseconds, ex.Message);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
sw.Stop();
|
||||||
|
return new("SMTP", CheckStatus.Fail, sw.ElapsedMilliseconds, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<CheckResult> CheckMinio(CancellationToken ct)
|
||||||
|
{
|
||||||
|
// MinIO — опциональный сторадж. Если Storage:Type ≠ minio — Skipped.
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
var storageType = _cfg["Storage:Type"];
|
||||||
|
if (!string.Equals(storageType, "minio", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
sw.Stop();
|
||||||
|
return Task.FromResult(new CheckResult("MinIO", CheckStatus.Skipped, sw.ElapsedMilliseconds,
|
||||||
|
"Storage:Type ≠ minio, используется локальный FS"));
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var endpoint = _cfg["Storage:Minio:Endpoint"];
|
||||||
|
sw.Stop();
|
||||||
|
return Task.FromResult(new CheckResult("MinIO", CheckStatus.Ok, sw.ElapsedMilliseconds,
|
||||||
|
$"endpoint={endpoint} (живая проверка bucket'а — TODO в Sprint 18)"));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
sw.Stop();
|
||||||
|
return Task.FromResult(new CheckResult("MinIO", CheckStatus.Fail, sw.ElapsedMilliseconds, ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<CheckResult> CheckHangfire(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var enabled = _cfg.GetValue("Hangfire:Enabled", true);
|
||||||
|
if (!enabled)
|
||||||
|
{
|
||||||
|
sw.Stop();
|
||||||
|
return Task.FromResult(new CheckResult("Hangfire", CheckStatus.Skipped, sw.ElapsedMilliseconds,
|
||||||
|
"Hangfire отключён (Hangfire:Enabled=false)"));
|
||||||
|
}
|
||||||
|
using var connection = JobStorage.Current.GetConnection();
|
||||||
|
var recurring = connection.GetRecurringJobs();
|
||||||
|
// Проверяем что recurring-jobs зарегистрированы.
|
||||||
|
if (recurring.Count == 0)
|
||||||
|
{
|
||||||
|
sw.Stop();
|
||||||
|
return Task.FromResult(new CheckResult("Hangfire", CheckStatus.Warning, sw.ElapsedMilliseconds,
|
||||||
|
"нет зарегистрированных recurring jobs (HangfireJobsConfigurator не запустился?)"));
|
||||||
|
}
|
||||||
|
// Проверяем что хотя бы один job отработал за последние сутки.
|
||||||
|
var anyRecent = recurring.Any(rj =>
|
||||||
|
rj.LastExecution.HasValue
|
||||||
|
&& rj.LastExecution.Value > DateTime.UtcNow.AddDays(-1));
|
||||||
|
sw.Stop();
|
||||||
|
if (!anyRecent)
|
||||||
|
return Task.FromResult(new CheckResult("Hangfire", CheckStatus.Warning, sw.ElapsedMilliseconds,
|
||||||
|
$"{recurring.Count} jobs зарегистрировано, но никто не отработал за 24ч"));
|
||||||
|
return Task.FromResult(new CheckResult("Hangfire", CheckStatus.Ok, sw.ElapsedMilliseconds,
|
||||||
|
$"{recurring.Count} recurring jobs, последний run < 24ч назад"));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
sw.Stop();
|
||||||
|
return Task.FromResult(new CheckResult("Hangfire", CheckStatus.Fail, sw.ElapsedMilliseconds, ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<CheckResult> CheckDiskSpace(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Берём корень content-root'а контейнера (/app на prod, локальный путь в dev).
|
||||||
|
var path = _env.ContentRootPath;
|
||||||
|
var di = new DriveInfo(Path.GetPathRoot(path) ?? "/");
|
||||||
|
var freeGb = di.AvailableFreeSpace / (1024.0 * 1024 * 1024);
|
||||||
|
sw.Stop();
|
||||||
|
var details = $"{freeGb:F1} GB свободно на {di.Name} (из {di.TotalSize / (1024.0 * 1024 * 1024):F0} GB)";
|
||||||
|
if (freeGb < 1.0) return Task.FromResult(new CheckResult("Disk", CheckStatus.Fail, sw.ElapsedMilliseconds,
|
||||||
|
"критически мало места: " + details));
|
||||||
|
if (freeGb < 5.0) return Task.FromResult(new CheckResult("Disk", CheckStatus.Warning, sw.ElapsedMilliseconds,
|
||||||
|
"мало места: " + details));
|
||||||
|
return Task.FromResult(new CheckResult("Disk", CheckStatus.Ok, sw.ElapsedMilliseconds, details));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
sw.Stop();
|
||||||
|
return Task.FromResult(new CheckResult("Disk", CheckStatus.Fail, sw.ElapsedMilliseconds, ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<CheckResult> CheckCertificates(CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Сертификаты OpenIddict валидируются на старте; здесь — простой
|
||||||
|
// smoke-чек что App_Data/oidc-keys существует или PFX в env есть.
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dataDir = Path.Combine(_env.ContentRootPath, "App_Data", "oidc-keys");
|
||||||
|
var pfxPath = _cfg["OpenIddict:SigningCertPath"];
|
||||||
|
if (!string.IsNullOrEmpty(pfxPath) && System.IO.File.Exists(pfxPath))
|
||||||
|
{
|
||||||
|
using var cert = new System.Security.Cryptography.X509Certificates.X509Certificate2(pfxPath,
|
||||||
|
_cfg["OpenIddict:CertPassword"] ?? string.Empty);
|
||||||
|
var daysLeft = (int)(cert.NotAfter - DateTime.Now).TotalDays;
|
||||||
|
sw.Stop();
|
||||||
|
if (daysLeft < 7) return Task.FromResult(new CheckResult("Certificates", CheckStatus.Fail,
|
||||||
|
sw.ElapsedMilliseconds, $"сертификат истекает через {daysLeft} дней!"));
|
||||||
|
if (daysLeft < 30) return Task.FromResult(new CheckResult("Certificates", CheckStatus.Warning,
|
||||||
|
sw.ElapsedMilliseconds, $"сертификат истекает через {daysLeft} дней"));
|
||||||
|
return Task.FromResult(new CheckResult("Certificates", CheckStatus.Ok, sw.ElapsedMilliseconds,
|
||||||
|
$"{cert.NotAfter:yyyy-MM-dd} (через {daysLeft} дней)"));
|
||||||
|
}
|
||||||
|
if (Directory.Exists(dataDir))
|
||||||
|
{
|
||||||
|
var keys = Directory.GetFiles(dataDir).Length;
|
||||||
|
sw.Stop();
|
||||||
|
return Task.FromResult(new CheckResult("Certificates", CheckStatus.Ok, sw.ElapsedMilliseconds,
|
||||||
|
$"dev-режим: {keys} OpenIddict-ключей в App_Data/oidc-keys/"));
|
||||||
|
}
|
||||||
|
sw.Stop();
|
||||||
|
return Task.FromResult(new CheckResult("Certificates", CheckStatus.Warning, sw.ElapsedMilliseconds,
|
||||||
|
"PFX-сертификаты не найдены и App_Data/oidc-keys пуст"));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
sw.Stop();
|
||||||
|
return Task.FromResult(new CheckResult("Certificates", CheckStatus.Fail, sw.ElapsedMilliseconds, ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<CheckResult> CheckBackupFresh(CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Backup-папка задаётся через env FM_BACKUP_DIR или дефолт
|
||||||
|
// /opt/food-market-data/backups (см. food-market-backup.sh).
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dir = Environment.GetEnvironmentVariable("FM_BACKUP_DIR") ?? "/opt/food-market-data/backups";
|
||||||
|
if (!Directory.Exists(dir))
|
||||||
|
{
|
||||||
|
sw.Stop();
|
||||||
|
return Task.FromResult(new CheckResult("Backup", CheckStatus.Warning, sw.ElapsedMilliseconds,
|
||||||
|
$"папка бэкапов {dir} не существует (нормально на dev-машине)"));
|
||||||
|
}
|
||||||
|
var latest = new DirectoryInfo(dir).GetFiles("db-*.dump")
|
||||||
|
.OrderByDescending(f => f.LastWriteTimeUtc).FirstOrDefault();
|
||||||
|
sw.Stop();
|
||||||
|
if (latest is null)
|
||||||
|
return Task.FromResult(new CheckResult("Backup", CheckStatus.Fail, sw.ElapsedMilliseconds,
|
||||||
|
"нет ни одного db-*.dump в " + dir));
|
||||||
|
var ageHours = (DateTime.UtcNow - latest.LastWriteTimeUtc).TotalHours;
|
||||||
|
if (ageHours > 25)
|
||||||
|
return Task.FromResult(new CheckResult("Backup", CheckStatus.Fail, sw.ElapsedMilliseconds,
|
||||||
|
$"последний бэкап {latest.Name} — {ageHours:F1}ч назад (cron упал?)"));
|
||||||
|
if (ageHours > 12)
|
||||||
|
return Task.FromResult(new CheckResult("Backup", CheckStatus.Warning, sw.ElapsedMilliseconds,
|
||||||
|
$"бэкап {latest.Name} устаревает ({ageHours:F1}ч)"));
|
||||||
|
return Task.FromResult(new CheckResult("Backup", CheckStatus.Ok, sw.ElapsedMilliseconds,
|
||||||
|
$"{latest.Name} ({ageHours:F1}ч назад, {latest.Length / 1024 / 1024} MB)"));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
sw.Stop();
|
||||||
|
return Task.FromResult(new CheckResult("Backup", CheckStatus.Fail, sw.ElapsedMilliseconds, ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
169
src/food-market.api/Controllers/FeedbackController.cs
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using foodmarket.Application.Common.Email;
|
||||||
|
using foodmarket.Application.Common.Tenancy;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Sprint 17: in-app feedback widget. POST /api/feedback
|
||||||
|
/// принимает {category, message}, отправляет:
|
||||||
|
/// - email на FromEmail из PlatformSettings (дубль администратору
|
||||||
|
/// платформы — пользователь не должен знать кому именно);
|
||||||
|
/// - Telegram-сообщение в чат поддержки, если задан
|
||||||
|
/// <c>SupportTelegram:BotToken</c> + <c>SupportTelegram:ChatId</c>.
|
||||||
|
///
|
||||||
|
/// Rate-limit: 5 отправок в час на одного user'а — in-memory dictionary
|
||||||
|
/// (для одного API-инстанса). При scale-out — переехать на Redis.</summary>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/feedback")]
|
||||||
|
public class FeedbackController : ControllerBase
|
||||||
|
{
|
||||||
|
public enum FeedbackCategory
|
||||||
|
{
|
||||||
|
Bug = 0,
|
||||||
|
Suggestion = 1,
|
||||||
|
Question = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
public record FeedbackInput(FeedbackCategory Category, string Message);
|
||||||
|
|
||||||
|
// Sprint 17: in-memory rate-limit per-user. После Sprint 13 у нас уже
|
||||||
|
// есть централизованный rate-limiter, но он привязан к auth-endpoint'ам.
|
||||||
|
// Для остальных API делаем простой ConcurrentDictionary.
|
||||||
|
private static readonly ConcurrentDictionary<string, List<DateTime>> _attempts = new();
|
||||||
|
private static readonly TimeSpan _window = TimeSpan.FromHours(1);
|
||||||
|
private const int _maxPerWindow = 5;
|
||||||
|
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly IEmailSender _email;
|
||||||
|
private readonly ITenantContext _tenant;
|
||||||
|
private readonly IConfiguration _cfg;
|
||||||
|
private readonly IHttpClientFactory _httpFactory;
|
||||||
|
private readonly ILogger<FeedbackController> _log;
|
||||||
|
|
||||||
|
public FeedbackController(
|
||||||
|
AppDbContext db, IEmailSender email, ITenantContext tenant,
|
||||||
|
IConfiguration cfg, IHttpClientFactory httpFactory,
|
||||||
|
ILogger<FeedbackController> log)
|
||||||
|
{
|
||||||
|
_db = db; _email = email; _tenant = tenant;
|
||||||
|
_cfg = cfg; _httpFactory = httpFactory; _log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Submit([FromBody] FeedbackInput input, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(input.Message))
|
||||||
|
return BadRequest(new { error = "Сообщение обязательно.", field = "message" });
|
||||||
|
if (input.Message.Length > 4000)
|
||||||
|
return BadRequest(new { error = "Сообщение слишком длинное (макс 4000 символов).", field = "message" });
|
||||||
|
|
||||||
|
// Rate-limit per-user.
|
||||||
|
var userKey = _tenant.UserId?.ToString() ?? HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||||
|
if (!CheckRateLimit(userKey))
|
||||||
|
{
|
||||||
|
_log.LogWarning("Feedback rate-limited for user={Key}", userKey);
|
||||||
|
return StatusCode(StatusCodes.Status429TooManyRequests, new
|
||||||
|
{
|
||||||
|
error = "Слишком много отзывов с вашего аккаунта (макс 5/час). Подождите час и попробуйте снова.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Контекст: кто отправил + откуда + категория + текст.
|
||||||
|
var email = User.FindFirstValue(System.Security.Claims.ClaimTypes.Email)
|
||||||
|
?? User.FindFirstValue("email") ?? "?";
|
||||||
|
var name = User.Identity?.Name ?? "?";
|
||||||
|
var org = "?";
|
||||||
|
if (_tenant.OrganizationId is { } orgId)
|
||||||
|
{
|
||||||
|
org = await _db.Organizations.IgnoreQueryFilters()
|
||||||
|
.Where(o => o.Id == orgId)
|
||||||
|
.Select(o => o.Name)
|
||||||
|
.FirstOrDefaultAsync(ct) ?? "?";
|
||||||
|
}
|
||||||
|
var ua = HttpContext.Request.Headers["User-Agent"].ToString();
|
||||||
|
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||||
|
|
||||||
|
var subject = $"Food Market feedback [{input.Category}] — {org}";
|
||||||
|
var body =
|
||||||
|
$"Категория: {input.Category}\n" +
|
||||||
|
$"Организация: {org}\n" +
|
||||||
|
$"Пользователь: {name} ({email})\n" +
|
||||||
|
$"IP: {ip}\n" +
|
||||||
|
$"User-Agent: {ua}\n" +
|
||||||
|
$"Время: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC\n" +
|
||||||
|
$"\n---\n{input.Message}";
|
||||||
|
|
||||||
|
// Email — best-effort: ловим EmailNotConfigured отдельно (это NOT
|
||||||
|
// ошибка для пользователя — feedback всё равно попадёт в логи + Telegram).
|
||||||
|
var emailSent = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Берём FromEmail из PlatformSettings как «адрес поддержки».
|
||||||
|
var settings = await _db.PlatformSettings.AsNoTracking().FirstOrDefaultAsync(ct);
|
||||||
|
var to = settings?.FromEmail;
|
||||||
|
if (!string.IsNullOrEmpty(to))
|
||||||
|
{
|
||||||
|
await _email.SendAsync(to, subject, body, ct);
|
||||||
|
emailSent = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (EmailNotConfiguredException ex)
|
||||||
|
{
|
||||||
|
_log.LogWarning("Feedback email skipped: {Reason}", ex.Message);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.LogError(ex, "Feedback email failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Telegram — тоже best-effort.
|
||||||
|
var telegramSent = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var token = _cfg["SupportTelegram:BotToken"];
|
||||||
|
var chatId = _cfg["SupportTelegram:ChatId"];
|
||||||
|
if (!string.IsNullOrEmpty(token) && !string.IsNullOrEmpty(chatId))
|
||||||
|
{
|
||||||
|
using var http = _httpFactory.CreateClient();
|
||||||
|
var resp = await http.PostAsJsonAsync(
|
||||||
|
$"https://api.telegram.org/bot{token}/sendMessage",
|
||||||
|
new { chat_id = chatId, text = subject + "\n\n" + body }, ct);
|
||||||
|
telegramSent = resp.IsSuccessStatusCode;
|
||||||
|
if (!telegramSent)
|
||||||
|
_log.LogWarning("Telegram feedback returned {Status}", (int)resp.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.LogWarning(ex, "Feedback Telegram failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Структурированный лог — гарантированно сохранится даже если оба
|
||||||
|
// канала упали (можно вытащить из Serilog позже).
|
||||||
|
_log.LogInformation(
|
||||||
|
"Feedback received: cat={Category} email={Email} org={Org} len={Length} sentEmail={E} sentTelegram={T}",
|
||||||
|
input.Category, email, org, input.Message.Length, emailSent, telegramSent);
|
||||||
|
|
||||||
|
return Ok(new { ok = true, deliveredEmail = emailSent, deliveredTelegram = telegramSent });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool CheckRateLimit(string key)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var attempts = _attempts.GetOrAdd(key, _ => new List<DateTime>());
|
||||||
|
lock (attempts)
|
||||||
|
{
|
||||||
|
attempts.RemoveAll(t => now - t > _window);
|
||||||
|
if (attempts.Count >= _maxPerWindow) return false;
|
||||||
|
attempts.Add(now);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
115
src/food-market.api/Controllers/WhatsNewController.cs
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Sprint 17: /api/whats-new — отдаёт содержимое CHANGELOG.md
|
||||||
|
/// (генерируется отдельным скриптом из commit-сообщений feat:/fix:).
|
||||||
|
///
|
||||||
|
/// Файл лежит в content-root приложения (копируется при build из
|
||||||
|
/// корня репо через Dockerfile.api). Если файла нет — отдаём пустой
|
||||||
|
/// список (фронт скрывает /whats-new пункт).
|
||||||
|
///
|
||||||
|
/// Дополнительно возвращаем timestamp build'а (через файл версии
|
||||||
|
/// или fallback на assembly InformationalVersion) — фронт сравнивает
|
||||||
|
/// с localStorage.lastSeenBuildVersion чтобы показывать toast
|
||||||
|
/// «есть новые фичи» только когда есть.</summary>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/whats-new")]
|
||||||
|
public class WhatsNewController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IWebHostEnvironment _env;
|
||||||
|
private readonly ILogger<WhatsNewController> _log;
|
||||||
|
|
||||||
|
public WhatsNewController(IWebHostEnvironment env, ILogger<WhatsNewController> log)
|
||||||
|
{
|
||||||
|
_env = env; _log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record WhatsNewItem(string Date, string Title, string Body, string Type);
|
||||||
|
public record WhatsNewResponse(string BuildVersion, IReadOnlyList<WhatsNewItem> Items);
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public ActionResult<WhatsNewResponse> Get()
|
||||||
|
{
|
||||||
|
// BuildVersion — short SHA / тэг билд-времени. Берём из
|
||||||
|
// VERSION файла (создаётся Docker-build'ом) или ассембли.
|
||||||
|
var versionFile = Path.Combine(_env.ContentRootPath, "VERSION");
|
||||||
|
string buildVersion;
|
||||||
|
if (System.IO.File.Exists(versionFile))
|
||||||
|
buildVersion = System.IO.File.ReadAllText(versionFile).Trim();
|
||||||
|
else
|
||||||
|
buildVersion = typeof(WhatsNewController).Assembly
|
||||||
|
.GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false)
|
||||||
|
.OfType<System.Reflection.AssemblyInformationalVersionAttribute>()
|
||||||
|
.FirstOrDefault()?.InformationalVersion ?? "dev";
|
||||||
|
|
||||||
|
var changelogPath = Path.Combine(_env.ContentRootPath, "CHANGELOG.md");
|
||||||
|
if (!System.IO.File.Exists(changelogPath))
|
||||||
|
{
|
||||||
|
_log.LogDebug("CHANGELOG.md not found at {Path}", changelogPath);
|
||||||
|
return new WhatsNewResponse(buildVersion, Array.Empty<WhatsNewItem>());
|
||||||
|
}
|
||||||
|
|
||||||
|
var content = System.IO.File.ReadAllText(changelogPath);
|
||||||
|
var items = ParseChangelog(content).ToList();
|
||||||
|
return new WhatsNewResponse(buildVersion, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Парсер CHANGELOG.md в формате:
|
||||||
|
/// <code>
|
||||||
|
/// ## 2026-06-07
|
||||||
|
///
|
||||||
|
/// - **feat**: новая фича (s17)
|
||||||
|
/// - **fix**: устранён баг (s17)
|
||||||
|
/// </code>
|
||||||
|
/// Возвращает items до 30 дней назад от latest-даты.</summary>
|
||||||
|
internal static IEnumerable<WhatsNewItem> ParseChangelog(string content)
|
||||||
|
{
|
||||||
|
var lines = content.Replace("\r\n", "\n").Split('\n');
|
||||||
|
string? currentDate = null;
|
||||||
|
var cutoff = DateTime.UtcNow.AddDays(-30).Date;
|
||||||
|
DateTime? latestSeen = null;
|
||||||
|
var items = new List<WhatsNewItem>();
|
||||||
|
foreach (var raw in lines)
|
||||||
|
{
|
||||||
|
var line = raw.TrimEnd();
|
||||||
|
if (line.StartsWith("## "))
|
||||||
|
{
|
||||||
|
currentDate = line.Substring(3).Trim();
|
||||||
|
if (DateTime.TryParse(currentDate, out var d))
|
||||||
|
{
|
||||||
|
latestSeen ??= d;
|
||||||
|
// Корректируем cutoff: 30 дней от ПОСЛЕДНЕЙ записи,
|
||||||
|
// не от UtcNow — даже если CHANGELOG старый, всё
|
||||||
|
// равно покажем последние 30 дней оттуда.
|
||||||
|
cutoff = latestSeen.Value.AddDays(-30).Date;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (currentDate is null) continue;
|
||||||
|
if (!line.StartsWith("- ")) continue;
|
||||||
|
if (DateTime.TryParse(currentDate, out var dd) && dd.Date < cutoff) break;
|
||||||
|
|
||||||
|
// «- **feat**: текст» или «- **fix**: текст»
|
||||||
|
var item = line.Substring(2);
|
||||||
|
var type = "other";
|
||||||
|
string title = item, body = "";
|
||||||
|
if (item.StartsWith("**feat**", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
type = "feat";
|
||||||
|
title = item.Substring("**feat**:".Length).Trim();
|
||||||
|
}
|
||||||
|
else if (item.StartsWith("**fix**", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
type = "fix";
|
||||||
|
title = item.Substring("**fix**:".Length).Trim();
|
||||||
|
}
|
||||||
|
// Простая эвристика «заголовок vs тело»: первые 80 chars — title.
|
||||||
|
if (title.Length > 80) { body = title.Substring(80); title = title.Substring(0, 80); }
|
||||||
|
items.Add(new WhatsNewItem(currentDate, title, body, type));
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import { LoginPage } from '@/pages/LoginPage'
|
||||||
import { AuthBridgePage } from '@/pages/AuthBridgePage'
|
import { AuthBridgePage } from '@/pages/AuthBridgePage'
|
||||||
import { DashboardPage } from '@/pages/DashboardPage'
|
import { DashboardPage } from '@/pages/DashboardPage'
|
||||||
import { OnboardingPage } from '@/pages/OnboardingPage'
|
import { OnboardingPage } from '@/pages/OnboardingPage'
|
||||||
|
import { OnboardingWizardPage } from '@/pages/OnboardingWizardPage'
|
||||||
import { ForgotPasswordPage } from '@/pages/ForgotPasswordPage'
|
import { ForgotPasswordPage } from '@/pages/ForgotPasswordPage'
|
||||||
import { ResetPasswordPage } from '@/pages/ResetPasswordPage'
|
import { ResetPasswordPage } from '@/pages/ResetPasswordPage'
|
||||||
import { ProductsPage } from '@/pages/ProductsPage'
|
import { ProductsPage } from '@/pages/ProductsPage'
|
||||||
|
|
@ -46,6 +47,10 @@ const LoyaltyCardsPage = lazy(() => import('@/pages/LoyaltyCardsPage').then(m =>
|
||||||
const PromotionsPage = lazy(() => import('@/pages/PromotionsPage').then(m => ({ default: m.PromotionsPage })))
|
const PromotionsPage = lazy(() => import('@/pages/PromotionsPage').then(m => ({ default: m.PromotionsPage })))
|
||||||
const MoySkladImportPage = lazy(() => import('@/pages/MoySkladImportPage').then(m => ({ default: m.MoySkladImportPage })))
|
const MoySkladImportPage = lazy(() => import('@/pages/MoySkladImportPage').then(m => ({ default: m.MoySkladImportPage })))
|
||||||
const OrganizationSettingsPage = lazy(() => import('@/pages/OrganizationSettingsPage').then(m => ({ default: m.OrganizationSettingsPage })))
|
const OrganizationSettingsPage = lazy(() => import('@/pages/OrganizationSettingsPage').then(m => ({ default: m.OrganizationSettingsPage })))
|
||||||
|
// Sprint 17: help / what's-new / diagnostic.
|
||||||
|
const HelpPage = lazy(() => import('@/pages/HelpPage').then(m => ({ default: m.HelpPage })))
|
||||||
|
const WhatsNewPage = lazy(() => import('@/pages/WhatsNewPage').then(m => ({ default: m.WhatsNewPage })))
|
||||||
|
const AdminDiagnosticPage = lazy(() => import('@/pages/AdminDiagnosticPage').then(m => ({ default: m.AdminDiagnosticPage })))
|
||||||
const EmployeesPage = lazy(() => import('@/pages/EmployeesPage').then(m => ({ default: m.EmployeesPage })))
|
const EmployeesPage = lazy(() => import('@/pages/EmployeesPage').then(m => ({ default: m.EmployeesPage })))
|
||||||
const EmployeeRolesPage = lazy(() => import('@/pages/EmployeeRolesPage').then(m => ({ default: m.EmployeeRolesPage })))
|
const EmployeeRolesPage = lazy(() => import('@/pages/EmployeeRolesPage').then(m => ({ default: m.EmployeeRolesPage })))
|
||||||
const StockMovementsPage = lazy(() => import('@/pages/StockMovementsPage').then(m => ({ default: m.StockMovementsPage })))
|
const StockMovementsPage = lazy(() => import('@/pages/StockMovementsPage').then(m => ({ default: m.StockMovementsPage })))
|
||||||
|
|
@ -143,6 +148,7 @@ export default function App() {
|
||||||
* SuperAdmin без активного override → редирект на /super-admin/organizations. */}
|
* SuperAdmin без активного override → редирект на /super-admin/organizations. */}
|
||||||
<Route element={<TenantRouteGuard><AppLayout /></TenantRouteGuard>}>
|
<Route element={<TenantRouteGuard><AppLayout /></TenantRouteGuard>}>
|
||||||
<Route path="/" element={<OnboardingPage />} />
|
<Route path="/" element={<OnboardingPage />} />
|
||||||
|
<Route path="/onboarding-wizard" element={<OnboardingWizardPage />} />
|
||||||
<Route path="/dashboard" element={<DashboardPage />} />
|
<Route path="/dashboard" element={<DashboardPage />} />
|
||||||
<Route path="/catalog/products" element={<ProductsPage />} />
|
<Route path="/catalog/products" element={<ProductsPage />} />
|
||||||
<Route path="/catalog/products/new" element={<ProductEditPage />} />
|
<Route path="/catalog/products/new" element={<ProductEditPage />} />
|
||||||
|
|
@ -189,6 +195,10 @@ export default function App() {
|
||||||
<Route path="/audit-log" element={<RoleGuard roles={['Admin']}>{lz(OrgAuditLogPage)}</RoleGuard>} />
|
<Route path="/audit-log" element={<RoleGuard roles={['Admin']}>{lz(OrgAuditLogPage)}</RoleGuard>} />
|
||||||
<Route path="/admin/import/moysklad" element={<RoleGuard roles={['Admin']}>{lz(MoySkladImportPage)}</RoleGuard>} />
|
<Route path="/admin/import/moysklad" element={<RoleGuard roles={['Admin']}>{lz(MoySkladImportPage)}</RoleGuard>} />
|
||||||
<Route path="/settings/organization" element={<RoleGuard roles={['Admin']}>{lz(OrganizationSettingsPage)}</RoleGuard>} />
|
<Route path="/settings/organization" element={<RoleGuard roles={['Admin']}>{lz(OrganizationSettingsPage)}</RoleGuard>} />
|
||||||
|
{/* Sprint 17 */}
|
||||||
|
<Route path="/help" element={lz(HelpPage)} />
|
||||||
|
<Route path="/whats-new" element={lz(WhatsNewPage)} />
|
||||||
|
<Route path="/admin/diagnostic" element={<RoleGuard roles={['Admin']}>{lz(AdminDiagnosticPage)}</RoleGuard>} />
|
||||||
<Route path="/settings/employees" element={<RoleGuard roles={['Admin']}>{lz(EmployeesPage)}</RoleGuard>} />
|
<Route path="/settings/employees" element={<RoleGuard roles={['Admin']}>{lz(EmployeesPage)}</RoleGuard>} />
|
||||||
<Route path="/settings/employee-roles" element={<RoleGuard roles={['Admin']}>{lz(EmployeeRolesPage)}</RoleGuard>} />
|
<Route path="/settings/employee-roles" element={<RoleGuard roles={['Admin']}>{lz(EmployeeRolesPage)}</RoleGuard>} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner'
|
||||||
import { ShortcutsOverlay } from './ShortcutsOverlay'
|
import { ShortcutsOverlay } from './ShortcutsOverlay'
|
||||||
import { LanguageSwitcher } from './LanguageSwitcher'
|
import { LanguageSwitcher } from './LanguageSwitcher'
|
||||||
import { CommandPalette } from './CommandPalette'
|
import { CommandPalette } from './CommandPalette'
|
||||||
|
import { FeedbackWidget } from './FeedbackWidget'
|
||||||
|
|
||||||
interface MeResponse {
|
interface MeResponse {
|
||||||
sub: string
|
sub: string
|
||||||
|
|
@ -249,6 +250,16 @@ export function AppLayout() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="px-2 pb-2"><LanguageSwitcher /></div>
|
<div className="px-2 pb-2"><LanguageSwitcher /></div>
|
||||||
|
{/* Sprint 17: feedback widget + ссылки на /help и /whats-new. */}
|
||||||
|
<div className="px-2 pb-2 flex items-center gap-3 flex-wrap">
|
||||||
|
<FeedbackWidget />
|
||||||
|
<a href="/help" className="text-xs text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200">
|
||||||
|
База знаний
|
||||||
|
</a>
|
||||||
|
<a href="/whats-new" className="text-xs text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200">
|
||||||
|
Что нового
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
className="w-full flex items-center gap-2 px-2 py-1.5 text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800/50 rounded"
|
className="w-full flex items-center gap-2 px-2 py-1.5 text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800/50 rounded"
|
||||||
|
|
|
||||||
86
src/food-market.web/src/components/EmptyStateWithDemo.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
/**
|
||||||
|
* Sprint 17: empty-state с CTA на демо.
|
||||||
|
*
|
||||||
|
* <EmptyStateWithDemo
|
||||||
|
* icon={Package}
|
||||||
|
* title="Здесь будут товары"
|
||||||
|
* description="После создания товара он появится в списке."
|
||||||
|
* primaryAction={{ label: 'Создать товар', to: '/catalog/products/new' }}
|
||||||
|
* helpTopic="catalog"
|
||||||
|
* demoVideoUrl={null} // placeholder для будущих видео
|
||||||
|
* />
|
||||||
|
*
|
||||||
|
* Placeholder для demo-видео — пока показывает только ссылку на /help.
|
||||||
|
* Когда появятся демки — `demoVideoUrl` рендерит embedded player.
|
||||||
|
*/
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { PlayCircle, BookOpen } from 'lucide-react'
|
||||||
|
|
||||||
|
interface Action {
|
||||||
|
label: string
|
||||||
|
to?: string
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EmptyStateWithDemoProps {
|
||||||
|
icon: React.ComponentType<{ className?: string }>
|
||||||
|
title: string
|
||||||
|
description: ReactNode
|
||||||
|
primaryAction?: Action
|
||||||
|
helpTopic?: string
|
||||||
|
/** Если задан — рендерим CTA «Посмотреть как это работает». Placeholder. */
|
||||||
|
demoVideoUrl?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyStateWithDemo({
|
||||||
|
icon: Icon, title, description, primaryAction, helpTopic, demoVideoUrl,
|
||||||
|
}: EmptyStateWithDemoProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center text-center py-16 px-6 gap-3">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center">
|
||||||
|
<Icon className="w-8 h-8 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-slate-900 dark:text-slate-100">{title}</h3>
|
||||||
|
<div className="text-sm text-slate-500 dark:text-slate-400 max-w-md">{description}</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 justify-center pt-2">
|
||||||
|
{primaryAction && (
|
||||||
|
primaryAction.to ? (
|
||||||
|
<Link
|
||||||
|
to={primaryAction.to}
|
||||||
|
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm bg-[var(--color-brand)] hover:bg-[var(--color-brand-hover)] text-white rounded-md"
|
||||||
|
>
|
||||||
|
{primaryAction.label}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={primaryAction.onClick}
|
||||||
|
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm bg-[var(--color-brand)] hover:bg-[var(--color-brand-hover)] text-white rounded-md"
|
||||||
|
>
|
||||||
|
{primaryAction.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{demoVideoUrl ? (
|
||||||
|
<a
|
||||||
|
href={demoVideoUrl}
|
||||||
|
target="_blank" rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm border border-slate-300 dark:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-800 rounded-md text-slate-700 dark:text-slate-200"
|
||||||
|
>
|
||||||
|
<PlayCircle className="w-4 h-4" /> Посмотреть как это работает
|
||||||
|
</a>
|
||||||
|
) : helpTopic ? (
|
||||||
|
<Link
|
||||||
|
to={`/help#${helpTopic}`}
|
||||||
|
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm border border-slate-300 dark:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-800 rounded-md text-slate-700 dark:text-slate-200"
|
||||||
|
>
|
||||||
|
<BookOpen className="w-4 h-4" /> Подробнее в базе знаний
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{!demoVideoUrl && helpTopic && (
|
||||||
|
<p className="text-xs text-slate-400 mt-2">Видео-демо появится в следующих версиях</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
114
src/food-market.web/src/components/FeedbackWidget.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
/**
|
||||||
|
* Sprint 17: in-app feedback widget.
|
||||||
|
*
|
||||||
|
* Кнопка в sidebar/footer «Отзыв». Click → Modal с:
|
||||||
|
* - категория (баг / предложение / вопрос),
|
||||||
|
* - textarea (макс 4000).
|
||||||
|
* Submit → POST /api/feedback. Сервер шлёт email + Telegram, rate-limit 5/час.
|
||||||
|
*/
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { MessageSquare, Bug, Lightbulb, HelpCircle } from 'lucide-react'
|
||||||
|
import { Modal } from '@/components/Modal'
|
||||||
|
import { Button } from '@/components/Button'
|
||||||
|
import { Field, TextArea } from '@/components/Field'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { toast } from '@/lib/toast'
|
||||||
|
|
||||||
|
type Category = 0 | 1 | 2 // 0=Bug, 1=Suggestion, 2=Question
|
||||||
|
|
||||||
|
export function FeedbackWidget() {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [category, setCategory] = useState<Category>(1)
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!message.trim()) {
|
||||||
|
toast.error('Заполните сообщение')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const r = await api.post<{ ok: boolean; deliveredEmail: boolean; deliveredTelegram: boolean }>(
|
||||||
|
'/api/feedback', { category, message: message.trim() },
|
||||||
|
)
|
||||||
|
const channels = []
|
||||||
|
if (r.data.deliveredEmail) channels.push('email')
|
||||||
|
if (r.data.deliveredTelegram) channels.push('telegram')
|
||||||
|
toast.success(channels.length > 0
|
||||||
|
? `Отзыв отправлен (${channels.join(' + ')}). Спасибо!`
|
||||||
|
: 'Отзыв принят. Спасибо!')
|
||||||
|
setMessage('')
|
||||||
|
setOpen(false)
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as { response?: { data?: { error?: string } }; message?: string }
|
||||||
|
toast.error(err.response?.data?.error ?? err.message ?? 'Не удалось отправить')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
|
Отзыв
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
title="Оставить отзыв"
|
||||||
|
width="max-w-md"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={() => setOpen(false)} mutating={false}>Отмена</Button>
|
||||||
|
<Button onClick={submit} disabled={!message.trim() || busy}>
|
||||||
|
{busy ? 'Отправляю…' : 'Отправить'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Field label="Категория">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{([
|
||||||
|
{ v: 0 as Category, label: 'Баг', icon: Bug },
|
||||||
|
{ v: 1 as Category, label: 'Предложение', icon: Lightbulb },
|
||||||
|
{ v: 2 as Category, label: 'Вопрос', icon: HelpCircle },
|
||||||
|
] as const).map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.v}
|
||||||
|
onClick={() => setCategory(c.v)}
|
||||||
|
type="button"
|
||||||
|
className={
|
||||||
|
'flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 text-sm rounded-md border ' +
|
||||||
|
(category === c.v
|
||||||
|
? 'border-[var(--color-brand)] text-[var(--color-brand)] bg-emerald-50 dark:bg-emerald-900/20'
|
||||||
|
: 'border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-300')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<c.icon className="w-4 h-4" aria-hidden="true" />
|
||||||
|
{c.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
<Field label="Сообщение">
|
||||||
|
<TextArea
|
||||||
|
rows={5}
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value.slice(0, 4000))}
|
||||||
|
placeholder="Опишите проблему / идею / вопрос…"
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-slate-500 mt-1">{message.length} / 4000</div>
|
||||||
|
</Field>
|
||||||
|
<p className="text-xs text-slate-500 mt-3">
|
||||||
|
Письмо уйдёт администратору платформы. Указывать контакт не нужно — мы видим ваш email в системе.
|
||||||
|
</p>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
77
src/food-market.web/src/components/HelpTooltip.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import { HelpCircle, ExternalLink } from 'lucide-react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { getHelpTopic } from '@/lib/help-topics'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sprint 17: контекстный help-popover. Используется рядом с заголовком
|
||||||
|
* раздела или подписью поля:
|
||||||
|
*
|
||||||
|
* <h3>Лояльность <HelpTooltip topic="loyalty"/></h3>
|
||||||
|
*
|
||||||
|
* Click → показывает popover с title + short + ссылкой на /help#key.
|
||||||
|
* Click outside / Esc → закрывается.
|
||||||
|
*
|
||||||
|
* Accessibility: кнопка с aria-label, popover с role="dialog" (но
|
||||||
|
* нонмодальный — не блокируем взаимодействие с другим UI).
|
||||||
|
*/
|
||||||
|
interface HelpTooltipProps {
|
||||||
|
topic: string
|
||||||
|
/** Размер иконки в px (default 16). */
|
||||||
|
size?: number
|
||||||
|
/** Класс для иконки (например, для inline-align). */
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HelpTooltip({ topic, size = 16, className }: HelpTooltipProps) {
|
||||||
|
const meta = getHelpTopic(topic)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const onDoc = (e: MouseEvent) => {
|
||||||
|
if (!ref.current?.contains(e.target as Node)) setOpen(false)
|
||||||
|
}
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
|
||||||
|
document.addEventListener('mousedown', onDoc)
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDoc)
|
||||||
|
document.removeEventListener('keydown', onKey)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span ref={ref} className={`relative inline-flex align-middle ${className ?? ''}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="text-slate-500 hover:text-[var(--color-brand)] inline-flex"
|
||||||
|
aria-label={`Помощь: ${meta.title}`}
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<HelpCircle width={size} height={size} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-label={meta.title}
|
||||||
|
className="absolute z-30 left-1/2 -translate-x-1/2 top-full mt-2 w-72 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-lg p-3 text-sm"
|
||||||
|
>
|
||||||
|
<div className="font-semibold text-slate-900 dark:text-slate-100 mb-1">{meta.title}</div>
|
||||||
|
<div className="text-slate-700 dark:text-slate-300 mb-2 leading-snug">{meta.short}</div>
|
||||||
|
{meta.fullPath && (
|
||||||
|
<Link
|
||||||
|
to={`/help#${meta.fullPath.split('#')[0]}`}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="inline-flex items-center gap-1 text-[var(--color-brand)] hover:underline text-xs"
|
||||||
|
>
|
||||||
|
Подробнее <ExternalLink className="w-3 h-3" aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
21
src/food-market.web/src/help/catalog.md
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
---
|
||||||
|
title: Каталог товаров
|
||||||
|
group: catalog
|
||||||
|
order: 1
|
||||||
|
---
|
||||||
|
|
||||||
|
# Каталог
|
||||||
|
|
||||||
|
Раздел «Каталог» включает:
|
||||||
|
- **Товары** — основные карточки с ценами, штрихкодами, изображениями.
|
||||||
|
- **Группы товаров** — иерархия для категоризации (Хлеб / Молочное / …).
|
||||||
|
- **Единицы измерения** — штука, кг, л (можно добавить свои).
|
||||||
|
- **Типы цен** — Розничная, Опт, Закупочная (системный «Розничная» создаётся при signup).
|
||||||
|
|
||||||
|
## Поиск штрихкода
|
||||||
|
|
||||||
|
В POS-приложении сканер штрихкода ищет товар по полю `barcode`. На веб-админке тот же поиск в `/api/search/global` через Cmd+K.
|
||||||
|
|
||||||
|
## Импорт из МойСклад
|
||||||
|
|
||||||
|
Перейдите «Каталог → Импорт МойСклад», вставьте ApiKey МойСклад — загрузятся товары, группы, контрагенты и остатки. Идемпотентно (повторный run — обновление).
|
||||||
25
src/food-market.web/src/help/documents.md
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
---
|
||||||
|
title: Документы
|
||||||
|
group: documents
|
||||||
|
order: 1
|
||||||
|
---
|
||||||
|
|
||||||
|
# Документы учёта
|
||||||
|
|
||||||
|
В системе 7 типов документов:
|
||||||
|
|
||||||
|
- **Приёмка (Supply)** — поступление от поставщика. Post → +qty на склад.
|
||||||
|
- **Оприходование (Enter)** — внутреннее увеличение остатка (без поставщика). Post → +qty.
|
||||||
|
- **Возврат поставщику (SupplierReturn)** — −qty со склада.
|
||||||
|
- **Розничная продажа (RetailSale)** — чек кассы. Post → −qty.
|
||||||
|
- **Опт-отгрузка (Demand)** — продажа контрагенту. Post → −qty.
|
||||||
|
- **Списание (Loss)** — брак/просрочка. Post → −qty.
|
||||||
|
- **Перемещение (Transfer)** — между складами. Post → −qty с from, +qty на to.
|
||||||
|
|
||||||
|
## Цикл документа
|
||||||
|
|
||||||
|
1. Создать **Draft** (черновик).
|
||||||
|
2. **Post** — проводит, стоки обновляются под Serializable-транзакцией.
|
||||||
|
3. **Unpost** — откатывает (если нет последующих ссылок).
|
||||||
|
|
||||||
|
`xmin` concurrency защищает от race'ов: параллельный пост одного документа → 409.
|
||||||
27
src/food-market.web/src/help/getting-started.md
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
---
|
||||||
|
title: Начало работы
|
||||||
|
group: start
|
||||||
|
order: 1
|
||||||
|
---
|
||||||
|
|
||||||
|
# Начало работы
|
||||||
|
|
||||||
|
После регистрации вы попадаете в **Wizard** из 4 шагов, который помогает заполнить базовые данные:
|
||||||
|
|
||||||
|
1. **Магазин** — название и адрес (видны на квитанциях).
|
||||||
|
2. **Первый товар** — заводим один тестовый товар или импортируем каталог.
|
||||||
|
3. **Первый сотрудник** — добавляем кассира/администратора.
|
||||||
|
4. **Демо-данные** — опционально заполняем пробными данными за месяц.
|
||||||
|
|
||||||
|
Каждый шаг можно пропустить. Wizard запоминается через `localStorage.fm.wizardCompleted` — повторно не показывается.
|
||||||
|
|
||||||
|
## Демо-данные
|
||||||
|
|
||||||
|
«Заполнить пробными данными» создаёт:
|
||||||
|
- 50 товаров в 5 группах,
|
||||||
|
- 10 контрагентов (5 поставщиков + 5 покупателей),
|
||||||
|
- 5 приёмок,
|
||||||
|
- 30 продаж,
|
||||||
|
- 1 опт-отгрузка, 1 списание, 1 перемещение, 1 инвентаризация.
|
||||||
|
|
||||||
|
Идемпотентно — повторный запуск ничего не дублирует. Это помогает увидеть как выглядят отчёты и виджеты с реальными числами.
|
||||||
25
src/food-market.web/src/help/integrations.md
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
---
|
||||||
|
title: Интеграции
|
||||||
|
group: integrations
|
||||||
|
order: 1
|
||||||
|
---
|
||||||
|
|
||||||
|
# Интеграции
|
||||||
|
|
||||||
|
## ОФД (фискализация чеков РК)
|
||||||
|
|
||||||
|
Поддерживаются:
|
||||||
|
- **Webkassa** (полный pipeline) — нужен Login + Password от API-пользователя + CashboxUniqueNumber.
|
||||||
|
- **Касса24** (skeleton, ждём спецификации API).
|
||||||
|
- **ОФД-Соло** (skeleton).
|
||||||
|
- **Mock** — для dev/демо без реального оператора.
|
||||||
|
|
||||||
|
Настройка: «Настройки → ОФД». ApiKey/ApiSecret шифруются через DataProtection (purpose=`foodmarket.fiscal`), в API не возвращаются (только has-флаги).
|
||||||
|
|
||||||
|
## МойСклад
|
||||||
|
|
||||||
|
Импорт каталога одним кликом. Per-organization ApiToken в `Organization.MoySkladToken`.
|
||||||
|
|
||||||
|
## MinIO (S3-совместимое хранилище)
|
||||||
|
|
||||||
|
По умолчанию картинки товаров хранятся на локальном диске (`/uploads`). Для production рекомендуется MinIO — даёт надёжное хранение + удобный URL.
|
||||||
19
src/food-market.web/src/help/inventory.md
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
---
|
||||||
|
title: Склад и остатки
|
||||||
|
group: documents
|
||||||
|
order: 5
|
||||||
|
---
|
||||||
|
|
||||||
|
# Склад
|
||||||
|
|
||||||
|
## Инвариант
|
||||||
|
|
||||||
|
`stocks.quantity` = `SUM(stock_movements.quantity)` per `(productId, storeId)`. Подтверждено property-based test'ом (`StockServicePropertyTests`).
|
||||||
|
|
||||||
|
## CSV-инвентаризация
|
||||||
|
|
||||||
|
«Инвентаризация → Загрузить CSV». Колонки: `article` (или `barcode`), `factQty`. На неизвестные товары — warning в импорт-логе, не падает целиком.
|
||||||
|
|
||||||
|
## Низкий остаток
|
||||||
|
|
||||||
|
Если у товара заполнено `MinStock` — Hangfire-job `low-stock-alert` каждый день в 08:00 UTC шлёт email-сводку Admin'у. Также реалтайм-уведомление через SignalR `LowStock` после каждой продажи.
|
||||||
21
src/food-market.web/src/help/loyalty.md
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
---
|
||||||
|
title: Лояльность и акции
|
||||||
|
group: features
|
||||||
|
order: 2
|
||||||
|
---
|
||||||
|
|
||||||
|
# Лояльность
|
||||||
|
|
||||||
|
Три типа программ:
|
||||||
|
|
||||||
|
- **Percentage** — фиксированный % скидки на чек.
|
||||||
|
- **FixedAmount** — фиксированная сумма скидки.
|
||||||
|
- **PointsAccrual** — баллы (% от чека) копятся на карту.
|
||||||
|
|
||||||
|
## Карты
|
||||||
|
|
||||||
|
Каждая карта = (программа, контрагент, номер карты). Номер уникален в рамках организации.
|
||||||
|
|
||||||
|
## Промокоды
|
||||||
|
|
||||||
|
Промокод — отдельная сущность. Может действовать на весь чек, на товары из групп или конкретные товары. Период действия, минимальная сумма чека и активность контролируются.
|
||||||
23
src/food-market.web/src/help/security.md
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
---
|
||||||
|
title: Безопасность
|
||||||
|
group: security
|
||||||
|
order: 1
|
||||||
|
---
|
||||||
|
|
||||||
|
# Безопасность
|
||||||
|
|
||||||
|
## Двухфакторная защита (2FA)
|
||||||
|
|
||||||
|
Включает TOTP — Google Authenticator / Authy / 1Password OTP. После включения логин требует кода каждый раз.
|
||||||
|
|
||||||
|
Включить: «Настройки → Профиль → 2FA».
|
||||||
|
|
||||||
|
## Завершить все сессии
|
||||||
|
|
||||||
|
Если подозреваете компрометацию пароля — «Профиль → Завершить все сессии» гасит все refresh-токены. Все ваши клиенты (web, POS) на следующем refresh'е разлогинятся.
|
||||||
|
|
||||||
|
## Журнал изменений (audit-log)
|
||||||
|
|
||||||
|
Каждая mutate-операция (CRUD на товарах, документах, ролях) логируется в `org_audit_log`. Хранится 180 дней. Доступ — Admin и SuperAdmin.
|
||||||
|
|
||||||
|
Sensitive-операции (смена пароля, 2FA enroll/disable, выдача роли, revoke-all sessions) дополнительно идут через `SensitiveOpsAudit` с структурированным payload'ом.
|
||||||
90
src/food-market.web/src/lib/help-topics.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
/**
|
||||||
|
* Sprint 17: mapping help-topics → короткие описания.
|
||||||
|
* Используется `<HelpTooltip topic="key"/>` — рендерит иконку «?» которая
|
||||||
|
* по клику показывает popover с textом и ссылкой на /help#key.
|
||||||
|
*
|
||||||
|
* Тексты короткие — 1-2 предложения. Длинная документация — в
|
||||||
|
* `src/help/<key>.md`, открывается на /help.
|
||||||
|
*/
|
||||||
|
export interface HelpTopic {
|
||||||
|
/** Заголовок popover'а — обычно совпадает с названием раздела. */
|
||||||
|
title: string
|
||||||
|
/** Короткое описание (1-2 предложения). */
|
||||||
|
short: string
|
||||||
|
/** Опц. путь в /help для full-read'a. */
|
||||||
|
fullPath?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HELP_TOPICS: Record<string, HelpTopic> = {
|
||||||
|
loyalty: {
|
||||||
|
title: 'Лояльность',
|
||||||
|
short: 'Создайте программу скидок (процент, фиксированная сумма или баллы) и выдайте карты постоянным покупателям. На кассе скидка применится по номеру карты.',
|
||||||
|
fullPath: 'loyalty',
|
||||||
|
},
|
||||||
|
'loyalty-cards': {
|
||||||
|
title: 'Карты лояльности',
|
||||||
|
short: 'Каждая карта привязана к программе и контрагенту-покупателю. CardNumber вводится на POS — кассир видит баланс и скидку.',
|
||||||
|
fullPath: 'loyalty#cards',
|
||||||
|
},
|
||||||
|
promotions: {
|
||||||
|
title: 'Промокоды и акции',
|
||||||
|
short: 'Промокод даёт скидку при вводе на чеке (или автоматически на товары из заданного scope). Можно ограничить по периоду, минимальной сумме чека и по товарам/группам.',
|
||||||
|
fullPath: 'promotions',
|
||||||
|
},
|
||||||
|
'2fa': {
|
||||||
|
title: 'Двухфакторная защита (TOTP)',
|
||||||
|
short: 'Включает второй фактор при логине: код из Google Authenticator / Authy. Защищает аккаунт даже если пароль утёк.',
|
||||||
|
fullPath: 'security#2fa',
|
||||||
|
},
|
||||||
|
'sessions-revoke': {
|
||||||
|
title: 'Завершить все сессии',
|
||||||
|
short: "Гасит все refresh-токены — все ваши клиенты (web, POS) на следующем refresh'е разлогинятся. Используйте если подозреваете компрометацию.",
|
||||||
|
fullPath: 'security#sessions',
|
||||||
|
},
|
||||||
|
'minio-settings': {
|
||||||
|
title: 'Хранилище изображений (MinIO)',
|
||||||
|
short: 'По умолчанию картинки товаров лежат на локальном диске. MinIO даёт надёжное S3-совместимое хранение с CDN-друидным URL и репликой.',
|
||||||
|
fullPath: 'integrations#storage',
|
||||||
|
},
|
||||||
|
ofd: {
|
||||||
|
title: 'ОФД (фискализация чеков РК)',
|
||||||
|
short: 'Чек регистрируется у оператора (Webkassa / Касса24 / ОФД-Соло) → выдаётся фискальный номер + QR для печати. Без ОФД чек проводится локально без фискализации.',
|
||||||
|
fullPath: 'integrations#ofd',
|
||||||
|
},
|
||||||
|
'moysklad-import': {
|
||||||
|
title: 'Импорт из МойСклад',
|
||||||
|
short: 'Загружает каталог (товары, группы, контрагенты, остатки) одним кликом по API-ключу МойСклад. Импорт идемпотентный — повторный run обновляет существующие.',
|
||||||
|
fullPath: 'integrations#moysklad',
|
||||||
|
},
|
||||||
|
'inventory-csv': {
|
||||||
|
title: 'Загрузка остатков CSV',
|
||||||
|
short: 'Зальёт остатки товаров через инвентаризацию с CSV-файлом. Колонки: article|barcode, factQty. На неизвестные товары — warning, не падает.',
|
||||||
|
fullPath: 'inventory#csv',
|
||||||
|
},
|
||||||
|
'audit-log': {
|
||||||
|
title: 'Журнал изменений',
|
||||||
|
short: 'Логирует каждую mutate-операцию (create/update/delete на товарах, документах, ролях). Хранится 180 дней. Можно фильтровать по типу, пользователю, времени.',
|
||||||
|
fullPath: 'security#audit',
|
||||||
|
},
|
||||||
|
'demo-seed': {
|
||||||
|
title: 'Демо-данные',
|
||||||
|
short: 'Заполняет вашу организацию реалистичными данными за месяц (50 товаров, 10 контрагентов, продажи, приёмки). Идемпотентно — повторный запуск ничего не дублирует. Можно использовать для тренинга.',
|
||||||
|
fullPath: 'getting-started#demo',
|
||||||
|
},
|
||||||
|
'wizard-store': {
|
||||||
|
title: 'Магазин',
|
||||||
|
short: 'Название и адрес видны на квитанциях. Изменить можно в любое время в «Настройки → Магазин».',
|
||||||
|
},
|
||||||
|
'wizard-employee': {
|
||||||
|
title: 'Сотрудник',
|
||||||
|
short: 'Создаём запись сотрудника без аккаунта пользователя. Чтобы он мог войти — отправьте приглашение по email из «Настройки → Сотрудники».',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Возвращает topic-meta или fallback с переданным текстом. */
|
||||||
|
export function getHelpTopic(key: string): HelpTopic {
|
||||||
|
return HELP_TOPICS[key] ?? {
|
||||||
|
title: key,
|
||||||
|
short: 'Описание ещё не написано.',
|
||||||
|
}
|
||||||
|
}
|
||||||
124
src/food-market.web/src/pages/AdminDiagnosticPage.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
/**
|
||||||
|
* Sprint 17: /admin/diagnostic — self-diagnostic для Admin/SuperAdmin.
|
||||||
|
*
|
||||||
|
* Запускает 7 параллельных health-проверок через `/api/admin/diagnostic/run`
|
||||||
|
* и рендерит результаты с 🟢/🟡/🔴 индикаторами. По умолчанию SMTP-проверка
|
||||||
|
* без отправки письма (sendTestEmail=false); чекбокс «отправить тестовое»
|
||||||
|
* добавляет параметр.
|
||||||
|
*/
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import { CheckCircle2, AlertTriangle, XCircle, MinusCircle, RefreshCw, Activity } from 'lucide-react'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
|
import { Button } from '@/components/Button'
|
||||||
|
|
||||||
|
type CheckStatus = 'Ok' | 'Warning' | 'Fail' | 'Skipped'
|
||||||
|
|
||||||
|
interface CheckResult {
|
||||||
|
name: string
|
||||||
|
status: CheckStatus
|
||||||
|
durationMs: number
|
||||||
|
details: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiagnosticReport {
|
||||||
|
overall: CheckStatus
|
||||||
|
checks: CheckResult[]
|
||||||
|
ranAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminDiagnosticPage() {
|
||||||
|
const [sendEmail, setSendEmail] = useState(false)
|
||||||
|
const [report, setReport] = useState<DiagnosticReport | null>(null)
|
||||||
|
|
||||||
|
const run = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const r = await api.get<DiagnosticReport>(
|
||||||
|
`/api/admin/diagnostic/run?sendTestEmail=${sendEmail}`,
|
||||||
|
)
|
||||||
|
return r.data
|
||||||
|
},
|
||||||
|
onSuccess: (d) => setReport(d),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-auto">
|
||||||
|
<div className="max-w-3xl mx-auto p-4 sm:p-6 space-y-5">
|
||||||
|
<PageHeader
|
||||||
|
title="Диагностика системы"
|
||||||
|
description="Self-check — БД, SMTP, MinIO, Hangfire, диск, сертификаты, бэкап. Доступно только Admin'у."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5 space-y-4">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Activity className="w-5 h-5 text-[var(--color-brand)]" />
|
||||||
|
<h2 className="font-semibold">Запустить проверку</h2>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => run.mutate()} disabled={run.isPending}>
|
||||||
|
{run.isPending ? (
|
||||||
|
<><RefreshCw className="w-4 h-4 animate-spin" /> Выполняю…</>
|
||||||
|
) : (
|
||||||
|
<><RefreshCw className="w-4 h-4" /> Запустить</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<label className="text-sm inline-flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={sendEmail}
|
||||||
|
onChange={(e) => setSendEmail(e.target.checked)}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
Отправить тестовое письмо при SMTP-проверке (иначе только конфиг)
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{report && (
|
||||||
|
<section className={
|
||||||
|
'rounded-xl border p-5 space-y-3 ' +
|
||||||
|
(report.overall === 'Ok' ? 'border-emerald-200 bg-emerald-50 dark:bg-emerald-900/10'
|
||||||
|
: report.overall === 'Warning' ? 'border-amber-200 bg-amber-50 dark:bg-amber-900/10'
|
||||||
|
: 'border-red-200 bg-red-50 dark:bg-red-900/10')
|
||||||
|
}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-semibold">
|
||||||
|
Общий статус: {report.overall === 'Ok' ? '🟢 Healthy'
|
||||||
|
: report.overall === 'Warning' ? '🟡 Warning'
|
||||||
|
: '🔴 Fail'}
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-slate-500">{new Date(report.ranAt).toLocaleString('ru-RU')}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{report && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{report.checks.map((c) => (
|
||||||
|
<article key={c.name} className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4 flex items-start gap-3">
|
||||||
|
<StatusIcon status={c.status} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h4 className="font-semibold text-sm">{c.name}</h4>
|
||||||
|
<span className="text-xs text-slate-500">{c.durationMs}ms</span>
|
||||||
|
</div>
|
||||||
|
{c.details && (
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400 mt-1 break-words">{c.details}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusIcon({ status }: { status: CheckStatus }) {
|
||||||
|
if (status === 'Ok') return <CheckCircle2 className="w-5 h-5 text-emerald-600 mt-0.5 shrink-0" aria-label="ok" />
|
||||||
|
if (status === 'Warning') return <AlertTriangle className="w-5 h-5 text-amber-600 mt-0.5 shrink-0" aria-label="warning" />
|
||||||
|
if (status === 'Fail') return <XCircle className="w-5 h-5 text-red-600 mt-0.5 shrink-0" aria-label="fail" />
|
||||||
|
return <MinusCircle className="w-5 h-5 text-slate-400 mt-0.5 shrink-0" aria-label="skipped" />
|
||||||
|
}
|
||||||
188
src/food-market.web/src/pages/HelpPage.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
/**
|
||||||
|
* Sprint 17: /help — knowledge base.
|
||||||
|
*
|
||||||
|
* Markdown topics из src/help/*.md загружаются через Vite's
|
||||||
|
* `import.meta.glob` (eager: true). Front-matter (--- ... ---) парсится
|
||||||
|
* простой regex'ой; body — отдаётся как HTML через minimum-renderer
|
||||||
|
* (без heavy markdown-it зависимости — хватает заголовков + списков +
|
||||||
|
* кода).
|
||||||
|
*
|
||||||
|
* Поиск: text-fuzzy по title + body, на клиенте (всё контент уже в
|
||||||
|
* памяти, < 50 КБ).
|
||||||
|
*
|
||||||
|
* Группы: «Начало работы», «Каталог», «Документы», «Безопасность»,
|
||||||
|
* «Интеграции», «Фичи» — берутся из front-matter `group:`.
|
||||||
|
*/
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useLocation } from 'react-router-dom'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
|
import { TextInput } from '@/components/Field'
|
||||||
|
|
||||||
|
interface Topic {
|
||||||
|
slug: string
|
||||||
|
title: string
|
||||||
|
group: string
|
||||||
|
order: number
|
||||||
|
body: string
|
||||||
|
html: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// import.meta.glob: загружаем все .md eager как сырой текст.
|
||||||
|
const modules = import.meta.glob('/src/help/*.md', { eager: true, query: '?raw', import: 'default' }) as Record<string, string>
|
||||||
|
|
||||||
|
function parseTopic(path: string, raw: string): Topic {
|
||||||
|
const slug = path.replace(/^.*\/(.+)\.md$/, '$1')
|
||||||
|
// front-matter: --- ... ---
|
||||||
|
let title = slug
|
||||||
|
let group = 'other'
|
||||||
|
let order = 99
|
||||||
|
let body = raw
|
||||||
|
const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
|
||||||
|
if (fmMatch) {
|
||||||
|
body = fmMatch[2] ?? ''
|
||||||
|
const fm = fmMatch[1] ?? ''
|
||||||
|
const t = fm.match(/^title:\s*(.+)$/m); if (t) title = t[1]!.trim()
|
||||||
|
const g = fm.match(/^group:\s*(.+)$/m); if (g) group = g[1]!.trim()
|
||||||
|
const o = fm.match(/^order:\s*(\d+)$/m); if (o) order = Number(o[1])
|
||||||
|
}
|
||||||
|
return { slug, title, group, order, body, html: renderMd(body) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Минимальный markdown-рендер: headings, lists, paragraphs, inline code.
|
||||||
|
* Не для произвольного MD — для наших help-файлов хватает. */
|
||||||
|
function renderMd(md: string): string {
|
||||||
|
// Сначала экранируем HTML-спец-символы чтобы не было XSS.
|
||||||
|
let s = md
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
// Headings.
|
||||||
|
s = s.replace(/^#### (.+)$/gm, '<h4 class="font-semibold mt-4 mb-2">$1</h4>')
|
||||||
|
.replace(/^### (.+)$/gm, '<h3 class="font-semibold text-base mt-5 mb-2">$1</h3>')
|
||||||
|
.replace(/^## (.+)$/gm, '<h2 class="font-semibold text-lg mt-6 mb-3">$1</h2>')
|
||||||
|
.replace(/^# (.+)$/gm, '<h1 class="font-bold text-xl mb-4 mt-2">$1</h1>')
|
||||||
|
// Списки.
|
||||||
|
s = s.replace(/(?:^- .+(?:\n|$))+/gm, (block) => {
|
||||||
|
const items = block.trim().split('\n').map(l => `<li>${l.replace(/^- /, '')}</li>`).join('')
|
||||||
|
return `<ul class="list-disc pl-6 my-3 space-y-1">${items}</ul>`
|
||||||
|
})
|
||||||
|
// Bold **text**.
|
||||||
|
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||||
|
// Inline code `text`.
|
||||||
|
s = s.replace(/`([^`]+)`/g, '<code class="bg-slate-100 dark:bg-slate-800 px-1 py-0.5 rounded text-xs">$1</code>')
|
||||||
|
// Параграфы — двойной перевод строки.
|
||||||
|
s = s.split(/\n{2,}/).map(p => {
|
||||||
|
if (p.startsWith('<')) return p
|
||||||
|
return `<p class="my-2 leading-relaxed">${p}</p>`
|
||||||
|
}).join('\n')
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROUP_LABELS: Record<string, string> = {
|
||||||
|
start: 'Начало работы',
|
||||||
|
catalog: 'Каталог',
|
||||||
|
documents: 'Документы',
|
||||||
|
features: 'Фичи',
|
||||||
|
security: 'Безопасность',
|
||||||
|
integrations: 'Интеграции',
|
||||||
|
other: 'Прочее',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HelpPage() {
|
||||||
|
const location = useLocation()
|
||||||
|
const topics = useMemo(() => {
|
||||||
|
const list = Object.entries(modules).map(([p, raw]) => parseTopic(p, raw))
|
||||||
|
return list.sort((a, b) => a.order - b.order || a.title.localeCompare(b.title))
|
||||||
|
}, [])
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!query.trim()) return topics
|
||||||
|
const q = query.toLowerCase()
|
||||||
|
return topics.filter(t =>
|
||||||
|
t.title.toLowerCase().includes(q) ||
|
||||||
|
t.body.toLowerCase().includes(q),
|
||||||
|
)
|
||||||
|
}, [topics, query])
|
||||||
|
|
||||||
|
// Если в URL есть hash (#slug), скролл к этому топику.
|
||||||
|
const activeSlug = location.hash.replace(/^#/, '')
|
||||||
|
|
||||||
|
// Группируем для index'a (sidebar).
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const byGroup: Record<string, Topic[]> = {}
|
||||||
|
for (const t of filtered) (byGroup[t.group] ??= []).push(t)
|
||||||
|
return byGroup
|
||||||
|
}, [filtered])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-auto">
|
||||||
|
<div className="max-w-5xl mx-auto p-4 sm:p-6 space-y-5">
|
||||||
|
<PageHeader
|
||||||
|
title="База знаний"
|
||||||
|
description="Документация по фичам с поиском. Cmd/Ctrl+K-палитра ищет здесь же."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||||
|
<TextInput
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Поиск по темам…"
|
||||||
|
className="pl-9"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Object.entries(groups).length === 0 && (
|
||||||
|
<p className="text-slate-500 text-sm">Ничего не найдено по «{query}»</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||||
|
{/* Sidebar nav */}
|
||||||
|
<nav className="space-y-4 lg:col-span-1">
|
||||||
|
{Object.entries(groups).map(([g, ts]) => (
|
||||||
|
<div key={g}>
|
||||||
|
<div className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400 mb-1">
|
||||||
|
{GROUP_LABELS[g] ?? g}
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1 text-sm">
|
||||||
|
{ts.map(t => (
|
||||||
|
<li key={t.slug}>
|
||||||
|
<a
|
||||||
|
href={`#${t.slug}`}
|
||||||
|
className={
|
||||||
|
`block px-2 py-1 rounded hover:bg-slate-100 dark:hover:bg-slate-800 ` +
|
||||||
|
(activeSlug === t.slug ? 'bg-slate-100 dark:bg-slate-800 font-semibold' : '')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t.title}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Topics body */}
|
||||||
|
<article className="lg:col-span-3 space-y-8">
|
||||||
|
{filtered.map(t => (
|
||||||
|
<section
|
||||||
|
key={t.slug}
|
||||||
|
id={t.slug}
|
||||||
|
className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5 sm:p-6"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="prose prose-slate dark:prose-invert max-w-none text-sm"
|
||||||
|
dangerouslySetInnerHTML={{ __html: t.html }}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
324
src/food-market.web/src/pages/OnboardingWizardPage.tsx
Normal file
|
|
@ -0,0 +1,324 @@
|
||||||
|
/**
|
||||||
|
* Sprint 17: First-run wizard за 4 шага.
|
||||||
|
*
|
||||||
|
* 1. Магазин: подтверждение названия + адрес (Optional).
|
||||||
|
* 2. Первый товар (имя + цена + опц. штрихкод) или
|
||||||
|
* «Импортировать из МойСклад» / «Загрузить CSV» / «Пропустить».
|
||||||
|
* 3. Первый сотрудник (email + роль) или «Сделать позже».
|
||||||
|
* 4. Demo-data: «Заполнить пробными данными за месяц» / «Не нужно».
|
||||||
|
*
|
||||||
|
* Каждый шаг можно skip'нуть (нижняя ссылка). По завершении —
|
||||||
|
* navigate на /dashboard и `localStorage.wizardCompleted=1`.
|
||||||
|
*
|
||||||
|
* Без heavy form-library — простые controlled-input'ы. Form-validation
|
||||||
|
* минимальная (только required-field check).
|
||||||
|
*/
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { CheckCircle2, ChevronRight, ChevronLeft, Store, Package, UserPlus, Sparkles, Database, Upload } from 'lucide-react'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
|
import { Button } from '@/components/Button'
|
||||||
|
import { Field, TextInput, Select } from '@/components/Field'
|
||||||
|
import { toast } from '@/lib/toast'
|
||||||
|
|
||||||
|
type Step = 1 | 2 | 3 | 4
|
||||||
|
|
||||||
|
const TOTAL_STEPS = 4
|
||||||
|
|
||||||
|
export function OnboardingWizardPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [step, setStep] = useState<Step>(1)
|
||||||
|
|
||||||
|
// Если юзер уже прошёл wizard — редирект на dashboard.
|
||||||
|
useEffect(() => {
|
||||||
|
if (localStorage.getItem('fm.wizardCompleted') === '1') {
|
||||||
|
navigate('/dashboard', { replace: true })
|
||||||
|
}
|
||||||
|
}, [navigate])
|
||||||
|
|
||||||
|
function finish() {
|
||||||
|
localStorage.setItem('fm.wizardCompleted', '1')
|
||||||
|
navigate('/dashboard', { replace: true })
|
||||||
|
}
|
||||||
|
function skip() {
|
||||||
|
if (step < TOTAL_STEPS) setStep((step + 1) as Step)
|
||||||
|
else finish()
|
||||||
|
}
|
||||||
|
function next() { setStep((Math.min(step + 1, TOTAL_STEPS)) as Step) }
|
||||||
|
function prev() { setStep((Math.max(step - 1, 1)) as Step) }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-auto bg-slate-50 dark:bg-slate-950">
|
||||||
|
<div className="max-w-3xl mx-auto p-4 sm:p-6 space-y-5">
|
||||||
|
<PageHeader
|
||||||
|
title="Добро пожаловать в Food Market"
|
||||||
|
description={`Шаг ${step} из ${TOTAL_STEPS}. Пройдите за 2-3 минуты или пропустите.`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{[1, 2, 3, 4].map((n) => (
|
||||||
|
<div key={n} className={
|
||||||
|
`flex-1 h-1.5 rounded-full ` +
|
||||||
|
(n <= step ? 'bg-[var(--color-brand)]' : 'bg-slate-200 dark:bg-slate-800')
|
||||||
|
} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-6 space-y-4">
|
||||||
|
{step === 1 && <StepStore onNext={next} onSkip={skip} />}
|
||||||
|
{step === 2 && <StepProduct onNext={next} onSkip={skip} />}
|
||||||
|
{step === 3 && <StepEmployee onNext={next} onSkip={skip} />}
|
||||||
|
{step === 4 && <StepDemoSeed onFinish={finish} onSkip={finish} />}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{step > 1 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<button onClick={prev} className="text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 inline-flex items-center gap-1">
|
||||||
|
<ChevronLeft className="w-4 h-4" /> Назад
|
||||||
|
</button>
|
||||||
|
<button onClick={skip} className="text-slate-500 hover:text-slate-700 dark:hover:text-slate-300">
|
||||||
|
Пропустить →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 1: магазин ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function StepStore({ onNext, onSkip }: { onNext: () => void; onSkip: () => void }) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const current = useQuery<any>({
|
||||||
|
queryKey: ['/api/organization/settings'],
|
||||||
|
queryFn: async () => (await api.get('/api/organization/settings')).data,
|
||||||
|
})
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [address, setAddress] = useState('')
|
||||||
|
useEffect(() => {
|
||||||
|
if (current.data) {
|
||||||
|
setName(current.data.name ?? '')
|
||||||
|
setAddress(current.data.address ?? '')
|
||||||
|
}
|
||||||
|
}, [current.data])
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!current.data) return
|
||||||
|
const payload = { ...current.data, name, address: address || null }
|
||||||
|
return (await api.put('/api/organization/settings', payload)).data
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['/api/organization/settings'] })
|
||||||
|
toast.success('Настройки магазина сохранены')
|
||||||
|
onNext()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2 className="font-semibold text-lg inline-flex items-center gap-2"><Store className="w-5 h-5 text-[var(--color-brand)]" /> Магазин</h2>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
Подтвердите название магазина и при желании укажите адрес. Это видно на квитанциях и в отчётах.
|
||||||
|
</p>
|
||||||
|
<Field label="Название магазина *">
|
||||||
|
<TextInput value={name} onChange={(e) => setName(e.target.value)} placeholder="например, Family Market" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Адрес (опционально)">
|
||||||
|
<TextInput value={address} onChange={(e) => setAddress(e.target.value)} placeholder="Алматы, ул. Абая 1" />
|
||||||
|
</Field>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="secondary" onClick={onSkip}>Пропустить</Button>
|
||||||
|
<Button onClick={() => save.mutate()} disabled={!name.trim() || save.isPending}>
|
||||||
|
{save.isPending ? 'Сохраняю…' : 'Дальше'} <ChevronRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 2: первый товар ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
function StepProduct({ onNext, onSkip }: { onNext: () => void; onSkip: () => void }) {
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [price, setPrice] = useState('100')
|
||||||
|
const [barcode, setBarcode] = useState('')
|
||||||
|
|
||||||
|
// Загружаем refs (unit/group/price-type/currency) — нужны для создания товара.
|
||||||
|
const refs = useQuery<any>({
|
||||||
|
queryKey: ['onboarding-refs'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const [units, groups, pts, curs] = await Promise.all([
|
||||||
|
api.get('/api/catalog/units-of-measure?pageSize=50'),
|
||||||
|
api.get('/api/catalog/product-groups?pageSize=50'),
|
||||||
|
api.get('/api/catalog/price-types?pageSize=50'),
|
||||||
|
api.get('/api/catalog/currencies?pageSize=50'),
|
||||||
|
])
|
||||||
|
return {
|
||||||
|
unit: units.data.items.find((u: any) => u.code === '796') ?? units.data.items[0],
|
||||||
|
group: groups.data.items[0],
|
||||||
|
priceType: pts.data.items.find((p: any) => p.isRetail) ?? pts.data.items[0],
|
||||||
|
currency: curs.data.items.find((c: any) => c.code === 'KZT') ?? curs.data.items[0],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!refs.data) throw new Error('refs not loaded')
|
||||||
|
const r = refs.data
|
||||||
|
return (await api.post('/api/catalog/products', {
|
||||||
|
name: name.trim(),
|
||||||
|
article: `ART-${Date.now()}`,
|
||||||
|
unitOfMeasureId: r.unit.id,
|
||||||
|
vat: 12, vatEnabled: true,
|
||||||
|
productGroupId: r.group.id,
|
||||||
|
packaging: 1,
|
||||||
|
prices: [{ priceTypeId: r.priceType.id, amount: Number(price), currencyId: r.currency.id }],
|
||||||
|
barcodes: barcode ? [{ code: barcode, type: 1, isPrimary: true }] : [],
|
||||||
|
})).data
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(`Товар «${name}» создан`)
|
||||||
|
onNext()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2 className="font-semibold text-lg inline-flex items-center gap-2"><Package className="w-5 h-5 text-[var(--color-brand)]" /> Первый товар</h2>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
Заведите один товар для теста — или импортируйте каталог из МойСклад/CSV.
|
||||||
|
</p>
|
||||||
|
<Field label="Название *">
|
||||||
|
<TextInput value={name} onChange={(e) => setName(e.target.value)} placeholder="например, Молоко 3.2%" />
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Розничная цена">
|
||||||
|
<TextInput type="number" inputMode="numeric" value={price}
|
||||||
|
onChange={(e) => setPrice(e.target.value)} placeholder="100" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Штрихкод (опц.)">
|
||||||
|
<TextInput value={barcode} onChange={(e) => setBarcode(e.target.value)} placeholder="4607034521024" />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-slate-200 dark:border-slate-800 pt-3 mt-3 text-sm">
|
||||||
|
<div className="text-slate-500 mb-2">Или массовый импорт:</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button variant="secondary" onClick={() => window.location.href = '/admin/import/moysklad'}>
|
||||||
|
<Database className="w-4 h-4" /> Импорт из МойСклад
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" disabled title="CSV-импорт — в следующих версиях">
|
||||||
|
<Upload className="w-4 h-4" /> Загрузить CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="secondary" onClick={onSkip}>Пропустить</Button>
|
||||||
|
<Button onClick={() => create.mutate()} disabled={!name.trim() || !refs.data || create.isPending}>
|
||||||
|
{create.isPending ? 'Создаю…' : 'Дальше'} <ChevronRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 3: первый сотрудник ─────────────────────────────────────────
|
||||||
|
|
||||||
|
function StepEmployee({ onNext, onSkip }: { onNext: () => void; onSkip: () => void }) {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [firstName, setFirstName] = useState('')
|
||||||
|
const [lastName, setLastName] = useState('')
|
||||||
|
const [roleId, setRoleId] = useState('')
|
||||||
|
const roles = useQuery<any>({
|
||||||
|
queryKey: ['onboarding-roles'],
|
||||||
|
queryFn: async () => (await api.get('/api/organization/employee-roles?pageSize=50')).data,
|
||||||
|
})
|
||||||
|
useEffect(() => {
|
||||||
|
// По умолчанию выбираем «Кассир» если есть.
|
||||||
|
if (roles.data && !roleId) {
|
||||||
|
const cashier = roles.data.items?.find((r: any) => /кассир/i.test(r.name))
|
||||||
|
const fallback = roles.data.items?.[0]
|
||||||
|
setRoleId(cashier?.id ?? fallback?.id ?? '')
|
||||||
|
}
|
||||||
|
}, [roles.data, roleId])
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
return (await api.post('/api/organization/employees', {
|
||||||
|
firstName, lastName, email, roleId,
|
||||||
|
isActive: true,
|
||||||
|
createAccount: false, // приглашение через email отдельно
|
||||||
|
})).data
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(`Сотрудник «${firstName} ${lastName}» добавлен`)
|
||||||
|
onNext()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2 className="font-semibold text-lg inline-flex items-center gap-2"><UserPlus className="w-5 h-5 text-[var(--color-brand)]" /> Первый сотрудник</h2>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
Добавьте кассира или администратора — позже можно отправить им приглашение по email.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Фамилия">
|
||||||
|
<TextInput value={lastName} onChange={(e) => setLastName(e.target.value)} placeholder="Иванов" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Имя">
|
||||||
|
<TextInput value={firstName} onChange={(e) => setFirstName(e.target.value)} placeholder="Иван" />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field label="Email">
|
||||||
|
<TextInput type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="cashier@example.com" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Роль">
|
||||||
|
<Select value={roleId} onChange={(e) => setRoleId(e.target.value)}>
|
||||||
|
{roles.data?.items?.map((r: any) => (
|
||||||
|
<option key={r.id} value={r.id}>{r.name}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="secondary" onClick={onSkip}>Сделать позже</Button>
|
||||||
|
<Button onClick={() => create.mutate()} disabled={!lastName.trim() || !roleId || create.isPending}>
|
||||||
|
{create.isPending ? 'Создаю…' : 'Дальше'} <ChevronRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 4: demo data ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function StepDemoSeed({ onFinish, onSkip }: { onFinish: () => void; onSkip: () => void }) {
|
||||||
|
const seed = useMutation({
|
||||||
|
mutationFn: async () => (await api.post('/api/admin/seed-demo')).data,
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Демо-данные за месяц созданы')
|
||||||
|
onFinish()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2 className="font-semibold text-lg inline-flex items-center gap-2"><Sparkles className="w-5 h-5 text-amber-500" /> Пробные данные</h2>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
Заполнить ваш магазин реалистичными данными за месяц (50 товаров, 30 продаж, 5 приёмок).
|
||||||
|
Это поможет посмотреть отчёты и виджеты в реальном виде. Можно удалить позже.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
|
<Button variant="secondary" onClick={onSkip}>Не нужно</Button>
|
||||||
|
<Button onClick={() => seed.mutate()} disabled={seed.isPending}>
|
||||||
|
{seed.isPending ? 'Создаю демо…' : 'Заполнить и завершить'} <CheckCircle2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
84
src/food-market.web/src/pages/WhatsNewPage.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
/**
|
||||||
|
* Sprint 17: /whats-new — список новых фич за 30 дней.
|
||||||
|
*
|
||||||
|
* Контент берётся из `/api/whats-new` (читает CHANGELOG.md в content-root).
|
||||||
|
* После просмотра сохраняем `localStorage.fm.lastSeenBuildVersion` —
|
||||||
|
* Layout-level баннер «Появились новые функции» больше не показывается.
|
||||||
|
*/
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Sparkles, Bug, Zap } from 'lucide-react'
|
||||||
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
|
||||||
|
interface WhatsNewItem {
|
||||||
|
date: string
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
type: 'feat' | 'fix' | 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WhatsNewResponse {
|
||||||
|
buildVersion: string
|
||||||
|
items: WhatsNewItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WhatsNewPage() {
|
||||||
|
const { data, isLoading } = useQuery<WhatsNewResponse>({
|
||||||
|
queryKey: ['/api/whats-new'],
|
||||||
|
queryFn: async () => (await api.get<WhatsNewResponse>('/api/whats-new')).data,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.buildVersion) {
|
||||||
|
localStorage.setItem('fm.lastSeenBuildVersion', data.buildVersion)
|
||||||
|
}
|
||||||
|
}, [data?.buildVersion])
|
||||||
|
|
||||||
|
// Группируем items по дате.
|
||||||
|
const byDate = (data?.items ?? []).reduce((acc, it) => {
|
||||||
|
(acc[it.date] ??= []).push(it)
|
||||||
|
return acc
|
||||||
|
}, {} as Record<string, WhatsNewItem[]>)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-auto">
|
||||||
|
<div className="max-w-3xl mx-auto p-4 sm:p-6 space-y-5">
|
||||||
|
<PageHeader
|
||||||
|
title="Что нового"
|
||||||
|
description={data?.buildVersion ? `Сборка: ${data.buildVersion}` : 'Список фич за последние 30 дней.'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading && <p className="text-slate-500 text-sm">Загружаю…</p>}
|
||||||
|
{!isLoading && Object.keys(byDate).length === 0 && (
|
||||||
|
<p className="text-slate-500 text-sm">Пока ничего нового — CHANGELOG.md пустой или endpoint не отвечает.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{Object.entries(byDate).map(([date, items]) => (
|
||||||
|
<section key={date} className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5">
|
||||||
|
<h2 className="font-semibold text-sm text-slate-500 dark:text-slate-400 mb-3">{date}</h2>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{items.map((it, i) => (
|
||||||
|
<li key={i} className="flex items-start gap-2 text-sm">
|
||||||
|
<IconFor type={it.type} />
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">{it.title}</span>
|
||||||
|
{it.body && <p className="text-slate-600 dark:text-slate-400 text-xs mt-0.5">{it.body}</p>}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconFor({ type }: { type: WhatsNewItem['type'] }) {
|
||||||
|
if (type === 'feat') return <Sparkles className="w-4 h-4 text-emerald-600 mt-0.5 shrink-0" aria-label="feat" />
|
||||||
|
if (type === 'fix') return <Bug className="w-4 h-4 text-blue-600 mt-0.5 shrink-0" aria-label="fix" />
|
||||||
|
return <Zap className="w-4 h-4 text-slate-400 mt-0.5 shrink-0" aria-label="other" />
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 75 KiB |
105
tests/regression/flows/09-onboarding-wizard.spec.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
/**
|
||||||
|
* Sprint 17 — flow 09 onboarding wizard (4 шага):
|
||||||
|
* 9.1 wizard рендерится на /onboarding-wizard и показывает 4 шага через progress-bar
|
||||||
|
* 9.2 skip всех шагов → /dashboard + localStorage.fm.wizardCompleted=1
|
||||||
|
* 9.3 шаг 1: сохранение названия магазина обновляет org.name
|
||||||
|
* 9.4 /help рендерит markdown topics + поиск
|
||||||
|
* 9.5 /admin/diagnostic возвращает 7 проверок (sendTestEmail=false)
|
||||||
|
* 9.6 POST /api/feedback с минимальным payload → ok
|
||||||
|
* 9.7 /api/whats-new возвращает массив items
|
||||||
|
*/
|
||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
import { OrgFactory } from '../factories/OrgFactory.js'
|
||||||
|
import { request } from '../factories/api-client.js'
|
||||||
|
import { attachSession } from '../lib/ui.js'
|
||||||
|
|
||||||
|
test.describe('flow 09 — onboarding wizard + help + diagnostic + feedback + whats-new', () => {
|
||||||
|
test('9.1 wizard рендерится с 4-шаговым progress-bar @smoke', async ({ page }) => {
|
||||||
|
const b = await OrgFactory.for('wiz91').build()
|
||||||
|
await attachSession(page, b.session, '/onboarding-wizard')
|
||||||
|
await expect(page.getByText(/Шаг 1 из 4/)).toBeVisible()
|
||||||
|
await expect(page.getByRole('heading', { name: /Магазин/i })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('9.2 skip всех 4 шагов → /dashboard + wizardCompleted', async ({ page }) => {
|
||||||
|
const b = await OrgFactory.for('wiz92').build()
|
||||||
|
await attachSession(page, b.session, '/onboarding-wizard')
|
||||||
|
// Шаг 1: «Пропустить» (две кнопки — основная и нижняя; используем основную в footer'е модала).
|
||||||
|
await page.getByRole('button', { name: 'Пропустить', exact: true }).first().click()
|
||||||
|
// Шаг 2.
|
||||||
|
await expect(page.getByText(/Шаг 2 из 4/)).toBeVisible()
|
||||||
|
await page.getByRole('button', { name: 'Пропустить', exact: true }).first().click()
|
||||||
|
// Шаг 3 — кнопка «Сделать позже».
|
||||||
|
await expect(page.getByText(/Шаг 3 из 4/)).toBeVisible()
|
||||||
|
await page.getByRole('button', { name: /Сделать позже/i }).click()
|
||||||
|
// Шаг 4 — «Не нужно» завершает.
|
||||||
|
await expect(page.getByText(/Шаг 4 из 4/)).toBeVisible()
|
||||||
|
await page.getByRole('button', { name: /Не нужно/i }).click()
|
||||||
|
await page.waitForURL(/\/dashboard/, { timeout: 10_000 })
|
||||||
|
const flag = await page.evaluate(() => localStorage.getItem('fm.wizardCompleted'))
|
||||||
|
expect(flag).toBe('1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('9.3 шаг 1: сохранение названия магазина обновляет org', async ({ page }) => {
|
||||||
|
const b = await OrgFactory.for('wiz93').build()
|
||||||
|
await attachSession(page, b.session, '/onboarding-wizard')
|
||||||
|
const newName = `WizardOrg-${Date.now()}`
|
||||||
|
await page.getByLabel(/Название магазина/).fill(newName)
|
||||||
|
await page.getByRole('button', { name: /Дальше/i }).click()
|
||||||
|
await expect(page.getByText(/Шаг 2 из 4/)).toBeVisible()
|
||||||
|
// Проверяем что сохранилось.
|
||||||
|
const settings = await request<{ name: string }>(
|
||||||
|
'/api/organization/settings', { token: b.session.accessToken },
|
||||||
|
)
|
||||||
|
expect(settings.name).toBe(newName)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('9.4 /help рендерит markdown topics с поиском @smoke', async ({ page }) => {
|
||||||
|
const b = await OrgFactory.for('wiz94').build()
|
||||||
|
await attachSession(page, b.session, '/help')
|
||||||
|
await expect(page.getByRole('heading', { name: /База знаний/i })).toBeVisible()
|
||||||
|
// Должны быть основные топики.
|
||||||
|
await expect(page.getByText(/Начало работы/i).first()).toBeVisible()
|
||||||
|
await expect(page.getByText(/Каталог/i).first()).toBeVisible()
|
||||||
|
// Поиск.
|
||||||
|
await page.getByPlaceholder(/Поиск по темам/).fill('лояльность')
|
||||||
|
await expect(page.getByText(/Программы скидок|Лояльность/i).first()).toBeVisible({ timeout: 3_000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('9.5 /api/admin/diagnostic/run возвращает 7 проверок @smoke', async () => {
|
||||||
|
const b = await OrgFactory.for('wiz95').build()
|
||||||
|
const r = await request<{ overall: string; checks: Array<{ name: string; status: string }>; ranAt: string }>(
|
||||||
|
'/api/admin/diagnostic/run?sendTestEmail=false', { token: b.session.accessToken },
|
||||||
|
)
|
||||||
|
expect(r.checks.length).toBeGreaterThanOrEqual(7)
|
||||||
|
const names = r.checks.map(c => c.name)
|
||||||
|
expect(names).toContain('Database')
|
||||||
|
expect(names).toContain('SMTP')
|
||||||
|
expect(names).toContain('Hangfire')
|
||||||
|
expect(names).toContain('Disk')
|
||||||
|
expect(names).toContain('Backup')
|
||||||
|
// overall должен быть Ok / Warning / Fail / Skipped.
|
||||||
|
expect(['Ok', 'Warning', 'Fail', 'Skipped']).toContain(r.overall)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('9.6 POST /api/feedback принимает минимальный payload', async () => {
|
||||||
|
const b = await OrgFactory.for('wiz96').build()
|
||||||
|
const r = await request<{ ok: boolean }>(
|
||||||
|
'/api/feedback',
|
||||||
|
{
|
||||||
|
token: b.session.accessToken,
|
||||||
|
body: { category: 1, message: 'Тестовое сообщение из regression test' },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
expect(r.ok).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('9.7 /api/whats-new возвращает buildVersion + items', async () => {
|
||||||
|
const b = await OrgFactory.for('wiz97').build()
|
||||||
|
const r = await request<{ buildVersion: string; items: Array<{ date: string; title: string; type: string }> }>(
|
||||||
|
'/api/whats-new', { token: b.session.accessToken },
|
||||||
|
)
|
||||||
|
expect(typeof r.buildVersion).toBe('string')
|
||||||
|
expect(Array.isArray(r.items)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
66
tests/regression/visual/03-wizard-screenshots.spec.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
/**
|
||||||
|
* Sprint 17: визуальные screenshots каждого шага wizard'а для отчёта.
|
||||||
|
*
|
||||||
|
* Шаги 1-4 captured как `wizard-step-N.png`. Используется один build()
|
||||||
|
* на весь файл — caпture'ы быстрые.
|
||||||
|
*/
|
||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
import { OrgFactory, type BuiltOrg } from '../factories/OrgFactory.js'
|
||||||
|
import { attachSession } from '../lib/ui.js'
|
||||||
|
|
||||||
|
let built: BuiltOrg
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
built = await OrgFactory.for('wiz-shots').build()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('wizard step 1 (магазин)', async ({ page }) => {
|
||||||
|
await attachSession(page, built.session, '/onboarding-wizard')
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
await expect(page).toHaveScreenshot('wizard-step-1.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('wizard step 2 (товар)', async ({ page }) => {
|
||||||
|
await attachSession(page, built.session, '/onboarding-wizard')
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
await page.getByRole('button', { name: 'Пропустить', exact: true }).first().click()
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
await expect(page).toHaveScreenshot('wizard-step-2.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('wizard step 3 (сотрудник)', async ({ page }) => {
|
||||||
|
await attachSession(page, built.session, '/onboarding-wizard')
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
await page.getByRole('button', { name: 'Пропустить', exact: true }).first().click()
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
await page.getByRole('button', { name: 'Пропустить', exact: true }).first().click()
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
await expect(page).toHaveScreenshot('wizard-step-3.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('wizard step 4 (demo-данные)', async ({ page }) => {
|
||||||
|
await attachSession(page, built.session, '/onboarding-wizard')
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
await page.getByRole('button', { name: 'Пропустить', exact: true }).first().click()
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
await page.getByRole('button', { name: 'Пропустить', exact: true }).first().click()
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
await page.getByRole('button', { name: /Сделать позже/i }).click()
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
await expect(page).toHaveScreenshot('wizard-step-4.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('help page', async ({ page }) => {
|
||||||
|
await attachSession(page, built.session, '/help')
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
await expect(page).toHaveScreenshot('help-page.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('admin diagnostic page', async ({ page }) => {
|
||||||
|
await attachSession(page, built.session, '/admin/diagnostic')
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
// Запускаем check'и для screenshot'a с реальным результатом.
|
||||||
|
await page.getByRole('button', { name: /Запустить/i }).click()
|
||||||
|
await page.waitForTimeout(2000)
|
||||||
|
await expect(page).toHaveScreenshot('admin-diagnostic.png', { fullPage: true })
|
||||||
|
})
|
||||||