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:
parent
921bca076c
commit
67c1b590ad
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
50
src/food-market.web/src/components/AdvancedModeGuard.tsx
Normal file
50
src/food-market.web/src/components/AdvancedModeGuard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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' })
|
||||
// Перемещения между точками — только в расширенном режиме.
|
||||
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' },
|
||||
// Аудит организации — только в расширенном режиме.
|
||||
...(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>
|
||||
)}
|
||||
{/* Переключатель языка — только в расширенном режиме; в простом
|
||||
магазине интерфейс только по-русски. 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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/** Текущий залогиненный юзер с ролями. Используется для гейтов
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
{(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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue