diff --git a/src/food-market.web/package.json b/src/food-market.web/package.json index d313a48..820fb98 100644 --- a/src/food-market.web/package.json +++ b/src/food-market.web/package.json @@ -19,11 +19,14 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "i18next": "^26.3.0", + "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^1.8.0", "react": "^19.2.5", "react-datepicker": "^9.1.0", "react-dom": "^19.2.5", "react-hook-form": "^7.73.1", + "react-i18next": "^17.0.8", "react-router-dom": "^7.14.1", "recharts": "^3.8.1", "tailwind-merge": "^3.5.0", diff --git a/src/food-market.web/pnpm-lock.yaml b/src/food-market.web/pnpm-lock.yaml index b144015..6e2bc40 100644 --- a/src/food-market.web/pnpm-lock.yaml +++ b/src/food-market.web/pnpm-lock.yaml @@ -32,6 +32,12 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + i18next: + specifier: ^26.3.0 + version: 26.3.0(typescript@6.0.3) + i18next-browser-languagedetector: + specifier: ^8.2.1 + version: 8.2.1 lucide-react: specifier: ^1.8.0 version: 1.8.0(react@19.2.5) @@ -47,6 +53,9 @@ importers: react-hook-form: specifier: ^7.73.1 version: 7.73.1(react@19.2.5) + react-i18next: + specifier: ^17.0.8 + version: 17.0.8(i18next@26.3.0(typescript@6.0.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3) react-router-dom: specifier: ^7.14.1 version: 7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -172,6 +181,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -1083,10 +1096,24 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + i18next-browser-languagedetector@8.2.1: + resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} + + i18next@26.3.0: + resolution: {integrity: sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==} + peerDependencies: + typescript: ^5 || ^6 + peerDependenciesMeta: + typescript: + optional: true + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1401,6 +1428,22 @@ packages: peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 + react-i18next@17.0.8: + resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==} + peerDependencies: + i18next: '>= 26.2.0' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 || ^6 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + react-is@19.2.5: resolution: {integrity: sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==} @@ -1645,6 +1688,10 @@ packages: yaml: optional: true + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -1774,6 +1821,8 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/runtime@7.29.7': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -2661,6 +2710,10 @@ snapshots: dependencies: hermes-estree: 0.25.1 + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + https-proxy-agent@7.0.6(supports-color@10.2.2): dependencies: agent-base: 7.1.4 @@ -2668,6 +2721,14 @@ snapshots: transitivePeerDependencies: - supports-color + i18next-browser-languagedetector@8.2.1: + dependencies: + '@babel/runtime': 7.29.7 + + i18next@26.3.0(typescript@6.0.3): + optionalDependencies: + typescript: 6.0.3 + ignore@5.3.2: {} ignore@7.0.5: {} @@ -2909,6 +2970,17 @@ snapshots: dependencies: react: 19.2.5 + react-i18next@17.0.8(i18next@26.3.0(typescript@6.0.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3): + dependencies: + '@babel/runtime': 7.29.7 + html-parse-stringify: 3.0.1 + i18next: 26.3.0(typescript@6.0.3) + react: 19.2.5 + use-sync-external-store: 1.6.0(react@19.2.5) + optionalDependencies: + react-dom: 19.2.5(react@19.2.5) + typescript: 6.0.3 + react-is@19.2.5: {} react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1): @@ -3123,6 +3195,8 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 + void-elements@3.1.0: {} + webidl-conversions@3.0.1: {} whatwg-url@5.0.0: diff --git a/src/food-market.web/src/components/AppLayout.tsx b/src/food-market.web/src/components/AppLayout.tsx index 8ee5b71..33d2226 100644 --- a/src/food-market.web/src/components/AppLayout.tsx +++ b/src/food-market.web/src/components/AppLayout.tsx @@ -9,9 +9,11 @@ import { Users, Warehouse, Store as StoreIcon, LogOut, Download, UserCog, Shield, ShieldCheck, Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, PackagePlus, PackageMinus, ArrowRightLeft, ClipboardCheck, Undo2, BarChart3, TrendingUp, Target, Send, FileText, } from 'lucide-react' +import { useTranslation } from 'react-i18next' import { Logo } from './Logo' import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner' import { ShortcutsOverlay } from './ShortcutsOverlay' +import { LanguageSwitcher } from './LanguageSwitcher' interface MeResponse { sub: string @@ -22,6 +24,8 @@ interface MeResponse { } type NavItem = { to: string; icon: typeof LayoutDashboard; label: string; end?: boolean } +/** group/labels — i18n-ключи (см. src/locales/*.json раздел `nav`). + * Render-сторона переводит через useTranslation().t(key). */ type NavSection = { group: string; items: NavItem[] } const ROLE_RU: Record = { @@ -56,89 +60,89 @@ function buildNav(roles: string[]): NavSection[] { const isStorekeeper = roles.includes('Storekeeper') const sections: NavSection[] = [ - { group: 'Главное', items: [ - { to: '/', icon: LayoutDashboard, label: 'Главная', end: true }, - { to: '/dashboard', icon: LayoutDashboard, label: 'Аналитика' }, + { group: 'nav.section_main', items: [ + { to: '/', icon: LayoutDashboard, label: 'nav.dashboardLink', end: true }, + { to: '/dashboard', icon: LayoutDashboard, label: 'nav.analytics' }, ]}, ] // Каталог — Кассиру и Кладовщику только просмотр товаров; группы/типы цен/единицы — admin. if (isAdmin || isCashier || isStorekeeper) { - const catalog: NavItem[] = [{ to: '/catalog/products', icon: Package, label: 'Товары' }] + const catalog: NavItem[] = [{ to: '/catalog/products', icon: Package, label: 'nav.products' }] if (isAdmin) { catalog.push( - { to: '/catalog/product-groups', icon: FolderTree, label: 'Группы' }, - { to: '/catalog/units', icon: Ruler, label: 'Ед. измерения' }, - { to: '/catalog/price-types', icon: Tag, label: 'Типы цен' }, + { 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' }, ) } - sections.push({ group: 'Каталог', items: catalog }) + sections.push({ group: 'nav.section_catalog', items: catalog }) } if (isAdmin) { - sections.push({ group: 'Контрагенты', items: [ - { to: '/catalog/counterparties', icon: Users, label: 'Контрагенты' }, + sections.push({ group: 'nav.section_counterparties', items: [ + { to: '/catalog/counterparties', icon: Users, label: 'nav.counterparties' }, ]}) } // Остатки видят все три tenant-роли. if (isAdmin || isCashier || isStorekeeper) { - const stock: NavItem[] = [{ to: '/inventory/stock', icon: Boxes, label: 'Остатки' }] + const stock: NavItem[] = [{ to: '/inventory/stock', icon: Boxes, label: 'nav.stock' }] if (isAdmin || isStorekeeper) { - stock.push({ to: '/inventory/movements', icon: History, label: 'Движения' }) - stock.push({ to: '/inventory/enters', icon: PackagePlus, label: 'Оприходования' }) - stock.push({ to: '/inventory/losses', icon: PackageMinus, label: 'Списания' }) - stock.push({ to: '/inventory/transfers', icon: ArrowRightLeft, label: 'Перемещения' }) - stock.push({ to: '/inventory/inventories', icon: ClipboardCheck, label: 'Инвентаризации' }) + 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' }) + stock.push({ to: '/inventory/inventories', icon: ClipboardCheck, label: 'nav.inventories' }) } - sections.push({ group: 'Остатки', items: stock }) + sections.push({ group: 'nav.section_inventory', items: stock }) } // Закупки — Admin и Storekeeper. if (isAdmin || isStorekeeper) { - sections.push({ group: 'Закупки', items: [ - { to: '/purchases/supplies', icon: TruckIcon, label: 'Приёмки' }, - { to: '/purchases/supplier-returns', icon: Undo2, label: 'Возвраты поставщикам' }, + sections.push({ group: 'nav.section_purchases', items: [ + { to: '/purchases/supplies', icon: TruckIcon, label: 'nav.supplies' }, + { to: '/purchases/supplier-returns', icon: Undo2, label: 'nav.supplierReturns' }, ]}) } // Продажи — Admin и Cashier. if (isAdmin || isCashier) { - sections.push({ group: 'Продажи', items: [ - { to: '/sales/retail', icon: ShoppingCart, label: 'Розничные чеки' }, - ...(isAdmin ? [{ to: '/sales/demands', icon: Send, label: 'Оптовые отгрузки' }] : []), + sections.push({ group: 'nav.section_sales', items: [ + { to: '/sales/retail', icon: ShoppingCart, label: 'nav.retailSales' }, + ...(isAdmin ? [{ to: '/sales/demands', icon: Send, label: 'nav.demands' }] : []), ]}) } - // Отчёты — Admin и Storekeeper (Cashier видит ограниченные через permissions). + // Отчёты — Admin и Storekeeper. if (isAdmin || isStorekeeper) { - sections.push({ group: 'Отчёты', items: [ - { to: '/reports/sales', icon: BarChart3, label: 'Продажи' }, - { to: '/reports/stock', icon: Boxes, label: 'Остатки на дату' }, - { to: '/reports/profit', icon: TrendingUp, label: 'Прибыль' }, - { to: '/reports/abc', icon: Target, label: 'ABC-анализ' }, + sections.push({ group: 'nav.section_reports', items: [ + { 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 (isAdmin) { - sections.push({ group: 'Импорт', items: [ - { to: '/admin/import/moysklad', icon: Download, label: 'МойСклад' }, + sections.push({ group: 'nav.section_admin', items: [ + { to: '/admin/import/moysklad', icon: Download, label: 'nav.moysklad' }, ]}) - sections.push({ group: 'Настройки организации', items: [ - { to: '/settings/organization', icon: Settings, label: 'Общие' }, - { to: '/catalog/stores', icon: Warehouse, label: 'Склады' }, - { to: '/catalog/retail-points', icon: StoreIcon, label: 'Кассы' }, - { to: '/settings/employees', icon: UserCog, label: 'Сотрудники' }, - { to: '/settings/employee-roles', icon: Shield, label: 'Роли' }, - { to: '/audit-log', icon: FileText, label: 'Журнал изменений' }, + sections.push({ group: 'nav.section_settings', items: [ + { to: '/settings/organization', icon: Settings, label: 'nav.settingsGeneral' }, + { to: '/catalog/stores', icon: Warehouse, label: 'nav.stores' }, + { 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' }, ]}) } if (isSuperAdmin) { sections.push({ - group: 'Супер-админ', + group: 'nav.section_super_admin', items: [ - { to: '/super-admin', icon: ShieldCheck, label: 'Системная консоль', end: true }, + { to: '/super-admin', icon: ShieldCheck, label: 'nav.superAdminConsole', end: true }, ], }) } @@ -147,6 +151,7 @@ function buildNav(roles: string[]): NavSection[] { } export function AppLayout() { + const { t } = useTranslation() const { data: me } = useQuery({ queryKey: ['me'], queryFn: async () => (await api.get('/api/me')).data, @@ -184,7 +189,7 @@ export function AppLayout() { @@ -193,7 +198,7 @@ export function AppLayout() {