feat(s29): web — простой режим скрывает меню / маршруты / виджеты (шаг 4/8)

- AdvancedModeGuard — render-guard: простой режим + не-SuperAdmin → страница
  «Недоступно в простом режиме» с текстом + кнопкой «На главную».
  SuperAdmin пропускается, чтобы можно было проверить работу после
  переключения.
- useMe (+ локальные MeResponse) расширены полем advancedMode: boolean.
- AppLayout.buildNav(roles, advancedMode) — в простом режиме скрыты:
  priceTypes, transfers, demands, promotions, loyaltyPrograms/Cards,
  reportAbc, auditLog, LanguageSwitcher.
- App.tsx — скрытые маршруты обёрнуты в <AdvancedModeGuard>:
  /catalog/price-types, /inventory/transfers*, /sales/demands*,
  /loyalty/programs, /loyalty/cards, /promotions, /audit-log,
  /reports/abc.
- DashboardPage — в простом режиме скрыты виджеты «Топ товаров» и
  «Маржа». Остаются KPI, график продаж, «Последние продажи», «Низкий
  остаток».
- Данные скрытых модулей НЕ удаляются при выключении режима; при
  повторном включении всё видно.

pnpm run build — ✓ 2.6s, 0 TS errors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-04 11:43:10 +05:00
parent 921bca076c
commit 67c1b590ad
6 changed files with 140 additions and 31 deletions

View file

@ -12,7 +12,7 @@ SuperAdmin и только для конкретной организации.
- [x] 1. Domain + миграция `Organization.IsAdvancedMode` (bool, default false, backfill false)
- [x] 2. API `PUT /api/super-admin/organizations/{id}/advanced-mode` + аудит + `/api/me.advancedMode`; org-admin → 403
- [x] 3. Серверная защита: `[RequiresAdvancedMode]` → 403 ProblemDetails на скрытых модулях
- [ ] 4. Web: меню, маршруты, дашборд фильтруются; прямой переход → страница «Недоступно»
- [x] 4. Web: меню, маршруты, дашборд фильтруются; прямой переход → страница «Недоступно»
- [ ] 5. Упрощённые формы (товар / контрагент / документы) — скрыть «не нужные» поля, значения сохраняются
- [ ] 6. SuperAdmin UI — бейдж «Простой/Расширенный» + переключатель с подтверждением
- [ ] 7. Тесты (unit + integration + e2e); патч quality-watchdog при необходимости
@ -86,3 +86,25 @@ SuperAdmin и только для конкретной организации.
- `Auth/ExternalAuthController` (SSO) — атрибут НЕ навешен: endpoint инициирует OAuth-челлендж для анонима, `org_id`-claim ещё не выставлен. Контроль — на уровне UI (не показывать кнопку «Войти через Google/Microsoft» в простом режиме). Также SSO в простом режиме не ломается при включении — просто кнопка появляется.
- `SuperAdminOrganizationsController.SetAdvancedMode` вызывает `AdvancedModeFilter.InvalidateCache(orgId)` после `SaveChangesAsync`, чтобы следующий запрос этой org сразу увидел новое значение.
- `dotnet build food-market.api` — ✓ (0 error).
### 2026-10-04 — Шаг 4 ✓
- `src/food-market.web/src/components/AdvancedModeGuard.tsx` — render-guard: `me.advancedMode = false` + не-SuperAdmin → страница «Недоступно в простом режиме» с текстом + кнопкой «На главную». SuperAdmin и расширенный режим — пропускает.
- `useMe.ts` + локальные `MeResponse` в AppLayout/DashboardPage расширены полем `advancedMode: boolean`.
- `AppLayout.buildNav(roles, advancedMode)` — в простом режиме скрыто:
- `nav.priceTypes` (справочник типов цен)
- `nav.transfers` (перемещения между точками)
- `nav.demands` / `nav.promotions` / `nav.loyaltyPrograms` / `nav.loyaltyCards`
- `nav.reportAbc`
- `nav.auditLog`
- `LanguageSwitcher` (в простом режиме интерфейс только по-русски)
- `App.tsx` — скрытые маршруты обёрнуты в `<AdvancedModeGuard>`:
- `/catalog/price-types`
- `/inventory/transfers`, `/inventory/transfers/new`, `/inventory/transfers/:id`
- `/sales/demands`, `/sales/demands/new`, `/sales/demands/:id`
- `/loyalty/programs`, `/loyalty/cards`, `/promotions`
- `/audit-log`
- `/reports/abc`
- `DashboardPage` — в простом режиме скрыты виджеты «Топ товаров» и «Маржа». Остаются KPI (выручка today/week/month, средний чек), график продаж, «Последние продажи», «Низкий остаток» — ровно то, что в чек-листе.
- Данные НЕ удаляются при выключении режима: это только UI-гейтинг + серверный 403; при повторном включении всё видно.
- `pnpm run build` — ✓ (2.60s, 0 TS errors).

View file

@ -28,6 +28,7 @@ import { SuperAdminLayout } from '@/components/SuperAdminLayout'
import { TenantRouteGuard } from '@/components/TenantRouteGuard'
import { ProtectedRoute } from '@/components/ProtectedRoute'
import { RoleGuard } from '@/components/RoleGuard'
import { AdvancedModeGuard } from '@/components/AdvancedModeGuard'
import { Toaster } from '@/components/Toaster'
import { toast } from '@/lib/toast'
import { FormSkeleton } from '@/components/Skeleton'
@ -155,7 +156,7 @@ export default function App() {
<Route path="/catalog/products/:id" element={<ProductEditPage />} />
<Route path="/catalog/product-groups" element={lz(ProductGroupsPage)} />
<Route path="/catalog/units" element={lz(UnitsOfMeasurePage)} />
<Route path="/catalog/price-types" element={lz(PriceTypesPage)} />
<Route path="/catalog/price-types" element={<AdvancedModeGuard>{lz(PriceTypesPage)}</AdvancedModeGuard>} />
<Route path="/catalog/counterparties" element={<RoleGuard roles={['Admin']}><CounterpartiesPage /></RoleGuard>} />
<Route path="/catalog/stores" element={<RoleGuard roles={['Admin']}>{lz(StoresPage)}</RoleGuard>} />
<Route path="/catalog/retail-points" element={<RoleGuard roles={['Admin']}>{lz(RetailPointsPage)}</RoleGuard>} />
@ -170,9 +171,9 @@ export default function App() {
<Route path="/inventory/losses" element={lz(LossesPage)} />
<Route path="/inventory/losses/new" element={lz(LossEditPage)} />
<Route path="/inventory/losses/:id" element={lz(LossEditPage)} />
<Route path="/inventory/transfers" element={lz(TransfersPage)} />
<Route path="/inventory/transfers/new" element={lz(TransferEditPage)} />
<Route path="/inventory/transfers/:id" element={lz(TransferEditPage)} />
<Route path="/inventory/transfers" element={<AdvancedModeGuard>{lz(TransfersPage)}</AdvancedModeGuard>} />
<Route path="/inventory/transfers/new" element={<AdvancedModeGuard>{lz(TransferEditPage)}</AdvancedModeGuard>} />
<Route path="/inventory/transfers/:id" element={<AdvancedModeGuard>{lz(TransferEditPage)}</AdvancedModeGuard>} />
<Route path="/inventory/inventories" element={lz(InventoriesPage)} />
<Route path="/inventory/inventories/new" element={lz(InventoryEditPage)} />
<Route path="/inventory/inventories/:id" element={lz(InventoryEditPage)} />
@ -182,17 +183,17 @@ export default function App() {
<Route path="/reports/sales" element={lz(SalesReportPage)} />
<Route path="/reports/stock" element={lz(StockReportPage)} />
<Route path="/reports/profit" element={lz(ProfitReportPage)} />
<Route path="/reports/abc" element={lz(AbcReportPage)} />
<Route path="/reports/abc" element={<AdvancedModeGuard>{lz(AbcReportPage)}</AdvancedModeGuard>} />
<Route path="/sales/retail" element={<RetailSalesPage />} />
<Route path="/sales/retail/new" element={<RetailSaleEditPage />} />
<Route path="/sales/retail/:id" element={<RetailSaleEditPage />} />
<Route path="/sales/demands" element={lz(DemandsPage)} />
<Route path="/sales/demands/new" element={lz(DemandEditPage)} />
<Route path="/sales/demands/:id" element={lz(DemandEditPage)} />
<Route path="/loyalty/programs" element={<RoleGuard roles={['Admin']}>{lz(LoyaltyProgramsPage)}</RoleGuard>} />
<Route path="/loyalty/cards" element={<RoleGuard roles={['Admin']}>{lz(LoyaltyCardsPage)}</RoleGuard>} />
<Route path="/promotions" element={<RoleGuard roles={['Admin']}>{lz(PromotionsPage)}</RoleGuard>} />
<Route path="/audit-log" element={<RoleGuard roles={['Admin']}>{lz(OrgAuditLogPage)}</RoleGuard>} />
<Route path="/sales/demands" element={<AdvancedModeGuard>{lz(DemandsPage)}</AdvancedModeGuard>} />
<Route path="/sales/demands/new" element={<AdvancedModeGuard>{lz(DemandEditPage)}</AdvancedModeGuard>} />
<Route path="/sales/demands/:id" element={<AdvancedModeGuard>{lz(DemandEditPage)}</AdvancedModeGuard>} />
<Route path="/loyalty/programs" element={<RoleGuard roles={['Admin']}><AdvancedModeGuard>{lz(LoyaltyProgramsPage)}</AdvancedModeGuard></RoleGuard>} />
<Route path="/loyalty/cards" element={<RoleGuard roles={['Admin']}><AdvancedModeGuard>{lz(LoyaltyCardsPage)}</AdvancedModeGuard></RoleGuard>} />
<Route path="/promotions" element={<RoleGuard roles={['Admin']}><AdvancedModeGuard>{lz(PromotionsPage)}</AdvancedModeGuard></RoleGuard>} />
<Route path="/audit-log" element={<RoleGuard roles={['Admin']}><AdvancedModeGuard>{lz(OrgAuditLogPage)}</AdvancedModeGuard></RoleGuard>} />
<Route path="/admin/import/moysklad" element={<RoleGuard roles={['Admin']}>{lz(MoySkladImportPage)}</RoleGuard>} />
<Route path="/settings/organization" element={<RoleGuard roles={['Admin']}>{lz(OrganizationSettingsPage)}</RoleGuard>} />
{/* Sprint 17 */}

View file

@ -0,0 +1,50 @@
import { Link } from 'react-router-dom'
import { useMe } from '@/lib/useMe'
import { Lock } from 'lucide-react'
interface Props {
children: React.ReactNode
}
/** Sprint 29 — render-guard для модулей, доступных только в «расширенном
* режиме». Если `me.advancedMode = false` и юзер не SuperAdmin — показывает
* понятную страницу «Недоступно в простом режиме» вместо белого экрана и
* 403 ProblemDetails в консоли.
*
* Серверная защита — атрибут `[RequiresAdvancedMode]`; этот компонент
* просто улучшает UX. */
export function AdvancedModeGuard({ children }: Props) {
const me = useMe()
if (me.isLoading) return null
const isSuperAdmin = me.data?.roles?.includes('SuperAdmin')
// SuperAdmin в режиме «войти как» видит всё — чтобы можно было проверить
// работоспособность после переключения режима.
if (isSuperAdmin) return <>{children}</>
if (me.data?.advancedMode) return <>{children}</>
return (
<div className="min-h-[60vh] flex items-center justify-center px-4">
<div className="max-w-md text-center space-y-4">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-slate-100 dark:bg-slate-800">
<Lock className="h-7 w-7 text-slate-500" />
</div>
<h1 className="text-2xl font-bold">Недоступно в простом режиме</h1>
<p className="text-sm text-slate-600 dark:text-slate-400">
Этот раздел включается вместе с расширенным режимом магазина.
Обратитесь к администратору платформы, чтобы включить его для
вашей организации — данные при переключении режимов не теряются.
</p>
<div className="pt-2">
<Link
to="/"
className="inline-flex items-center justify-center rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700"
>
На главную
</Link>
</div>
</div>
</div>
)
}

View file

@ -26,6 +26,9 @@ interface MeResponse {
email: string
roles: string[]
orgId: string
/** Sprint 29 — расширенный режим орг-tenanta. false → простой магазин
* (меню фильтруется, скрытые роуты показывают «Недоступно»). */
advancedMode?: boolean
}
type NavItem = { to: string; icon: typeof LayoutDashboard; label: string; end?: boolean }
@ -58,11 +61,14 @@ function translateRoles(roles: string[]): string {
* Источник правды для прав — серверные `[Authorize(Roles = ...)]` на
* каждом endpoint-е; sidebar-фильтр это UX-слой чтобы не показывать
* неработающие пункты. */
function buildNav(roles: string[]): NavSection[] {
function buildNav(roles: string[], advancedMode: boolean): NavSection[] {
const isSuperAdmin = roles.includes('SuperAdmin')
const isAdmin = roles.includes('Admin') || isSuperAdmin
const isCashier = roles.includes('Cashier')
const isStorekeeper = roles.includes('Storekeeper')
// SuperAdmin видит ВСЕ пункты (даже при advancedMode=false для его org),
// чтобы можно было проверить что ничего не сломалось после переключения.
const advanced = advancedMode || isSuperAdmin
const sections: NavSection[] = [
{ group: 'nav.section_main', items: [
@ -78,8 +84,12 @@ function buildNav(roles: string[]): NavSection[] {
catalog.push(
{ to: '/catalog/product-groups', icon: FolderTree, label: 'nav.productGroups' },
{ to: '/catalog/units', icon: Ruler, label: 'nav.units' },
{ to: '/catalog/price-types', icon: Tag, label: 'nav.priceTypes' },
)
// В простом режиме у магазина одна цена продажи — страница управления
// типами цен не нужна. SuperAdmin и расширенный режим — видят.
if (advanced) {
catalog.push({ to: '/catalog/price-types', icon: Tag, label: 'nav.priceTypes' })
}
}
sections.push({ group: 'nav.section_catalog', items: catalog })
}
@ -97,7 +107,10 @@ function buildNav(roles: string[]): NavSection[] {
stock.push({ to: '/inventory/movements', icon: History, label: 'nav.stockMovements' })
stock.push({ to: '/inventory/enters', icon: PackagePlus, label: 'nav.enters' })
stock.push({ to: '/inventory/losses', icon: PackageMinus, label: 'nav.losses' })
stock.push({ to: '/inventory/transfers', icon: ArrowRightLeft, label: 'nav.transfers' })
// Перемещения между точками — только в расширенном режиме.
if (advanced) {
stock.push({ to: '/inventory/transfers', icon: ArrowRightLeft, label: 'nav.transfers' })
}
stock.push({ to: '/inventory/inventories', icon: ClipboardCheck, label: 'nav.inventories' })
}
sections.push({ group: 'nav.section_inventory', items: stock })
@ -115,7 +128,8 @@ function buildNav(roles: string[]): NavSection[] {
if (isAdmin || isCashier) {
sections.push({ group: 'nav.section_sales', items: [
{ to: '/sales/retail', icon: ShoppingCart, label: 'nav.retailSales' },
...(isAdmin ? [
// Оптовые отгрузки / промо / лояльность — только расширенный.
...(isAdmin && advanced ? [
{ to: '/sales/demands', icon: Send, label: 'nav.demands' },
{ to: '/promotions', icon: Tag, label: 'nav.promotions' },
{ to: '/loyalty/programs', icon: ShieldCheck, label: 'nav.loyaltyPrograms' },
@ -124,14 +138,15 @@ function buildNav(roles: string[]): NavSection[] {
]})
}
// Отчёты — Admin и Storekeeper.
// Отчёты — Admin и Storekeeper. ABC — только расширенный режим.
if (isAdmin || isStorekeeper) {
sections.push({ group: 'nav.section_reports', items: [
const reports: NavItem[] = [
{ to: '/reports/sales', icon: BarChart3, label: 'nav.reportSales' },
{ to: '/reports/stock', icon: Boxes, label: 'nav.reportStock' },
{ to: '/reports/profit', icon: TrendingUp, label: 'nav.reportProfit' },
{ to: '/reports/abc', icon: Target, label: 'nav.reportAbc' },
]})
]
if (advanced) reports.push({ to: '/reports/abc', icon: Target, label: 'nav.reportAbc' })
sections.push({ group: 'nav.section_reports', items: reports })
}
if (isAdmin) {
@ -144,7 +159,10 @@ function buildNav(roles: string[]): NavSection[] {
{ to: '/catalog/retail-points', icon: StoreIcon, label: 'nav.retailPoints' },
{ to: '/settings/employees', icon: UserCog, label: 'nav.employees' },
{ to: '/settings/employee-roles', icon: Shield, label: 'nav.employeeRoles' },
{ to: '/audit-log', icon: FileText, label: 'nav.auditLog' },
// Аудит организации — только в расширенном режиме.
...(advanced ? [
{ to: '/audit-log', icon: FileText, label: 'nav.auditLog' },
] : []),
]})
}
@ -169,7 +187,7 @@ export function AppLayout() {
})
const isSuperAdmin = !!me?.roles?.includes('SuperAdmin')
const nav = buildNav(me?.roles ?? [])
const nav = buildNav(me?.roles ?? [], !!me?.advancedMode)
// При активном «открыто как…» — слабая жёлтая тонировка фона tenant-области
// даёт периферийный сигнал «я не в своей админке».
const inOverride = typeof window !== 'undefined' && !!localStorage.getItem('superAdminAsOrg')
@ -256,7 +274,11 @@ export function AppLayout() {
<div className="truncate">{translateRoles(me.roles)}</div>
</div>
)}
<div className="px-2 pb-2"><LanguageSwitcher /></div>
{/* Переключатель языка — только в расширенном режиме; в простом
магазине интерфейс только по-русски. SuperAdmin видит всегда. */}
{(me?.advancedMode || isSuperAdmin) && (
<div className="px-2 pb-2"><LanguageSwitcher /></div>
)}
{/* Sprint 17: feedback widget + ссылки на /help и /whats-new.
Sprint 18: NotificationCenter — иконка-колокольчик с popover'ом. */}
<div className="px-2 pb-2 flex items-center gap-3 flex-wrap">

View file

@ -12,6 +12,11 @@ export interface MeResponse {
hasLiveOrg: boolean
/** Активный Employee текущего AppUser в его Organization. False → orphan. */
hasActiveEmployee: boolean
/** Sprint 29 — расширенный режим орг-tenanta (SuperAdmin включает
* вручную). false → UI скрывает «не для простого магазина»
* модули: Demand, Transfers, Loyalty, Promotions, ABC, Аудит,
* пресеты, Telegram-бот, SSO, несколько типов цен. */
advancedMode: boolean
}
/** Текущий залогиненный юзер с ролями. Используется для гейтов

View file

@ -26,6 +26,9 @@ interface MeResponse {
email: string
roles: string[]
orgId: string
/** Sprint 29 — расширенный режим орг-tenanta. false → скрыты
* продвинутые виджеты (TopProducts, Margin). */
advancedMode?: boolean
}
function useCount(url: string) {
@ -208,14 +211,20 @@ export function DashboardPage() {
)}
</section>
{/* Виджеты «Топ товаров», «Маржа», «Last sales», «Low stock» */}
{/* Виджеты. В простом режиме — только «что заканчивается» и
«последние продажи». «Топ товаров» и «Маржа» требуют расширенный
режим (SuperAdmin видит всегда). */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Suspense fallback={<Skeleton variant="block" className="h-72 w-full" />}>
<TopProductsWidget days={7} />
</Suspense>
<Suspense fallback={<Skeleton variant="block" className="h-72 w-full" />}>
<MarginWidget days={30} />
</Suspense>
{(me.data?.advancedMode || me.data?.roles?.includes('SuperAdmin')) && (
<>
<Suspense fallback={<Skeleton variant="block" className="h-72 w-full" />}>
<TopProductsWidget days={7} />
</Suspense>
<Suspense fallback={<Skeleton variant="block" className="h-72 w-full" />}>
<MarginWidget days={30} />
</Suspense>
</>
)}
<Suspense fallback={<Skeleton variant="block" className="h-72 w-full" />}>
<RecentSalesWidget limit={10} />
</Suspense>