+ Подтвердите название магазина и при желании укажите адрес. Это видно на квитанциях и в отчётах.
+
+ ({
+ 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 (
+ <>
+ Первый товар
+
+ Заведите один товар для теста — или импортируйте каталог из МойСклад/CSV.
+
+
+ setName(e.target.value)} placeholder="например, Молоко 3.2%" />
+
+
+
+ setPrice(e.target.value)} placeholder="100" />
+
+
+ setBarcode(e.target.value)} placeholder="4607034521024" />
+
+
+
+
Или массовый импорт:
+
+ window.location.href = '/admin/import/moysklad'}>
+ Импорт из МойСклад
+
+
+ Загрузить CSV
+
+
+
+
+ Пропустить
+ create.mutate()} disabled={!name.trim() || !refs.data || create.isPending}>
+ {create.isPending ? 'Создаю…' : 'Дальше'}
+
+
+ >
+ )
+}
+
+// ── 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({
+ 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 (
+ <>
+ Первый сотрудник
+
+ Добавьте кассира или администратора — позже можно отправить им приглашение по email.
+
+
+
+ setLastName(e.target.value)} placeholder="Иванов" />
+
+
+ setFirstName(e.target.value)} placeholder="Иван" />
+
+
+
+ setEmail(e.target.value)} placeholder="cashier@example.com" />
+
+
+ setRoleId(e.target.value)}>
+ {roles.data?.items?.map((r: any) => (
+ {r.name}
+ ))}
+
+
+
+ Сделать позже
+ create.mutate()} disabled={!lastName.trim() || !roleId || create.isPending}>
+ {create.isPending ? 'Создаю…' : 'Дальше'}
+
+
+ >
+ )
+}
+
+// ── 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 (
+ <>
+ Пробные данные
+
+ Заполнить ваш магазин реалистичными данными за месяц (50 товаров, 30 продаж, 5 приёмок).
+ Это поможет посмотреть отчёты и виджеты в реальном виде. Можно удалить позже.
+
+
+ Не нужно
+ seed.mutate()} disabled={seed.isPending}>
+ {seed.isPending ? 'Создаю демо…' : 'Заполнить и завершить'}
+
+
+ >
+ )
+}
diff --git a/src/food-market.web/src/pages/WhatsNewPage.tsx b/src/food-market.web/src/pages/WhatsNewPage.tsx
new file mode 100644
index 0000000..d2e2590
--- /dev/null
+++ b/src/food-market.web/src/pages/WhatsNewPage.tsx
@@ -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({
+ queryKey: ['/api/whats-new'],
+ queryFn: async () => (await api.get('/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)
+
+ return (
+
+
+
+
+ {isLoading &&
Загружаю…
}
+ {!isLoading && Object.keys(byDate).length === 0 && (
+
Пока ничего нового — CHANGELOG.md пустой или endpoint не отвечает.
+ )}
+
+
+ {Object.entries(byDate).map(([date, items]) => (
+
+ {date}
+
+ {items.map((it, i) => (
+
+
+
+
{it.title}
+ {it.body &&
{it.body}
}
+
+
+ ))}
+
+
+ ))}
+
+
+
+ )
+}
+
+function IconFor({ type }: { type: WhatsNewItem['type'] }) {
+ if (type === 'feat') return
+ if (type === 'fix') return
+ return
+}
diff --git a/tests/regression/__screenshots__/desktop-chromium/03-wizard-screenshots.spec.ts/admin-diagnostic.png b/tests/regression/__screenshots__/desktop-chromium/03-wizard-screenshots.spec.ts/admin-diagnostic.png
new file mode 100644
index 0000000..319cbfc
Binary files /dev/null and b/tests/regression/__screenshots__/desktop-chromium/03-wizard-screenshots.spec.ts/admin-diagnostic.png differ
diff --git a/tests/regression/__screenshots__/desktop-chromium/03-wizard-screenshots.spec.ts/help-page.png b/tests/regression/__screenshots__/desktop-chromium/03-wizard-screenshots.spec.ts/help-page.png
new file mode 100644
index 0000000..4412b36
Binary files /dev/null and b/tests/regression/__screenshots__/desktop-chromium/03-wizard-screenshots.spec.ts/help-page.png differ
diff --git a/tests/regression/__screenshots__/desktop-chromium/03-wizard-screenshots.spec.ts/wizard-step-1.png b/tests/regression/__screenshots__/desktop-chromium/03-wizard-screenshots.spec.ts/wizard-step-1.png
new file mode 100644
index 0000000..1162333
Binary files /dev/null and b/tests/regression/__screenshots__/desktop-chromium/03-wizard-screenshots.spec.ts/wizard-step-1.png differ
diff --git a/tests/regression/__screenshots__/desktop-chromium/03-wizard-screenshots.spec.ts/wizard-step-2.png b/tests/regression/__screenshots__/desktop-chromium/03-wizard-screenshots.spec.ts/wizard-step-2.png
new file mode 100644
index 0000000..9f9d2eb
Binary files /dev/null and b/tests/regression/__screenshots__/desktop-chromium/03-wizard-screenshots.spec.ts/wizard-step-2.png differ
diff --git a/tests/regression/__screenshots__/desktop-chromium/03-wizard-screenshots.spec.ts/wizard-step-3.png b/tests/regression/__screenshots__/desktop-chromium/03-wizard-screenshots.spec.ts/wizard-step-3.png
new file mode 100644
index 0000000..cf3dc60
Binary files /dev/null and b/tests/regression/__screenshots__/desktop-chromium/03-wizard-screenshots.spec.ts/wizard-step-3.png differ
diff --git a/tests/regression/__screenshots__/desktop-chromium/03-wizard-screenshots.spec.ts/wizard-step-4.png b/tests/regression/__screenshots__/desktop-chromium/03-wizard-screenshots.spec.ts/wizard-step-4.png
new file mode 100644
index 0000000..96a6b98
Binary files /dev/null and b/tests/regression/__screenshots__/desktop-chromium/03-wizard-screenshots.spec.ts/wizard-step-4.png differ
diff --git a/tests/regression/flows/09-onboarding-wizard.spec.ts b/tests/regression/flows/09-onboarding-wizard.spec.ts
new file mode 100644
index 0000000..2ac7d42
--- /dev/null
+++ b/tests/regression/flows/09-onboarding-wizard.spec.ts
@@ -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)
+ })
+})
diff --git a/tests/regression/visual/03-wizard-screenshots.spec.ts b/tests/regression/visual/03-wizard-screenshots.spec.ts
new file mode 100644
index 0000000..db487af
--- /dev/null
+++ b/tests/regression/visual/03-wizard-screenshots.spec.ts
@@ -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 })
+})