feat(onboarding): welcome dashboard with first-steps cards

Дефолтная страница после логина (/) — OnboardingPage по образу
сторонняя система «Первые шаги». Старый DashboardPage с KPI и графиком
переехал на /dashboard, в меню «Главная» теперь два пункта:
«Главная» (онбординг) и «Аналитика» (KPI/графики).

useOnboardingProgress() — хук, считает 4 шага:
- orgConfigured: country + defaultCurrency установлены
- hasEmployees: > 1 сотрудник (помимо админа)
- hasProducts: > 0 товаров
- hasSupplies: > 0 приёмок

OnboardingPage:
- Прогресс-бар «N из 4 шагов» с процентом
- 4 карточки задач: Настройки → Сотрудники → Каталог/Импорт → Приёмка
- Каждая показывает иконку (CheckCircle2 если done) + бэйдж
  категории + заголовок + описание + CTA-кнопка с ArrowRight,
  меняющая текст и ссылку в зависимости от done.
- Когда все 4 шага сделаны — плашка «🎉 Готово!» + переход на
  /dashboard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-26 12:08:03 +05:00
parent f5cccb6f10
commit d3bcbee8b9
4 changed files with 192 additions and 2 deletions

View file

@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { LoginPage } from '@/pages/LoginPage' import { LoginPage } from '@/pages/LoginPage'
import { DashboardPage } from '@/pages/DashboardPage' import { DashboardPage } from '@/pages/DashboardPage'
import { OnboardingPage } from '@/pages/OnboardingPage'
import { CountriesPage } from '@/pages/CountriesPage' import { CountriesPage } from '@/pages/CountriesPage'
import { UnitsOfMeasurePage } from '@/pages/UnitsOfMeasurePage' import { UnitsOfMeasurePage } from '@/pages/UnitsOfMeasurePage'
import { PriceTypesPage } from '@/pages/PriceTypesPage' import { PriceTypesPage } from '@/pages/PriceTypesPage'
@ -41,7 +42,8 @@ export default function App() {
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route element={<ProtectedRoute />}> <Route element={<ProtectedRoute />}>
<Route element={<AppLayout />}> <Route element={<AppLayout />}>
<Route path="/" element={<DashboardPage />} /> <Route path="/" element={<OnboardingPage />} />
<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 />} />
<Route path="/catalog/products/:id" element={<ProductEditPage />} /> <Route path="/catalog/products/:id" element={<ProductEditPage />} />

View file

