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>
This commit is contained in:
nns 2026-06-07 17:04:26 +05:00
parent 1989db32bb
commit f56c6efab1
38 changed files with 2543 additions and 0 deletions

307
CHANGELOG.md Normal file
View 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)

View file

@ -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
View 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 есть, ждём
скрин-капсы / видео.

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

48
scripts/generate-changelog.sh Executable file
View 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 ""

View 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));
}
}
}

View 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;
}
}
}

View 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;
}
}

View file

@ -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>

View file

@ -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"

View 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>
)
}

View 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>
</>
)
}

View 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>
)
}

View file

@ -0,0 +1,21 @@
---
title: Каталог товаров
group: catalog
order: 1
---
# Каталог
Раздел «Каталог» включает:
- **Товары** — основные карточки с ценами, штрихкодами, изображениями.
- **Группы товаров** — иерархия для категоризации (Хлеб / Молочное / …).
- **Единицы измерения** — штука, кг, л (можно добавить свои).
- **Типы цен** — Розничная, Опт, Закупочная (системный «Розничная» создаётся при signup).
## Поиск штрихкода
В POS-приложении сканер штрихкода ищет товар по полю `barcode`. На веб-админке тот же поиск в `/api/search/global` через Cmd+K.
## Импорт из МойСклад
Перейдите «Каталог → Импорт МойСклад», вставьте ApiKey МойСклад — загрузятся товары, группы, контрагенты и остатки. Идемпотентно (повторный run — обновление).

View 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.

View 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 инвентаризация.
Идемпотентно — повторный запуск ничего не дублирует. Это помогает увидеть как выглядят отчёты и виджеты с реальными числами.

View 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.

View 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` после каждой продажи.

View file

@ -0,0 +1,21 @@
---
title: Лояльность и акции
group: features
order: 2
---
# Лояльность
Три типа программ:
- **Percentage** — фиксированный % скидки на чек.
- **FixedAmount** — фиксированная сумма скидки.
- **PointsAccrual** — баллы (% от чека) копятся на карту.
## Карты
Каждая карта = (программа, контрагент, номер карты). Номер уникален в рамках организации.
## Промокоды
Промокод — отдельная сущность. Может действовать на весь чек, на товары из групп или конкретные товары. Период действия, минимальная сумма чека и активность контролируются.

View 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'ом.

View 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: 'Описание ещё не написано.',
}
}

View 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" />
}

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// 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>
)
}

View 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>
</>
)
}

View 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" />
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View 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)
})
})

View 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 })
})