From 67c1b590ade25009de9f2786885131a5e04f4f03 Mon Sep 17 00:00:00 2001 From: nns Date: Sun, 4 Oct 2026 11:43:10 +0500 Subject: [PATCH] =?UTF-8?q?feat(s29):=20web=20=E2=80=94=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=BE=D0=B9=20=D1=80=D0=B5=D0=B6=D0=B8=D0=BC?= =?UTF-8?q?=20=D1=81=D0=BA=D1=80=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=8E=20/=20=D0=BC=D0=B0=D1=80=D1=88=D1=80=D1=83?= =?UTF-8?q?=D1=82=D1=8B=20/=20=D0=B2=D0=B8=D0=B4=D0=B6=D0=B5=D1=82=D1=8B?= =?UTF-8?q?=20(=D1=88=D0=B0=D0=B3=204/8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdvancedModeGuard — render-guard: простой режим + не-SuperAdmin → страница «Недоступно в простом режиме» с текстом + кнопкой «На главную». SuperAdmin пропускается, чтобы можно было проверить работу после переключения. - useMe (+ локальные MeResponse) расширены полем advancedMode: boolean. - AppLayout.buildNav(roles, advancedMode) — в простом режиме скрыты: priceTypes, transfers, demands, promotions, loyaltyPrograms/Cards, reportAbc, auditLog, LanguageSwitcher. - App.tsx — скрытые маршруты обёрнуты в : /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 --- docs/simple-mode-progress.md | 24 ++++++++- src/food-market.web/src/App.tsx | 25 +++++----- .../src/components/AdvancedModeGuard.tsx | 50 +++++++++++++++++++ .../src/components/AppLayout.tsx | 44 ++++++++++++---- src/food-market.web/src/lib/useMe.ts | 5 ++ .../src/pages/DashboardPage.tsx | 23 ++++++--- 6 files changed, 140 insertions(+), 31 deletions(-) create mode 100644 src/food-market.web/src/components/AdvancedModeGuard.tsx diff --git a/docs/simple-mode-progress.md b/docs/simple-mode-progress.md index 7f2de2f..b5f2bb4 100644 --- a/docs/simple-mode-progress.md +++ b/docs/simple-mode-progress.md @@ -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` — скрытые маршруты обёрнуты в ``: + - `/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). diff --git a/src/food-market.web/src/App.tsx b/src/food-market.web/src/App.tsx index 5f7bc08..28dcec0 100644 --- a/src/food-market.web/src/App.tsx +++ b/src/food-market.web/src/App.tsx @@ -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() { } /> - + {lz(PriceTypesPage)}} /> } /> {lz(StoresPage)}} /> {lz(RetailPointsPage)}} /> @@ -170,9 +171,9 @@ export default function App() { - - - + {lz(TransfersPage)}} /> + {lz(TransferEditPage)}} /> + {lz(TransferEditPage)}} /> @@ -182,17 +183,17 @@ export default function App() { - + {lz(AbcReportPage)}} /> } /> } /> } /> - - - - {lz(LoyaltyProgramsPage)}} /> - {lz(LoyaltyCardsPage)}} /> - {lz(PromotionsPage)}} /> - {lz(OrgAuditLogPage)}} /> + {lz(DemandsPage)}} /> + {lz(DemandEditPage)}} /> + {lz(DemandEditPage)}} /> + {lz(LoyaltyProgramsPage)}} /> + {lz(LoyaltyCardsPage)}} /> + {lz(PromotionsPage)}} /> + {lz(OrgAuditLogPage)}} /> {lz(MoySkladImportPage)}} /> {lz(OrganizationSettingsPage)}} /> {/* Sprint 17 */} diff --git a/src/food-market.web/src/components/AdvancedModeGuard.tsx b/src/food-market.web/src/components/AdvancedModeGuard.tsx new file mode 100644 index 0000000..9cf162c --- /dev/null +++ b/src/food-market.web/src/components/AdvancedModeGuard.tsx @@ -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 ( +
+
+
+ +
+

Недоступно в простом режиме

+

+ Этот раздел включается вместе с расширенным режимом магазина. + Обратитесь к администратору платформы, чтобы включить его для + вашей организации — данные при переключении режимов не теряются. +

+
+ + На главную + +
+
+
+ ) +} diff --git a/src/food-market.web/src/components/AppLayout.tsx b/src/food-market.web/src/components/AppLayout.tsx index cf8865b..a02c4f8 100644 --- a/src/food-market.web/src/components/AppLayout.tsx +++ b/src/food-market.web/src/components/AppLayout.tsx @@ -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() {
{translateRoles(me.roles)}
)} -
+ {/* Переключатель языка — только в расширенном режиме; в простом + магазине интерфейс только по-русски. SuperAdmin видит всегда. */} + {(me?.advancedMode || isSuperAdmin) && ( +
+ )} {/* Sprint 17: feedback widget + ссылки на /help и /whats-new. Sprint 18: NotificationCenter — иконка-колокольчик с popover'ом. */}
diff --git a/src/food-market.web/src/lib/useMe.ts b/src/food-market.web/src/lib/useMe.ts index 6a0f10d..64a63e4 100644 --- a/src/food-market.web/src/lib/useMe.ts +++ b/src/food-market.web/src/lib/useMe.ts @@ -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 } /** Текущий залогиненный юзер с ролями. Используется для гейтов diff --git a/src/food-market.web/src/pages/DashboardPage.tsx b/src/food-market.web/src/pages/DashboardPage.tsx index c0cb6ea..8862744 100644 --- a/src/food-market.web/src/pages/DashboardPage.tsx +++ b/src/food-market.web/src/pages/DashboardPage.tsx @@ -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() { )} - {/* Виджеты «Топ товаров», «Маржа», «Last sales», «Low stock» */} + {/* Виджеты. В простом режиме — только «что заканчивается» и + «последние продажи». «Топ товаров» и «Маржа» требуют расширенный + режим (SuperAdmin видит всегда). */}
- }> - - - }> - - + {(me.data?.advancedMode || me.data?.roles?.includes('SuperAdmin')) && ( + <> + }> + + + }> + + + + )} }>