@ -31,7 +31,8 @@ function buildNav(): NavSection[] {
] ]
return [ return [
{ group: 'Главное', items: [ { group: 'Главное', items: [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard', end: true }, { to: '/', icon: LayoutDashboard, label: 'Главная', end: true },
{ to: '/dashboard', icon: LayoutDashboard, label: 'Аналитика' },
]}, ]},
{ group: 'Каталог', items: catalog }, { group: 'Каталог', items: catalog },
{ group: 'Контрагенты', items: [ { group: 'Контрагенты', items: [

View file

@ -0,0 +1,44 @@
import { useQuery } from '@tanstack/react-query'
import { api } from '@/lib/api'
import type { PagedResult } from '@/lib/types'
import { useOrgSettings } from '@/lib/useOrgSettings'
interface EmployeeRow { id: string; userId: string | null }
/** Прогресс по «первым шагам» новой организации: настройки + сотрудники +
* товары + первая приёмка. Используется в OnboardingPage. */
export function useOnboardingProgress() {
const org = useOrgSettings()
const employees = useQuery({
queryKey: ['onboarding:employees-count'],
queryFn: async () => (await api.get<PagedResult<EmployeeRow>>('/api/organization/employees?pageSize=1')).data.total,
staleTime: 60_000,
})
const products = useQuery({
queryKey: ['onboarding:products-count'],
queryFn: async () => (await api.get<PagedResult<unknown>>('/api/catalog/products?pageSize=1')).data.total,
staleTime: 60_000,
})
const supplies = useQuery({
queryKey: ['onboarding:supplies-count'],
queryFn: async () => (await api.get<PagedResult<unknown>>('/api/purchases/supplies?pageSize=1')).data.total,
staleTime: 60_000,
})
const orgConfigured = !!org.data?.defaultCurrencyId && !!org.data?.countryCode
const hasEmployees = (employees.data ?? 0) > 1 // больше одного admin'а
const hasProducts = (products.data ?? 0) > 0
const hasSupplies = (supplies.data ?? 0) > 0
const steps = { orgConfigured, hasEmployees, hasProducts, hasSupplies }
const done = Object.values(steps).filter(Boolean).length
const total = Object.keys(steps).length
return {
isLoading: org.isLoading || employees.isLoading || products.isLoading || supplies.isLoading,
...steps,
doneCount: done,
totalCount: total,
overall: total === 0 ? 0 : Math.round((done / total) * 100),
allDone: done === total,
}
}

View file

@ -0,0 +1,143 @@
import { Link } from 'react-router-dom'
import { Settings, UserCog, Package, ShoppingCart, ArrowRight, CheckCircle2, BarChart3 } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { useOnboardingProgress } from '@/lib/useOnboardingProgress'
interface StepCard {
done: boolean
badge: string
title: string
description: string
ctaLabel: string
ctaTo: string
ctaDisabled?: boolean
icon: React.ComponentType<{ className?: string }>
}
export function OnboardingPage() {
const p = useOnboardingProgress()
const cards: StepCard[] = [
{
done: p.orgConfigured,
badge: 'Запуск магазина',
title: 'Основные настройки бизнеса',
description: 'Укажите название, страну и валюту. От этого зависят валюта цен и ставка НДС в карточках товаров.',
ctaLabel: p.orgConfigured ? 'Изменить настройки' : 'Перейти к настройкам',
ctaTo: '/settings/organization',
icon: Settings,
},
{
done: p.hasEmployees,
badge: 'Команда магазина',
title: 'Добавьте кассира, кладовщика и других сотрудников',
description: 'Каждому сотруднику — своя роль с набором прав. Кассирам можно привязать конкретные кассы.',
ctaLabel: p.hasEmployees ? 'Управление сотрудниками' : 'Добавить сотрудников',
ctaTo: '/settings/employees',
icon: UserCog,
},
{
done: p.hasProducts,
badge: 'Каталог',
title: 'Заведите товары вручную или импортируйте из МойСклад',
description: 'Товары — основа всего. Импорт подтянет наименования, штрихкоды, цены и остатки одним нажатием.',
ctaLabel: p.hasProducts ? 'Перейти в каталог' : 'Добавить товары',
ctaTo: p.hasProducts ? '/catalog/products' : '/admin/import/moysklad',
icon: Package,
},
{
done: p.hasSupplies,
badge: 'Закупки',
title: 'Создайте первую приёмку товара',
description: 'Проведённая приёмка кладёт товар на склад и обновляет себестоимость. Сканируйте штрихкоды или ищите вручную.',
ctaLabel: p.hasSupplies ? 'К приёмкам' : 'Создать приёмку',
ctaTo: p.hasSupplies ? '/purchases/supplies' : '/purchases/supplies/new',
icon: ShoppingCart,
},
]
return (
<div className="h-full overflow-auto">
<div className="max-w-5xl mx-auto p-4 sm:p-6 space-y-5 sm:space-y-6">
<PageHeader
title="Первые шаги в Food Market"
description="Складской учёт, закупки и продажи в одной системе. Пройдите 4 шага — и можно работать."
/>
{/* Прогресс-плашка */}
<section className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-5">
<div className="flex items-center justify-between gap-3 mb-3">
<div>
<div className="text-sm text-slate-500">Готовность системы</div>
<div className="text-xl font-semibold">{p.doneCount} из {p.totalCount} шагов</div>
</div>
{p.allDone ? (
<Link
to="/dashboard"
className="inline-flex items-center gap-2 px-3 py-2 rounded-md bg-[var(--color-brand)] text-white text-sm font-medium hover:bg-[var(--color-brand-hover)]"
>
<BarChart3 className="w-4 h-4" /> Открыть дашборд
</Link>
) : (
<span className="text-2xl font-bold text-[var(--color-brand)]">{p.overall}%</span>
)}
</div>
<div className="h-2 rounded-full bg-slate-100 dark:bg-slate-800 overflow-hidden">
<div
className="h-full bg-[var(--color-brand)] transition-all"
style={{ width: `${p.overall}%` }}
/>
</div>
{p.allDone && (
<div className="mt-3 text-sm text-emerald-600 inline-flex items-center gap-1.5">
<CheckCircle2 className="w-4 h-4" /> 🎉 Готово! Система настроена и готова к работе.
</div>
)}
</section>
{/* Карточки шагов */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-5">
{cards.map((c) => (
<article
key={c.title}
className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-5 flex flex-col gap-3"
>
<div className="flex items-start gap-3">
<div className={`w-11 h-11 rounded-lg flex items-center justify-center flex-shrink-0
${c.done ? 'bg-[var(--color-brand-light)] text-[var(--color-brand)]' : 'bg-slate-100 dark:bg-slate-800 text-slate-500'}`}>
{c.done ? <CheckCircle2 className="w-6 h-6" /> : <c.icon className="w-6 h-6" />}
</div>
<div className="flex-1 min-w-0">
<div className="text-xs uppercase tracking-wide text-slate-500">{c.badge}</div>
<h3 className="text-base font-semibold mt-0.5">{c.title}</h3>
</div>
</div>
<p className="text-sm text-slate-600 dark:text-slate-400 flex-1">{c.description}</p>
{c.ctaDisabled ? (
<button disabled className="inline-flex items-center gap-1.5 self-start px-3 py-1.5 rounded-md border border-slate-200 dark:border-slate-700 text-sm text-slate-400 cursor-not-allowed">
{c.ctaLabel}
</button>
) : (
<Link
to={c.ctaTo}
className="inline-flex items-center gap-1.5 self-start px-3 py-1.5 rounded-md border border-slate-200 dark:border-slate-700 text-sm font-medium hover:bg-slate-50 dark:hover:bg-slate-800"
>
{c.ctaLabel} <ArrowRight className="w-3.5 h-3.5" />
</Link>
)}
</article>
))}
</div>
{/* Поддержка */}
<section className="rounded-xl border border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-800/30 p-5">
<h3 className="text-sm font-semibold mb-1">Помощь и поддержка</h3>
<p className="text-sm text-slate-600 dark:text-slate-400">
Возникли вопросы по настройке или работе системы? Напишите администратору
или загляните в документацию проекта на Forgejo.
</p>
</section>
</div>
</div>
)
}