feat(i18n): react-i18next ru/en + language switcher (P2-6a — базовая)
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions

Подключён 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:
nns 2026-05-31 20:03:33 +05:00
parent 749829c12f
commit 301bf15924
9 changed files with 614 additions and 72 deletions

View file

@ -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",

View file

@ -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:

View file

@ -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>

View 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>
)
}

View 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 }

View 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"
}
}

View 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": "Настройки сохранены"
}
}

View file

@ -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(

View file

@ -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>