feat(i18n): react-i18next ru/en + language switcher (P2-6a — базовая)
Some checks are pending
Some checks are pending
Подключён react-i18next с inline-ресурсами (без fetch'a). Языки ru/en с полным переводом базовых ключей (common/nav/dashboard/products/ settings/demoSeed/shortcuts/toast). kz пока fallback на ru — нужен живой переводчик (TODO). Конфигурация: - src/lib/i18n.ts — i18next + browser-language-detector. localStorage ['fm.lang'] хранит выбор пользователя. - src/components/LanguageSwitcher.tsx — Languages-иконка + <select>. - В AppLayout: ниже user-footer'a, рядом с кнопкой Выход. Переведены пока что: - AppLayout: 11 sidebar-разделов + 26 nav-ссылок + aria-label'ов. - DashboardPage: PageHeader, KPI-карточки, mini-cards каталога, заголовок графика, пустое состояние. TODO для следующих итераций (~25 страниц): - Products/Counterparties/Enters/Losses/Transfers/Inventories/Demands/ SupplierReturns/RetailSales/Supplies (edit + list — заголовки, кнопки, breadcrumbs). - OrganizationSettings (формы — все labels). - Shortcuts overlay, EmptyState texts, ConfirmDialog default labels. - kz перевод требует живого переводчика РК. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
749829c12f
commit
301bf15924
|
|
@ -19,11 +19,14 @@
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"i18next": "^26.3.0",
|
||||||
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-datepicker": "^9.1.0",
|
"react-datepicker": "^9.1.0",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
"react-hook-form": "^7.73.1",
|
"react-hook-form": "^7.73.1",
|
||||||
|
"react-i18next": "^17.0.8",
|
||||||
"react-router-dom": "^7.14.1",
|
"react-router-dom": "^7.14.1",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,12 @@ importers:
|
||||||
date-fns:
|
date-fns:
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 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:
|
lucide-react:
|
||||||
specifier: ^1.8.0
|
specifier: ^1.8.0
|
||||||
version: 1.8.0(react@19.2.5)
|
version: 1.8.0(react@19.2.5)
|
||||||
|
|
@ -47,6 +53,9 @@ importers:
|
||||||
react-hook-form:
|
react-hook-form:
|
||||||
specifier: ^7.73.1
|
specifier: ^7.73.1
|
||||||
version: 7.73.1(react@19.2.5)
|
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:
|
react-router-dom:
|
||||||
specifier: ^7.14.1
|
specifier: ^7.14.1
|
||||||
version: 7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
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'}
|
engines: {node: '>=6.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
'@babel/runtime@7.29.7':
|
||||||
|
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@babel/template@7.28.6':
|
'@babel/template@7.28.6':
|
||||||
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
|
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
@ -1083,10 +1096,24 @@ packages:
|
||||||
hermes-parser@0.25.1:
|
hermes-parser@0.25.1:
|
||||||
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
|
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
|
||||||
|
|
||||||
|
html-parse-stringify@3.0.1:
|
||||||
|
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
|
||||||
|
|
||||||
https-proxy-agent@7.0.6:
|
https-proxy-agent@7.0.6:
|
||||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||||
engines: {node: '>= 14'}
|
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:
|
ignore@5.3.2:
|
||||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
|
|
@ -1401,6 +1428,22 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.8.0 || ^17 || ^18 || ^19
|
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:
|
react-is@19.2.5:
|
||||||
resolution: {integrity: sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==}
|
resolution: {integrity: sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==}
|
||||||
|
|
||||||
|
|
@ -1645,6 +1688,10 @@ packages:
|
||||||
yaml:
|
yaml:
|
||||||
optional: true
|
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:
|
webidl-conversions@3.0.1:
|
||||||
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
||||||
|
|
||||||
|
|
@ -1774,6 +1821,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/types': 7.29.0
|
'@babel/types': 7.29.0
|
||||||
|
|
||||||
|
'@babel/runtime@7.29.7': {}
|
||||||
|
|
||||||
'@babel/template@7.28.6':
|
'@babel/template@7.28.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.29.0
|
'@babel/code-frame': 7.29.0
|
||||||
|
|
@ -2661,6 +2710,10 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
hermes-estree: 0.25.1
|
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):
|
https-proxy-agent@7.0.6(supports-color@10.2.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
agent-base: 7.1.4
|
agent-base: 7.1.4
|
||||||
|
|
@ -2668,6 +2721,14 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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@5.3.2: {}
|
||||||
|
|
||||||
ignore@7.0.5: {}
|
ignore@7.0.5: {}
|
||||||
|
|
@ -2909,6 +2970,17 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.5
|
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-is@19.2.5: {}
|
||||||
|
|
||||||
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1):
|
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
|
fsevents: 2.3.3
|
||||||
jiti: 2.6.1
|
jiti: 2.6.1
|
||||||
|
|
||||||
|
void-elements@3.1.0: {}
|
||||||
|
|
||||||
webidl-conversions@3.0.1: {}
|
webidl-conversions@3.0.1: {}
|
||||||
|
|
||||||
whatwg-url@5.0.0:
|
whatwg-url@5.0.0:
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,11 @@ import {
|
||||||
Users, Warehouse, Store as StoreIcon, LogOut, Download, UserCog, Shield, ShieldCheck,
|
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,
|
Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, PackagePlus, PackageMinus, ArrowRightLeft, ClipboardCheck, Undo2, BarChart3, TrendingUp, Target, Send, FileText,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Logo } from './Logo'
|
import { Logo } from './Logo'
|
||||||
import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner'
|
import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner'
|
||||||
import { ShortcutsOverlay } from './ShortcutsOverlay'
|
import { ShortcutsOverlay } from './ShortcutsOverlay'
|
||||||
|
import { LanguageSwitcher } from './LanguageSwitcher'
|
||||||
|
|
||||||
interface MeResponse {
|
interface MeResponse {
|
||||||
sub: string
|
sub: string
|
||||||
|
|
@ -22,6 +24,8 @@ interface MeResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
type NavItem = { to: string; icon: typeof LayoutDashboard; label: string; end?: boolean }
|
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[] }
|
type NavSection = { group: string; items: NavItem[] }
|
||||||
|
|
||||||
const ROLE_RU: Record<string, string> = {
|
const ROLE_RU: Record<string, string> = {
|
||||||
|
|
@ -56,89 +60,89 @@ function buildNav(roles: string[]): NavSection[] {
|
||||||
const isStorekeeper = roles.includes('Storekeeper')
|
const isStorekeeper = roles.includes('Storekeeper')
|
||||||
|
|
||||||
const sections: NavSection[] = [
|
const sections: NavSection[] = [
|
||||||
{ group: 'Главное', items: [
|
{ group: 'nav.section_main', items: [
|
||||||
{ to: '/', icon: LayoutDashboard, label: 'Главная', end: true },
|
{ to: '/', icon: LayoutDashboard, label: 'nav.dashboardLink', end: true },
|
||||||
{ to: '/dashboard', icon: LayoutDashboard, label: 'Аналитика' },
|
{ to: '/dashboard', icon: LayoutDashboard, label: 'nav.analytics' },
|
||||||
]},
|
]},
|
||||||
]
|
]
|
||||||
|
|
||||||
// Каталог — Кассиру и Кладовщику только просмотр товаров; группы/типы цен/единицы — admin.
|
// Каталог — Кассиру и Кладовщику только просмотр товаров; группы/типы цен/единицы — admin.
|
||||||
if (isAdmin || isCashier || isStorekeeper) {
|
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) {
|
if (isAdmin) {
|
||||||
catalog.push(
|
catalog.push(
|
||||||
{ to: '/catalog/product-groups', icon: FolderTree, label: 'Группы' },
|
{ to: '/catalog/product-groups', icon: FolderTree, label: 'nav.productGroups' },
|
||||||
{ to: '/catalog/units', icon: Ruler, label: 'Ед. измерения' },
|
{ to: '/catalog/units', icon: Ruler, label: 'nav.units' },
|
||||||
{ to: '/catalog/price-types', icon: Tag, label: 'Типы цен' },
|
{ to: '/catalog/price-types', icon: Tag, label: 'nav.priceTypes' },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
sections.push({ group: 'Каталог', items: catalog })
|
sections.push({ group: 'nav.section_catalog', items: catalog })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
sections.push({ group: 'Контрагенты', items: [
|
sections.push({ group: 'nav.section_counterparties', items: [
|
||||||
{ to: '/catalog/counterparties', icon: Users, label: 'Контрагенты' },
|
{ to: '/catalog/counterparties', icon: Users, label: 'nav.counterparties' },
|
||||||
]})
|
]})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Остатки видят все три tenant-роли.
|
// Остатки видят все три tenant-роли.
|
||||||
if (isAdmin || isCashier || isStorekeeper) {
|
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) {
|
if (isAdmin || isStorekeeper) {
|
||||||
stock.push({ to: '/inventory/movements', icon: History, label: 'Движения' })
|
stock.push({ to: '/inventory/movements', icon: History, label: 'nav.stockMovements' })
|
||||||
stock.push({ to: '/inventory/enters', icon: PackagePlus, label: 'Оприходования' })
|
stock.push({ to: '/inventory/enters', icon: PackagePlus, label: 'nav.enters' })
|
||||||
stock.push({ to: '/inventory/losses', icon: PackageMinus, label: 'Списания' })
|
stock.push({ to: '/inventory/losses', icon: PackageMinus, label: 'nav.losses' })
|
||||||
stock.push({ to: '/inventory/transfers', icon: ArrowRightLeft, label: 'Перемещения' })
|
stock.push({ to: '/inventory/transfers', icon: ArrowRightLeft, label: 'nav.transfers' })
|
||||||
stock.push({ to: '/inventory/inventories', icon: ClipboardCheck, label: 'Инвентаризации' })
|
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.
|
// Закупки — Admin и Storekeeper.
|
||||||
if (isAdmin || isStorekeeper) {
|
if (isAdmin || isStorekeeper) {
|
||||||
sections.push({ group: 'Закупки', items: [
|
sections.push({ group: 'nav.section_purchases', items: [
|
||||||
{ to: '/purchases/supplies', icon: TruckIcon, label: 'Приёмки' },
|
{ to: '/purchases/supplies', icon: TruckIcon, label: 'nav.supplies' },
|
||||||
{ to: '/purchases/supplier-returns', icon: Undo2, label: 'Возвраты поставщикам' },
|
{ to: '/purchases/supplier-returns', icon: Undo2, label: 'nav.supplierReturns' },
|
||||||
]})
|
]})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Продажи — Admin и Cashier.
|
// Продажи — Admin и Cashier.
|
||||||
if (isAdmin || isCashier) {
|
if (isAdmin || isCashier) {
|
||||||
sections.push({ group: 'Продажи', items: [
|
sections.push({ group: 'nav.section_sales', items: [
|
||||||
{ to: '/sales/retail', icon: ShoppingCart, label: 'Розничные чеки' },
|
{ to: '/sales/retail', icon: ShoppingCart, label: 'nav.retailSales' },
|
||||||
...(isAdmin ? [{ to: '/sales/demands', icon: Send, label: 'Оптовые отгрузки' }] : []),
|
...(isAdmin ? [{ to: '/sales/demands', icon: Send, label: 'nav.demands' }] : []),
|
||||||
]})
|
]})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Отчёты — Admin и Storekeeper (Cashier видит ограниченные через permissions).
|
// Отчёты — Admin и Storekeeper.
|
||||||
if (isAdmin || isStorekeeper) {
|
if (isAdmin || isStorekeeper) {
|
||||||
sections.push({ group: 'Отчёты', items: [
|
sections.push({ group: 'nav.section_reports', items: [
|
||||||
{ to: '/reports/sales', icon: BarChart3, label: 'Продажи' },
|
{ to: '/reports/sales', icon: BarChart3, label: 'nav.reportSales' },
|
||||||
{ to: '/reports/stock', icon: Boxes, label: 'Остатки на дату' },
|
{ to: '/reports/stock', icon: Boxes, label: 'nav.reportStock' },
|
||||||
{ to: '/reports/profit', icon: TrendingUp, label: 'Прибыль' },
|
{ to: '/reports/profit', icon: TrendingUp, label: 'nav.reportProfit' },
|
||||||
{ to: '/reports/abc', icon: Target, label: 'ABC-анализ' },
|
{ to: '/reports/abc', icon: Target, label: 'nav.reportAbc' },
|
||||||
]})
|
]})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
sections.push({ group: 'Импорт', items: [
|
sections.push({ group: 'nav.section_admin', items: [
|
||||||
{ to: '/admin/import/moysklad', icon: Download, label: 'МойСклад' },
|
{ to: '/admin/import/moysklad', icon: Download, label: 'nav.moysklad' },
|
||||||
]})
|
]})
|
||||||
sections.push({ group: 'Настройки организации', items: [
|
sections.push({ group: 'nav.section_settings', items: [
|
||||||
{ to: '/settings/organization', icon: Settings, label: 'Общие' },
|
{ to: '/settings/organization', icon: Settings, label: 'nav.settingsGeneral' },
|
||||||
{ to: '/catalog/stores', icon: Warehouse, label: 'Склады' },
|
{ to: '/catalog/stores', icon: Warehouse, label: 'nav.stores' },
|
||||||
{ to: '/catalog/retail-points', icon: StoreIcon, label: 'Кассы' },
|
{ to: '/catalog/retail-points', icon: StoreIcon, label: 'nav.retailPoints' },
|
||||||
{ to: '/settings/employees', icon: UserCog, label: 'Сотрудники' },
|
{ to: '/settings/employees', icon: UserCog, label: 'nav.employees' },
|
||||||
{ to: '/settings/employee-roles', icon: Shield, label: 'Роли' },
|
{ to: '/settings/employee-roles', icon: Shield, label: 'nav.employeeRoles' },
|
||||||
{ to: '/audit-log', icon: FileText, label: 'Журнал изменений' },
|
{ to: '/audit-log', icon: FileText, label: 'nav.auditLog' },
|
||||||
]})
|
]})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSuperAdmin) {
|
if (isSuperAdmin) {
|
||||||
sections.push({
|
sections.push({
|
||||||
group: 'Супер-админ',
|
group: 'nav.section_super_admin',
|
||||||
items: [
|
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() {
|
export function AppLayout() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { data: me } = useQuery({
|
const { data: me } = useQuery({
|
||||||
queryKey: ['me'],
|
queryKey: ['me'],
|
||||||
queryFn: async () => (await api.get<MeResponse>('/api/me')).data,
|
queryFn: async () => (await api.get<MeResponse>('/api/me')).data,
|
||||||
|
|
@ -184,7 +189,7 @@ export function AppLayout() {
|
||||||
<button
|
<button
|
||||||
onClick={() => setDrawerOpen(false)}
|
onClick={() => setDrawerOpen(false)}
|
||||||
className="md:hidden text-slate-500 hover:text-slate-800"
|
className="md:hidden text-slate-500 hover:text-slate-800"
|
||||||
aria-label="Закрыть меню"
|
aria-label={t('nav.closeMenu')}
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -193,7 +198,7 @@ export function AppLayout() {
|
||||||
<nav className="flex-1 overflow-y-auto py-3">
|
<nav className="flex-1 overflow-y-auto py-3">
|
||||||
{nav.map((section) => (
|
{nav.map((section) => (
|
||||||
<div key={section.group} className="mb-4">
|
<div key={section.group} className="mb-4">
|
||||||
<div className="px-5 text-xs uppercase tracking-wide text-slate-400 mb-1">{section.group}</div>
|
<div className="px-5 text-xs uppercase tracking-wide text-slate-400 mb-1">{t(section.group)}</div>
|
||||||
{section.items.map((item) => (
|
{section.items.map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
key={item.to}
|
||||||
|
|
@ -207,7 +212,7 @@ export function AppLayout() {
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<item.icon className="w-4 h-4" />
|
<item.icon className="w-4 h-4" />
|
||||||
{item.label}
|
{t(item.label)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -221,11 +226,12 @@ export function AppLayout() {
|
||||||
<div className="truncate">{translateRoles(me.roles)}</div>
|
<div className="truncate">{translateRoles(me.roles)}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="px-2 pb-2"><LanguageSwitcher /></div>
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
className="w-full flex items-center gap-2 px-2 py-1.5 text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800/50 rounded"
|
className="w-full flex items-center gap-2 px-2 py-1.5 text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800/50 rounded"
|
||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4" /> Выход
|
<LogOut className="w-4 h-4" /> {t('nav.logout')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
@ -241,7 +247,7 @@ export function AppLayout() {
|
||||||
<button
|
<button
|
||||||
onClick={() => setDrawerOpen(true)}
|
onClick={() => setDrawerOpen(true)}
|
||||||
className="text-slate-600 dark:text-slate-300"
|
className="text-slate-600 dark:text-slate-300"
|
||||||
aria-label="Открыть меню"
|
aria-label={t('nav.openMenu')}
|
||||||
>
|
>
|
||||||
<Menu className="w-6 h-6" />
|
<Menu className="w-6 h-6" />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
37
src/food-market.web/src/components/LanguageSwitcher.tsx
Normal file
37
src/food-market.web/src/components/LanguageSwitcher.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Languages } from 'lucide-react'
|
||||||
|
import { supportedLngs, type SupportedLng } from '@/lib/i18n'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Простой селектор языка — выпадающий <select>. Текущий язык хранится в
|
||||||
|
* localStorage['fm.lang'] (i18next-browser-languagedetector делает это
|
||||||
|
* автоматически), смена применяется мгновенно ко всем useTranslation()-
|
||||||
|
* хукам в дереве компонентов.
|
||||||
|
*
|
||||||
|
* Для kz (государственный РК) пока fallback на ru — нужен живой переводчик;
|
||||||
|
* в селекте его не показываем чтобы не вводить в заблуждение.
|
||||||
|
*/
|
||||||
|
export function LanguageSwitcher() {
|
||||||
|
const { i18n, t } = useTranslation()
|
||||||
|
const current = (i18n.resolvedLanguage ?? i18n.language ?? 'ru') as SupportedLng
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className="inline-flex items-center gap-1.5 text-xs text-slate-500" title={t('lang.' + current)}>
|
||||||
|
<Languages className="w-3.5 h-3.5" />
|
||||||
|
<select
|
||||||
|
value={current}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value as SupportedLng
|
||||||
|
void i18n.changeLanguage(v)
|
||||||
|
}}
|
||||||
|
className="bg-transparent text-slate-600 dark:text-slate-300 focus:outline-none cursor-pointer"
|
||||||
|
>
|
||||||
|
{supportedLngs.map((lng) => (
|
||||||
|
<option key={lng} value={lng}>
|
||||||
|
{t('lang.' + lng)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
src/food-market.web/src/lib/i18n.ts
Normal file
42
src/food-market.web/src/lib/i18n.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
/**
|
||||||
|
* Подключение react-i18next. Используем встроенный объект ресурсов (без
|
||||||
|
* fetch'a) — bundle мал, и не нужны лишние запросы.
|
||||||
|
*
|
||||||
|
* Языки:
|
||||||
|
* ru — русский (default, есть полный перевод)
|
||||||
|
* en — английский (полный перевод сделан вручную)
|
||||||
|
* kz — казахский (TODO: машинный перевод тут не подходит, требуется
|
||||||
|
* живой переводчик; пока fallback на ru)
|
||||||
|
*
|
||||||
|
* Выбор языка приоритет:
|
||||||
|
* 1. localStorage['fm.lang'] — если пользователь сменил руками
|
||||||
|
* 2. navigator.language (en-* → en, остальные → ru)
|
||||||
|
*/
|
||||||
|
import i18n from 'i18next'
|
||||||
|
import { initReactI18next } from 'react-i18next'
|
||||||
|
import LanguageDetector from 'i18next-browser-languagedetector'
|
||||||
|
import ru from '@/locales/ru.json'
|
||||||
|
import en from '@/locales/en.json'
|
||||||
|
|
||||||
|
export const supportedLngs = ['ru', 'en'] as const
|
||||||
|
export type SupportedLng = (typeof supportedLngs)[number]
|
||||||
|
|
||||||
|
void i18n
|
||||||
|
.use(LanguageDetector)
|
||||||
|
.use(initReactI18next)
|
||||||
|
.init({
|
||||||
|
resources: {
|
||||||
|
ru: { translation: ru },
|
||||||
|
en: { translation: en },
|
||||||
|
},
|
||||||
|
fallbackLng: 'ru',
|
||||||
|
supportedLngs: [...supportedLngs],
|
||||||
|
interpolation: { escapeValue: false }, // react sanitizes
|
||||||
|
detection: {
|
||||||
|
order: ['localStorage', 'navigator'],
|
||||||
|
lookupLocalStorage: 'fm.lang',
|
||||||
|
caches: ['localStorage'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export { i18n }
|
||||||
190
src/food-market.web/src/locales/en.json
Normal file
190
src/food-market.web/src/locales/en.json
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
{
|
||||||
|
"lang": {
|
||||||
|
"ru": "Русский",
|
||||||
|
"en": "English",
|
||||||
|
"kz": "Қазақша"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"save": "Save",
|
||||||
|
"saving": "Saving…",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"delete": "Delete",
|
||||||
|
"create": "Create",
|
||||||
|
"add": "Add",
|
||||||
|
"search": "Search…",
|
||||||
|
"back": "Back",
|
||||||
|
"edit": "Edit",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"noData": "No data",
|
||||||
|
"yes": "Yes",
|
||||||
|
"no": "No",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"close": "Close",
|
||||||
|
"ok": "OK",
|
||||||
|
"filter": "Filters",
|
||||||
|
"reset": "Reset",
|
||||||
|
"page": "Page",
|
||||||
|
"of": "of",
|
||||||
|
"rows": "records",
|
||||||
|
"all": "all",
|
||||||
|
"name": "Name",
|
||||||
|
"description": "Description",
|
||||||
|
"date": "Date",
|
||||||
|
"status": "Status",
|
||||||
|
"actions": "Actions",
|
||||||
|
"required": "required",
|
||||||
|
"active": "Active",
|
||||||
|
"archived": "Archived",
|
||||||
|
"yesterday": "Yesterday",
|
||||||
|
"today": "Today",
|
||||||
|
"month": "Month",
|
||||||
|
"live": "live",
|
||||||
|
"offline": "offline"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"section_main": "Main",
|
||||||
|
"section_catalog": "Catalog",
|
||||||
|
"section_counterparties": "Counterparties",
|
||||||
|
"section_inventory": "Inventory",
|
||||||
|
"section_purchases": "Purchases",
|
||||||
|
"section_sales": "Sales",
|
||||||
|
"section_reports": "Reports",
|
||||||
|
"section_admin": "Administration",
|
||||||
|
"section_settings": "Settings",
|
||||||
|
"section_super_admin": "Super admin",
|
||||||
|
"dashboardLink": "Home",
|
||||||
|
"analytics": "Analytics",
|
||||||
|
"products": "Products",
|
||||||
|
"productGroups": "Groups",
|
||||||
|
"units": "Units",
|
||||||
|
"priceTypes": "Price types",
|
||||||
|
"counterparties": "Counterparties",
|
||||||
|
"stock": "Stock",
|
||||||
|
"stockMovements": "Movements",
|
||||||
|
"enters": "Receipts in",
|
||||||
|
"losses": "Write-offs",
|
||||||
|
"transfers": "Transfers",
|
||||||
|
"inventories": "Stocktake",
|
||||||
|
"supplies": "Supplies",
|
||||||
|
"supplierReturns": "Supplier returns",
|
||||||
|
"retailSales": "Retail sales",
|
||||||
|
"demands": "Wholesale",
|
||||||
|
"reportSales": "Sales",
|
||||||
|
"reportStock": "Stock on date",
|
||||||
|
"reportProfit": "Profit",
|
||||||
|
"reportAbc": "ABC analysis",
|
||||||
|
"auditLog": "Audit log",
|
||||||
|
"moysklad": "MoySklad",
|
||||||
|
"settingsGeneral": "General",
|
||||||
|
"stores": "Stores",
|
||||||
|
"retailPoints": "Cashiers",
|
||||||
|
"employees": "Employees",
|
||||||
|
"employeeRoles": "Roles",
|
||||||
|
"superAdminConsole": "System console",
|
||||||
|
"logout": "Sign out",
|
||||||
|
"openMenu": "Open menu",
|
||||||
|
"closeMenu": "Close menu"
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"title": "Dashboard",
|
||||||
|
"welcome": "Welcome, {{name}}",
|
||||||
|
"fallbackDescription": "Sales and catalog summary",
|
||||||
|
"revenueToday": "Revenue today",
|
||||||
|
"revenueMonth": "Revenue this month",
|
||||||
|
"avgTicket": "Avg. receipt",
|
||||||
|
"perMonth": "per month",
|
||||||
|
"prevMonth": "Previous month",
|
||||||
|
"forCompare": "for comparison",
|
||||||
|
"receiptsCount": "{{count}} receipts",
|
||||||
|
"deltaSuffix": "% vs previous month",
|
||||||
|
"chartTitle": "Revenue for 30 days",
|
||||||
|
"chartSubtitle": "Daily sales sum, posted receipts",
|
||||||
|
"noSales": "No receipts yet.",
|
||||||
|
"noSalesHint": "Chart will appear when first sales happen.",
|
||||||
|
"catalogSection": "Catalog",
|
||||||
|
"productsCount": "Products",
|
||||||
|
"counterpartiesCount": "Counterparties",
|
||||||
|
"storesCount": "Stores",
|
||||||
|
"retailPointsCount": "Cashiers",
|
||||||
|
"liveOn": "Live updates enabled",
|
||||||
|
"liveOff": "Live updates off"
|
||||||
|
},
|
||||||
|
"products": {
|
||||||
|
"pageTitle": "Products",
|
||||||
|
"subtitle": "Goods and services catalog",
|
||||||
|
"create": "Create first product",
|
||||||
|
"empty": "Nothing here yet",
|
||||||
|
"emptyDescription": "Products will appear when you create the first one or post a supply.",
|
||||||
|
"edit": "Editing",
|
||||||
|
"new": "New product",
|
||||||
|
"newSubtitle": "Creating a new catalog item",
|
||||||
|
"fieldName": "Name",
|
||||||
|
"fieldArticle": "SKU",
|
||||||
|
"fieldGroup": "Group",
|
||||||
|
"fieldUnit": "Unit",
|
||||||
|
"fieldPackaging": "Packaging",
|
||||||
|
"fieldBarcode": "Barcode",
|
||||||
|
"fieldCost": "Cost",
|
||||||
|
"fieldRetailPrice": "Retail price",
|
||||||
|
"deleteConfirmTitle": "Delete product?",
|
||||||
|
"deleteConfirmBody": "Delete «{{name}}»? This action cannot be undone.",
|
||||||
|
"groupsTitle": "Product groups"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"orgTitle": "Organization settings",
|
||||||
|
"orgDescription": "Country, currency, default VAT rate.",
|
||||||
|
"telegramTitle": "📨 Owner Telegram",
|
||||||
|
"telegramDescription": "Daily summary (yesterday's revenue, top-3 products, low-stock) at 09:00 MSK.",
|
||||||
|
"telegramBotDisabled": "Bot is not configured on the server (no token in env). Binding is currently unavailable.",
|
||||||
|
"telegramBound": "Bound: chat_id={{id}}",
|
||||||
|
"telegramUnbind": "Unbind",
|
||||||
|
"telegramBind": "Bind",
|
||||||
|
"telegramStep1": "Open {{bot}} in Telegram and send /start.",
|
||||||
|
"telegramStep2": "Open {{userinfobot}} to learn your chat_id.",
|
||||||
|
"telegramStep3": "Your chat_id"
|
||||||
|
},
|
||||||
|
"demoSeed": {
|
||||||
|
"title": "Demo data",
|
||||||
|
"description": "Fills the organization with realistic data for stage demos: 50 products in 5 groups, 10 counterparties, 5 supplies, 30 sales, 1 wholesale order, 1 write-off, 1 transfer, 1 stocktake. Idempotent — re-running does not create duplicates.",
|
||||||
|
"loadingStatus": "Loading status…",
|
||||||
|
"fillButton": "Fill with demo data",
|
||||||
|
"filling": "Filling…",
|
||||||
|
"alreadyFilled": "Already filled",
|
||||||
|
"success": "Demo data created. Reload the catalog page to see it.",
|
||||||
|
"productsLabel": "Products",
|
||||||
|
"groupsLabel": "Groups",
|
||||||
|
"counterpartiesLabel": "Counterparties",
|
||||||
|
"storesLabel": "Stores",
|
||||||
|
"suppliesLabel": "Supplies",
|
||||||
|
"salesLabel": "Sales",
|
||||||
|
"demandsLabel": "Wholesale",
|
||||||
|
"lossesLabel": "Write-offs",
|
||||||
|
"transfersLabel": "Transfers",
|
||||||
|
"inventoriesLabel": "Stocktake"
|
||||||
|
},
|
||||||
|
"shortcuts": {
|
||||||
|
"title": "Keyboard shortcuts",
|
||||||
|
"anyPage": "Any page",
|
||||||
|
"showHelp": "Show/hide hint",
|
||||||
|
"closeDialog": "Close dialog / cancel",
|
||||||
|
"lists": "Lists (Products, Counterparties, Documents)",
|
||||||
|
"focusSearch": "Focus search",
|
||||||
|
"createNew": "Create new",
|
||||||
|
"editForms": "Edit forms (Product, Document)",
|
||||||
|
"saveAction": "Save",
|
||||||
|
"backToList": "Back to list",
|
||||||
|
"hintFooter": "Press {{key}} anytime to open this cheatsheet."
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"notFound": "Not found",
|
||||||
|
"noAccess": "Access denied",
|
||||||
|
"conflict": "Conflict",
|
||||||
|
"checkFields": "Check the fields",
|
||||||
|
"tooManyRequests": "Too many requests",
|
||||||
|
"serverError": "Server error",
|
||||||
|
"saved": "Saved",
|
||||||
|
"created": "Created",
|
||||||
|
"deleted": "Deleted",
|
||||||
|
"settingsSaved": "Settings saved"
|
||||||
|
}
|
||||||
|
}
|
||||||
190
src/food-market.web/src/locales/ru.json
Normal file
190
src/food-market.web/src/locales/ru.json
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
{
|
||||||
|
"lang": {
|
||||||
|
"ru": "Русский",
|
||||||
|
"en": "English",
|
||||||
|
"kz": "Қазақша"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"save": "Сохранить",
|
||||||
|
"saving": "Сохраняю…",
|
||||||
|
"cancel": "Отмена",
|
||||||
|
"delete": "Удалить",
|
||||||
|
"create": "Создать",
|
||||||
|
"add": "Добавить",
|
||||||
|
"search": "Поиск…",
|
||||||
|
"back": "Назад",
|
||||||
|
"edit": "Редактировать",
|
||||||
|
"loading": "Загрузка…",
|
||||||
|
"noData": "Нет данных",
|
||||||
|
"yes": "Да",
|
||||||
|
"no": "Нет",
|
||||||
|
"confirm": "Подтвердить",
|
||||||
|
"close": "Закрыть",
|
||||||
|
"ok": "Готово",
|
||||||
|
"filter": "Фильтры",
|
||||||
|
"reset": "Сбросить",
|
||||||
|
"page": "Страница",
|
||||||
|
"of": "из",
|
||||||
|
"rows": "записей",
|
||||||
|
"all": "все",
|
||||||
|
"name": "Название",
|
||||||
|
"description": "Описание",
|
||||||
|
"date": "Дата",
|
||||||
|
"status": "Статус",
|
||||||
|
"actions": "Действия",
|
||||||
|
"required": "обязательно",
|
||||||
|
"active": "Активен",
|
||||||
|
"archived": "Архив",
|
||||||
|
"yesterday": "Вчера",
|
||||||
|
"today": "Сегодня",
|
||||||
|
"month": "Месяц",
|
||||||
|
"live": "live",
|
||||||
|
"offline": "offline"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"section_main": "Главное",
|
||||||
|
"section_catalog": "Каталог",
|
||||||
|
"section_counterparties": "Контрагенты",
|
||||||
|
"section_inventory": "Остатки",
|
||||||
|
"section_purchases": "Закупки",
|
||||||
|
"section_sales": "Продажи",
|
||||||
|
"section_reports": "Отчёты",
|
||||||
|
"section_admin": "Администрирование",
|
||||||
|
"section_settings": "Настройки",
|
||||||
|
"section_super_admin": "Супер админ",
|
||||||
|
"dashboardLink": "Главная",
|
||||||
|
"analytics": "Аналитика",
|
||||||
|
"products": "Товары",
|
||||||
|
"productGroups": "Группы",
|
||||||
|
"units": "Ед. измерения",
|
||||||
|
"priceTypes": "Типы цен",
|
||||||
|
"counterparties": "Контрагенты",
|
||||||
|
"stock": "Остатки",
|
||||||
|
"stockMovements": "Движения",
|
||||||
|
"enters": "Оприходования",
|
||||||
|
"losses": "Списания",
|
||||||
|
"transfers": "Перемещения",
|
||||||
|
"inventories": "Инвентаризации",
|
||||||
|
"supplies": "Приёмки",
|
||||||
|
"supplierReturns": "Возвраты поставщикам",
|
||||||
|
"retailSales": "Розничные чеки",
|
||||||
|
"demands": "Оптовые отгрузки",
|
||||||
|
"reportSales": "Продажи",
|
||||||
|
"reportStock": "Остатки на дату",
|
||||||
|
"reportProfit": "Прибыль",
|
||||||
|
"reportAbc": "ABC-анализ",
|
||||||
|
"auditLog": "Журнал изменений",
|
||||||
|
"moysklad": "МойСклад",
|
||||||
|
"settingsGeneral": "Общие",
|
||||||
|
"stores": "Склады",
|
||||||
|
"retailPoints": "Кассы",
|
||||||
|
"employees": "Сотрудники",
|
||||||
|
"employeeRoles": "Роли",
|
||||||
|
"superAdminConsole": "Системная консоль",
|
||||||
|
"logout": "Выход",
|
||||||
|
"openMenu": "Открыть меню",
|
||||||
|
"closeMenu": "Закрыть меню"
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"title": "Dashboard",
|
||||||
|
"welcome": "Добро пожаловать, {{name}}",
|
||||||
|
"fallbackDescription": "Сводка по продажам и каталогу",
|
||||||
|
"revenueToday": "Выручка сегодня",
|
||||||
|
"revenueMonth": "Выручка за месяц",
|
||||||
|
"avgTicket": "Средний чек",
|
||||||
|
"perMonth": "за месяц",
|
||||||
|
"prevMonth": "Прошлый месяц",
|
||||||
|
"forCompare": "для сравнения",
|
||||||
|
"receiptsCount": "{{count}} чеков",
|
||||||
|
"deltaSuffix": "% к прошлому месяцу",
|
||||||
|
"chartTitle": "Выручка за 30 дней",
|
||||||
|
"chartSubtitle": "Сумма продаж по дням, проведённые чеки",
|
||||||
|
"noSales": "Чеков пока нет.",
|
||||||
|
"noSalesHint": "График появится когда появятся первые продажи.",
|
||||||
|
"catalogSection": "Каталог",
|
||||||
|
"productsCount": "Товаров",
|
||||||
|
"counterpartiesCount": "Контрагентов",
|
||||||
|
"storesCount": "Складов",
|
||||||
|
"retailPointsCount": "Точек продаж",
|
||||||
|
"liveOn": "Live-обновления включены",
|
||||||
|
"liveOff": "Live-обновления отключены"
|
||||||
|
},
|
||||||
|
"products": {
|
||||||
|
"pageTitle": "Товары",
|
||||||
|
"subtitle": "Каталог товаров и услуг",
|
||||||
|
"create": "Создать первый товар",
|
||||||
|
"empty": "Здесь пока пусто",
|
||||||
|
"emptyDescription": "Товары появятся когда вы создадите первый или сделаете приёмку.",
|
||||||
|
"edit": "Редактирование",
|
||||||
|
"new": "Новый товар",
|
||||||
|
"newSubtitle": "Создание новой позиции каталога",
|
||||||
|
"fieldName": "Название",
|
||||||
|
"fieldArticle": "Артикул",
|
||||||
|
"fieldGroup": "Группа",
|
||||||
|
"fieldUnit": "Единица измерения",
|
||||||
|
"fieldPackaging": "Фасовка",
|
||||||
|
"fieldBarcode": "Штрихкод",
|
||||||
|
"fieldCost": "Себестоимость",
|
||||||
|
"fieldRetailPrice": "Розничная цена",
|
||||||
|
"deleteConfirmTitle": "Удалить товар?",
|
||||||
|
"deleteConfirmBody": "Удалить «{{name}}»? Действие необратимо.",
|
||||||
|
"groupsTitle": "Группы товаров"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"orgTitle": "Настройки организации",
|
||||||
|
"orgDescription": "Страна, валюта, ставка НДС по умолчанию.",
|
||||||
|
"telegramTitle": "📨 Telegram владельца",
|
||||||
|
"telegramDescription": "Ежедневная сводка (выручка вчера, топ-3 товара, low-stock) в 09:00 МСК.",
|
||||||
|
"telegramBotDisabled": "Бот не настроен на сервере (нет токена в env). Привязка пока недоступна.",
|
||||||
|
"telegramBound": "Привязан: chat_id={{id}}",
|
||||||
|
"telegramUnbind": "Отвязать",
|
||||||
|
"telegramBind": "Привязать",
|
||||||
|
"telegramStep1": "Откройте {{bot}} в Telegram, отправьте /start.",
|
||||||
|
"telegramStep2": "Откройте {{userinfobot}}, чтобы узнать свой chat_id.",
|
||||||
|
"telegramStep3": "Ваш chat_id"
|
||||||
|
},
|
||||||
|
"demoSeed": {
|
||||||
|
"title": "Демо-данные",
|
||||||
|
"description": "Заполняет организацию реалистичными данными для демонстрации стейджа: 50 товаров в 5 группах, 10 контрагентов, 5 приёмок, 30 продаж, 1 опт-отгрузка, 1 списание, 1 перемещение, 1 инвентаризация. Идемпотентно — повторный запуск не создаст дубликатов.",
|
||||||
|
"loadingStatus": "Загружаю статус…",
|
||||||
|
"fillButton": "Заполнить демо-данными",
|
||||||
|
"filling": "Наполняю…",
|
||||||
|
"alreadyFilled": "Уже заполнено",
|
||||||
|
"success": "Демо-данные созданы. Перезагрузите страницу каталога чтобы увидеть.",
|
||||||
|
"productsLabel": "Товаров",
|
||||||
|
"groupsLabel": "Групп",
|
||||||
|
"counterpartiesLabel": "Контрагентов",
|
||||||
|
"storesLabel": "Складов",
|
||||||
|
"suppliesLabel": "Приёмок",
|
||||||
|
"salesLabel": "Продаж",
|
||||||
|
"demandsLabel": "Опт",
|
||||||
|
"lossesLabel": "Списаний",
|
||||||
|
"transfersLabel": "Перемещений",
|
||||||
|
"inventoriesLabel": "Инвентар."
|
||||||
|
},
|
||||||
|
"shortcuts": {
|
||||||
|
"title": "Горячие клавиши",
|
||||||
|
"anyPage": "На любой странице",
|
||||||
|
"showHelp": "Показать/скрыть подсказку",
|
||||||
|
"closeDialog": "Закрыть диалог / отменить",
|
||||||
|
"lists": "Списки (Товары, Контрагенты, Документы)",
|
||||||
|
"focusSearch": "Фокус в поиск",
|
||||||
|
"createNew": "Создать новый",
|
||||||
|
"editForms": "Edit-формы (Товар, Документ)",
|
||||||
|
"saveAction": "Сохранить",
|
||||||
|
"backToList": "Назад к списку",
|
||||||
|
"hintFooter": "Нажми {{key}} в любой момент, чтобы открыть эту шпаргалку."
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"notFound": "Не найдено",
|
||||||
|
"noAccess": "Нет доступа",
|
||||||
|
"conflict": "Конфликт",
|
||||||
|
"checkFields": "Проверьте поля",
|
||||||
|
"tooManyRequests": "Слишком много запросов",
|
||||||
|
"serverError": "Ошибка сервера",
|
||||||
|
"saved": "Сохранено",
|
||||||
|
"created": "Создано",
|
||||||
|
"deleted": "Удалено",
|
||||||
|
"settingsSaved": "Настройки сохранены"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
import '@/lib/i18n'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Package, Users, Warehouse, Store, TrendingUp, TrendingDown, Receipt, Banknote, Calendar, Wifi, WifiOff } from 'lucide-react'
|
import { Package, Users, Warehouse, Store, TrendingUp, TrendingDown, Receipt, Banknote, Calendar, Wifi, WifiOff } from 'lucide-react'
|
||||||
import { PageHeader } from '@/components/PageHeader'
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
import { SalesChart } from '@/components/SalesChart'
|
import { SalesChart } from '@/components/SalesChart'
|
||||||
|
|
@ -32,7 +33,7 @@ interface KpiCardProps {
|
||||||
label: string
|
label: string
|
||||||
value: string | number
|
value: string | number
|
||||||
hint?: string
|
hint?: string
|
||||||
delta?: { value: number; positive: boolean }
|
delta?: { value: number; positive: boolean; suffix?: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
function KpiCard({ icon: Icon, label, value, hint, delta }: KpiCardProps) {
|
function KpiCard({ icon: Icon, label, value, hint, delta }: KpiCardProps) {
|
||||||
|
|
@ -51,7 +52,7 @@ function KpiCard({ icon: Icon, label, value, hint, delta }: KpiCardProps) {
|
||||||
{delta && (
|
{delta && (
|
||||||
<div className={`mt-2 inline-flex items-center gap-1 text-xs font-medium ${delta.positive ? 'text-green-600' : 'text-red-600'}`}>
|
<div className={`mt-2 inline-flex items-center gap-1 text-xs font-medium ${delta.positive ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
{delta.positive ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
|
{delta.positive ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
|
||||||
{delta.positive ? '+' : ''}{delta.value.toFixed(1)}% к прошлому месяцу
|
{delta.positive ? '+' : ''}{delta.value.toFixed(1)}{delta.suffix ?? '% к прошлому месяцу'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -79,6 +80,7 @@ function MiniCard({ icon: Icon, label, value, isLoading }: {
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
const { t } = useTranslation()
|
||||||
const me = useQuery({
|
const me = useQuery({
|
||||||
queryKey: ['me'],
|
queryKey: ['me'],
|
||||||
queryFn: async () => (await api.get<MeResponse>('/api/me')).data,
|
queryFn: async () => (await api.get<MeResponse>('/api/me')).data,
|
||||||
|
|
@ -125,16 +127,14 @@ export function DashboardPage() {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 sm:p-6 space-y-5 sm:space-y-6 overflow-auto">
|
<div className="p-4 sm:p-6 space-y-5 sm:space-y-6 overflow-auto">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Dashboard"
|
title={t('dashboard.title')}
|
||||||
description={me.data ? `Добро пожаловать, ${me.data.name}` : 'Сводка по продажам и каталогу'}
|
description={me.data ? t('dashboard.welcome', { name: me.data.name }) : t('dashboard.fallbackDescription')}
|
||||||
actions={(
|
actions={(
|
||||||
// Индикатор live-связи: SignalR connected = зелёный Wifi, off = серый.
|
<span title={isConnected ? t('dashboard.liveOn') : t('dashboard.liveOff')} className="inline-flex items-center gap-1 text-xs text-slate-500">
|
||||||
// Title с подсказкой, чтобы не было непонятной иконки.
|
|
||||||
<span title={isConnected ? 'Live-обновления включены' : 'Live-обновления отключены'} className="inline-flex items-center gap-1 text-xs text-slate-500">
|
|
||||||
{isConnected
|
{isConnected
|
||||||
? <Wifi className="w-3.5 h-3.5 text-emerald-500" />
|
? <Wifi className="w-3.5 h-3.5 text-emerald-500" />
|
||||||
: <WifiOff className="w-3.5 h-3.5 text-slate-400" />}
|
: <WifiOff className="w-3.5 h-3.5 text-slate-400" />}
|
||||||
{isConnected ? 'live' : 'offline'}
|
{isConnected ? t('common.live') : t('common.offline')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -143,28 +143,28 @@ export function DashboardPage() {
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<KpiCard
|
<KpiCard
|
||||||
icon={Banknote}
|
icon={Banknote}
|
||||||
label="Выручка сегодня"
|
label={t('dashboard.revenueToday')}
|
||||||
value={stats.isLoading ? '…' : `${fmtMoney((stats.data?.revenueToday ?? 0) + liveRevenueDelta)} ₸`}
|
value={stats.isLoading ? '…' : `${fmtMoney((stats.data?.revenueToday ?? 0) + liveRevenueDelta)} ₸`}
|
||||||
hint={`${(stats.data?.transactionsToday ?? 0) + liveCountDelta} чеков`}
|
hint={t('dashboard.receiptsCount', { count: (stats.data?.transactionsToday ?? 0) + liveCountDelta })}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
icon={Calendar}
|
icon={Calendar}
|
||||||
label="Выручка за месяц"
|
label={t('dashboard.revenueMonth')}
|
||||||
value={stats.isLoading ? '…' : `${fmtMoney(stats.data?.revenueThisMonth ?? 0)} ₸`}
|
value={stats.isLoading ? '…' : `${fmtMoney(stats.data?.revenueThisMonth ?? 0)} ₸`}
|
||||||
hint={`${stats.data?.transactionsThisMonth ?? 0} чеков`}
|
hint={t('dashboard.receiptsCount', { count: stats.data?.transactionsThisMonth ?? 0 })}
|
||||||
delta={monthDelta !== null ? { value: monthDelta, positive: monthDelta >= 0 } : undefined}
|
delta={monthDelta !== null ? { value: monthDelta, positive: monthDelta >= 0, suffix: t('dashboard.deltaSuffix') } : undefined}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
icon={Receipt}
|
icon={Receipt}
|
||||||
label="Средний чек"
|
label={t('dashboard.avgTicket')}
|
||||||
value={stats.isLoading ? '…' : `${fmtMoney(stats.data?.avgTicketThisMonth ?? 0)} ₸`}
|
value={stats.isLoading ? '…' : `${fmtMoney(stats.data?.avgTicketThisMonth ?? 0)} ₸`}
|
||||||
hint="за месяц"
|
hint={t('dashboard.perMonth')}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
icon={TrendingUp}
|
icon={TrendingUp}
|
||||||
label="Прошлый месяц"
|
label={t('dashboard.prevMonth')}
|
||||||
value={stats.isLoading ? '…' : `${fmtMoney(stats.data?.revenuePrevMonth ?? 0)} ₸`}
|
value={stats.isLoading ? '…' : `${fmtMoney(stats.data?.revenuePrevMonth ?? 0)} ₸`}
|
||||||
hint="для сравнения"
|
hint={t('dashboard.forCompare')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -172,18 +172,17 @@ export function DashboardPage() {
|
||||||
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5">
|
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-100">Выручка за 30 дней</h2>
|
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-100">{t('dashboard.chartTitle')}</h2>
|
||||||
<p className="text-xs text-slate-500 mt-0.5">Сумма продаж по дням, проведённые чеки</p>
|
<p className="text-xs text-slate-500 mt-0.5">{t('dashboard.chartSubtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{stats.isLoading ? (
|
{stats.isLoading ? (
|
||||||
// Shimmer на месте графика: примерно той же высоты, чтобы layout не прыгал.
|
|
||||||
<Skeleton variant="block" className="h-72 w-full" />
|
<Skeleton variant="block" className="h-72 w-full" />
|
||||||
) : !hasAnySales ? (
|
) : !hasAnySales ? (
|
||||||
<div className="h-72 flex flex-col items-center justify-center text-slate-400 text-sm gap-2">
|
<div className="h-72 flex flex-col items-center justify-center text-slate-400 text-sm gap-2">
|
||||||
<Receipt className="w-8 h-8 text-slate-300" />
|
<Receipt className="w-8 h-8 text-slate-300" />
|
||||||
<div>Чеков пока нет.</div>
|
<div>{t('dashboard.noSales')}</div>
|
||||||
<div className="text-xs">График появится когда появятся первые продажи.</div>
|
<div className="text-xs">{t('dashboard.noSalesHint')}</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<SalesChart series={stats.data!.series} currencyCode="₸" />
|
<SalesChart series={stats.data!.series} currencyCode="₸" />
|
||||||
|
|
@ -193,13 +192,13 @@ export function DashboardPage() {
|
||||||
{/* Каталог */}
|
{/* Каталог */}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3 uppercase tracking-wide">
|
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3 uppercase tracking-wide">
|
||||||
Каталог
|
{t('dashboard.catalogSection')}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<MiniCard icon={Package} label="Товаров" value={products.data} isLoading={products.isLoading} />
|
<MiniCard icon={Package} label={t('dashboard.productsCount')} value={products.data} isLoading={products.isLoading} />
|
||||||
<MiniCard icon={Users} label="Контрагентов" value={counterparties.data} isLoading={counterparties.isLoading} />
|
<MiniCard icon={Users} label={t('dashboard.counterpartiesCount')} value={counterparties.data} isLoading={counterparties.isLoading} />
|
||||||
<MiniCard icon={Warehouse} label="Складов" value={stores.data} isLoading={stores.isLoading} />
|
<MiniCard icon={Warehouse} label={t('dashboard.storesCount')} value={stores.data} isLoading={stores.isLoading} />
|
||||||
<MiniCard icon={Store} label="Точек продаж" value={retailPoints.data} isLoading={retailPoints.isLoading} />
|
<MiniCard icon={Store} label={t('dashboard.retailPointsCount')} value={retailPoints.data} isLoading={retailPoints.isLoading} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue