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",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"i18next": "^26.3.0",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.8.0",
"react": "^19.2.5",
"react-datepicker": "^9.1.0",
"react-dom": "^19.2.5",
"react-hook-form": "^7.73.1",
"react-i18next": "^17.0.8",
"react-router-dom": "^7.14.1",
"recharts": "^3.8.1",
"tailwind-merge": "^3.5.0",

View file

@ -32,6 +32,12 @@ importers:
date-fns:
specifier: ^4.1.0
version: 4.1.0
i18next:
specifier: ^26.3.0
version: 26.3.0(typescript@6.0.3)
i18next-browser-languagedetector:
specifier: ^8.2.1
version: 8.2.1
lucide-react:
specifier: ^1.8.0
version: 1.8.0(react@19.2.5)
@ -47,6 +53,9 @@ importers:
react-hook-form:
specifier: ^7.73.1
version: 7.73.1(react@19.2.5)
react-i18next:
specifier: ^17.0.8
version: 17.0.8(i18next@26.3.0(typescript@6.0.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3)
react-router-dom:
specifier: ^7.14.1
version: 7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@ -172,6 +181,10 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/runtime@7.29.7':
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
engines: {node: '>=6.9.0'}
'@babel/template@7.28.6':
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
engines: {node: '>=6.9.0'}
@ -1083,10 +1096,24 @@ packages:
hermes-parser@0.25.1:
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
html-parse-stringify@3.0.1:
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
https-proxy-agent@7.0.6:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
i18next-browser-languagedetector@8.2.1:
resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==}
i18next@26.3.0:
resolution: {integrity: sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==}
peerDependencies:
typescript: ^5 || ^6
peerDependenciesMeta:
typescript:
optional: true
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@ -1401,6 +1428,22 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
react-i18next@17.0.8:
resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==}
peerDependencies:
i18next: '>= 26.2.0'
react: '>= 16.8.0'
react-dom: '*'
react-native: '*'
typescript: ^5 || ^6
peerDependenciesMeta:
react-dom:
optional: true
react-native:
optional: true
typescript:
optional: true
react-is@19.2.5:
resolution: {integrity: sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==}
@ -1645,6 +1688,10 @@ packages:
yaml:
optional: true
void-elements@3.1.0:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
engines: {node: '>=0.10.0'}
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
@ -1774,6 +1821,8 @@ snapshots:
dependencies:
'@babel/types': 7.29.0
'@babel/runtime@7.29.7': {}
'@babel/template@7.28.6':
dependencies:
'@babel/code-frame': 7.29.0
@ -2661,6 +2710,10 @@ snapshots:
dependencies:
hermes-estree: 0.25.1
html-parse-stringify@3.0.1:
dependencies:
void-elements: 3.1.0
https-proxy-agent@7.0.6(supports-color@10.2.2):
dependencies:
agent-base: 7.1.4
@ -2668,6 +2721,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
i18next-browser-languagedetector@8.2.1:
dependencies:
'@babel/runtime': 7.29.7
i18next@26.3.0(typescript@6.0.3):
optionalDependencies:
typescript: 6.0.3
ignore@5.3.2: {}
ignore@7.0.5: {}
@ -2909,6 +2970,17 @@ snapshots:
dependencies:
react: 19.2.5
react-i18next@17.0.8(i18next@26.3.0(typescript@6.0.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3):
dependencies:
'@babel/runtime': 7.29.7
html-parse-stringify: 3.0.1
i18next: 26.3.0(typescript@6.0.3)
react: 19.2.5
use-sync-external-store: 1.6.0(react@19.2.5)
optionalDependencies:
react-dom: 19.2.5(react@19.2.5)
typescript: 6.0.3
react-is@19.2.5: {}
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1):
@ -3123,6 +3195,8 @@ snapshots:
fsevents: 2.3.3
jiti: 2.6.1
void-elements@3.1.0: {}
webidl-conversions@3.0.1: {}
whatwg-url@5.0.0:

View file

@ -9,9 +9,11 @@ import {
Users, Warehouse, Store as StoreIcon, LogOut, Download, UserCog, Shield, ShieldCheck,
Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, PackagePlus, PackageMinus, ArrowRightLeft, ClipboardCheck, Undo2, BarChart3, TrendingUp, Target, Send, FileText,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Logo } from './Logo'
import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner'
import { ShortcutsOverlay } from './ShortcutsOverlay'
import { LanguageSwitcher } from './LanguageSwitcher'
interface MeResponse {
sub: string
@ -22,6 +24,8 @@ interface MeResponse {
}
type NavItem = { to: string; icon: typeof LayoutDashboard; label: string; end?: boolean }
/** group/labels i18n-ключи (см. src/locales/*.json раздел `nav`).
* Render-сторона переводит через useTranslation().t(key). */
type NavSection = { group: string; items: NavItem[] }
const ROLE_RU: Record<string, string> = {
@ -56,89 +60,89 @@ function buildNav(roles: string[]): NavSection[] {
const isStorekeeper = roles.includes('Storekeeper')
const sections: NavSection[] = [
{ group: 'Главное', items: [
{ to: '/', icon: LayoutDashboard, label: 'Главная', end: true },
{ to: '/dashboard', icon: LayoutDashboard, label: 'Аналитика' },
{ group: 'nav.section_main', items: [
{ to: '/', icon: LayoutDashboard, label: 'nav.dashboardLink', end: true },
{ to: '/dashboard', icon: LayoutDashboard, label: 'nav.analytics' },
]},
]
// Каталог — Кассиру и Кладовщику только просмотр товаров; группы/типы цен/единицы — admin.
if (isAdmin || isCashier || isStorekeeper) {
const catalog: NavItem[] = [{ to: '/catalog/products', icon: Package, label: 'Товары' }]
const catalog: NavItem[] = [{ to: '/catalog/products', icon: Package, label: 'nav.products' }]
if (isAdmin) {
catalog.push(
{ to: '/catalog/product-groups', icon: FolderTree, label: 'Группы' },
{ to: '/catalog/units', icon: Ruler, label: 'Ед. измерения' },
{ to: '/catalog/price-types', icon: Tag, label: 'Типы цен' },
{ to: '/catalog/product-groups', icon: FolderTree, label: 'nav.productGroups' },
{ to: '/catalog/units', icon: Ruler, label: 'nav.units' },
{ to: '/catalog/price-types', icon: Tag, label: 'nav.priceTypes' },
)
}
sections.push({ group: 'Каталог', items: catalog })
sections.push({ group: 'nav.section_catalog', items: catalog })
}
if (isAdmin) {
sections.push({ group: 'Контрагенты', items: [
{ to: '/catalog/counterparties', icon: Users, label: 'Контрагенты' },
sections.push({ group: 'nav.section_counterparties', items: [
{ to: '/catalog/counterparties', icon: Users, label: 'nav.counterparties' },
]})
}
// Остатки видят все три tenant-роли.
if (isAdmin || isCashier || isStorekeeper) {
const stock: NavItem[] = [{ to: '/inventory/stock', icon: Boxes, label: 'Остатки' }]
const stock: NavItem[] = [{ to: '/inventory/stock', icon: Boxes, label: 'nav.stock' }]
if (isAdmin || isStorekeeper) {
stock.push({ to: '/inventory/movements', icon: History, label: 'Движения' })
stock.push({ to: '/inventory/enters', icon: PackagePlus, label: 'Оприходования' })
stock.push({ to: '/inventory/losses', icon: PackageMinus, label: 'Списания' })
stock.push({ to: '/inventory/transfers', icon: ArrowRightLeft, label: 'Перемещения' })
stock.push({ to: '/inventory/inventories', icon: ClipboardCheck, label: 'Инвентаризации' })
stock.push({ to: '/inventory/movements', icon: History, label: 'nav.stockMovements' })
stock.push({ to: '/inventory/enters', icon: PackagePlus, label: 'nav.enters' })
stock.push({ to: '/inventory/losses', icon: PackageMinus, label: 'nav.losses' })
stock.push({ to: '/inventory/transfers', icon: ArrowRightLeft, label: 'nav.transfers' })
stock.push({ to: '/inventory/inventories', icon: ClipboardCheck, label: 'nav.inventories' })
}
sections.push({ group: 'Остатки', items: stock })
sections.push({ group: 'nav.section_inventory', items: stock })
}
// Закупки — Admin и Storekeeper.
if (isAdmin || isStorekeeper) {
sections.push({ group: 'Закупки', items: [
{ to: '/purchases/supplies', icon: TruckIcon, label: 'Приёмки' },
{ to: '/purchases/supplier-returns', icon: Undo2, label: 'Возвраты поставщикам' },
sections.push({ group: 'nav.section_purchases', items: [
{ to: '/purchases/supplies', icon: TruckIcon, label: 'nav.supplies' },
{ to: '/purchases/supplier-returns', icon: Undo2, label: 'nav.supplierReturns' },
]})
}
// Продажи — Admin и Cashier.
if (isAdmin || isCashier) {
sections.push({ group: 'Продажи', items: [
{ to: '/sales/retail', icon: ShoppingCart, label: 'Розничные чеки' },
...(isAdmin ? [{ to: '/sales/demands', icon: Send, label: 'Оптовые отгрузки' }] : []),
sections.push({ group: 'nav.section_sales', items: [
{ to: '/sales/retail', icon: ShoppingCart, label: 'nav.retailSales' },
...(isAdmin ? [{ to: '/sales/demands', icon: Send, label: 'nav.demands' }] : []),
]})
}
// Отчёты — Admin и Storekeeper (Cashier видит ограниченные через permissions).
// Отчёты — Admin и Storekeeper.
if (isAdmin || isStorekeeper) {
sections.push({ group: 'Отчёты', items: [
{ to: '/reports/sales', icon: BarChart3, label: 'Продажи' },
{ to: '/reports/stock', icon: Boxes, label: 'Остатки на дату' },
{ to: '/reports/profit', icon: TrendingUp, label: 'Прибыль' },
{ to: '/reports/abc', icon: Target, label: 'ABC-анализ' },
sections.push({ group: 'nav.section_reports', items: [
{ to: '/reports/sales', icon: BarChart3, label: 'nav.reportSales' },
{ to: '/reports/stock', icon: Boxes, label: 'nav.reportStock' },
{ to: '/reports/profit', icon: TrendingUp, label: 'nav.reportProfit' },
{ to: '/reports/abc', icon: Target, label: 'nav.reportAbc' },
]})
}
if (isAdmin) {
sections.push({ group: 'Импорт', items: [
{ to: '/admin/import/moysklad', icon: Download, label: 'МойСклад' },
sections.push({ group: 'nav.section_admin', items: [
{ to: '/admin/import/moysklad', icon: Download, label: 'nav.moysklad' },
]})
sections.push({ group: 'Настройки организации', items: [
{ to: '/settings/organization', icon: Settings, label: 'Общие' },
{ to: '/catalog/stores', icon: Warehouse, label: 'Склады' },
{ to: '/catalog/retail-points', icon: StoreIcon, label: 'Кассы' },
{ to: '/settings/employees', icon: UserCog, label: 'Сотрудники' },
{ to: '/settings/employee-roles', icon: Shield, label: 'Роли' },
{ to: '/audit-log', icon: FileText, label: 'Журнал изменений' },
sections.push({ group: 'nav.section_settings', items: [
{ to: '/settings/organization', icon: Settings, label: 'nav.settingsGeneral' },
{ to: '/catalog/stores', icon: Warehouse, label: 'nav.stores' },
{ to: '/catalog/retail-points', icon: StoreIcon, label: 'nav.retailPoints' },
{ to: '/settings/employees', icon: UserCog, label: 'nav.employees' },
{ to: '/settings/employee-roles', icon: Shield, label: 'nav.employeeRoles' },
{ to: '/audit-log', icon: FileText, label: 'nav.auditLog' },
]})
}
if (isSuperAdmin) {
sections.push({
group: 'Супер-админ',
group: 'nav.section_super_admin',
items: [
{ to: '/super-admin', icon: ShieldCheck, label: 'Системная консоль', end: true },
{ to: '/super-admin', icon: ShieldCheck, label: 'nav.superAdminConsole', end: true },
],
})
}
@ -147,6 +151,7 @@ function buildNav(roles: string[]): NavSection[] {
}
export function AppLayout() {
const { t } = useTranslation()
const { data: me } = useQuery({
queryKey: ['me'],
queryFn: async () => (await api.get<MeResponse>('/api/me')).data,
@ -184,7 +189,7 @@ export function AppLayout() {
<button
onClick={() => setDrawerOpen(false)}
className="md:hidden text-slate-500 hover:text-slate-800"
aria-label="Закрыть меню"
aria-label={t('nav.closeMenu')}
>
<X className="w-5 h-5" />
</button>
@ -193,7 +198,7 @@ export function AppLayout() {
<nav className="flex-1 overflow-y-auto py-3">
{nav.map((section) => (
<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) => (
<NavLink
key={item.to}
@ -207,7 +212,7 @@ export function AppLayout() {
)}
>
<item.icon className="w-4 h-4" />
{item.label}
{t(item.label)}
</NavLink>
))}
</div>
@ -221,11 +226,12 @@ export function AppLayout() {
<div className="truncate">{translateRoles(me.roles)}</div>
</div>
)}
<div className="px-2 pb-2"><LanguageSwitcher /></div>
<button
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"
>
<LogOut className="w-4 h-4" /> Выход
<LogOut className="w-4 h-4" /> {t('nav.logout')}
</button>
</div>
</>
@ -241,7 +247,7 @@ export function AppLayout() {
<button
onClick={() => setDrawerOpen(true)}
className="text-slate-600 dark:text-slate-300"
aria-label="Открыть меню"
aria-label={t('nav.openMenu')}
>
<Menu className="w-6 h-6" />
</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 { createRoot } from 'react-dom/client'
import './index.css'
import '@/lib/i18n'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(

View file

@ -1,5 +1,6 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
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 { PageHeader } from '@/components/PageHeader'
import { SalesChart } from '@/components/SalesChart'
@ -32,7 +33,7 @@ interface KpiCardProps {
label: string
value: string | number
hint?: string
delta?: { value: number; positive: boolean }
delta?: { value: number; positive: boolean; suffix?: string }
}
function KpiCard({ icon: Icon, label, value, hint, delta }: KpiCardProps) {
@ -51,7 +52,7 @@ function KpiCard({ icon: Icon, label, value, hint, delta }: KpiCardProps) {
{delta && (
<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 ? '+' : ''}{delta.value.toFixed(1)}% к прошлому месяцу
{delta.positive ? '+' : ''}{delta.value.toFixed(1)}{delta.suffix ?? '% к прошлому месяцу'}
</div>
)}
</div>
@ -79,6 +80,7 @@ function MiniCard({ icon: Icon, label, value, isLoading }: {
export function DashboardPage() {
const qc = useQueryClient()
const { t } = useTranslation()
const me = useQuery({
queryKey: ['me'],
queryFn: async () => (await api.get<MeResponse>('/api/me')).data,
@ -125,16 +127,14 @@ export function DashboardPage() {
return (
<div className="p-4 sm:p-6 space-y-5 sm:space-y-6 overflow-auto">
<PageHeader
title="Dashboard"
description={me.data ? `Добро пожаловать, ${me.data.name}` : 'Сводка по продажам и каталогу'}
title={t('dashboard.title')}
description={me.data ? t('dashboard.welcome', { name: me.data.name }) : t('dashboard.fallbackDescription')}
actions={(
// Индикатор live-связи: SignalR connected = зелёный Wifi, off = серый.
// Title с подсказкой, чтобы не было непонятной иконки.
<span title={isConnected ? 'Live-обновления включены' : 'Live-обновления отключены'} className="inline-flex items-center gap-1 text-xs text-slate-500">
<span title={isConnected ? t('dashboard.liveOn') : t('dashboard.liveOff')} className="inline-flex items-center gap-1 text-xs text-slate-500">
{isConnected
? <Wifi className="w-3.5 h-3.5 text-emerald-500" />
: <WifiOff className="w-3.5 h-3.5 text-slate-400" />}
{isConnected ? 'live' : 'offline'}
{isConnected ? t('common.live') : t('common.offline')}
</span>
)}
/>
@ -143,28 +143,28 @@ export function DashboardPage() {
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<KpiCard
icon={Banknote}
label="Выручка сегодня"
label={t('dashboard.revenueToday')}
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
icon={Calendar}
label="Выручка за месяц"
label={t('dashboard.revenueMonth')}
value={stats.isLoading ? '…' : `${fmtMoney(stats.data?.revenueThisMonth ?? 0)}`}
hint={`${stats.data?.transactionsThisMonth ?? 0} чеков`}
delta={monthDelta !== null ? { value: monthDelta, positive: monthDelta >= 0 } : undefined}
hint={t('dashboard.receiptsCount', { count: stats.data?.transactionsThisMonth ?? 0 })}
delta={monthDelta !== null ? { value: monthDelta, positive: monthDelta >= 0, suffix: t('dashboard.deltaSuffix') } : undefined}
/>
<KpiCard
icon={Receipt}
label="Средний чек"
label={t('dashboard.avgTicket')}
value={stats.isLoading ? '…' : `${fmtMoney(stats.data?.avgTicketThisMonth ?? 0)}`}
hint="за месяц"
hint={t('dashboard.perMonth')}
/>
<KpiCard
icon={TrendingUp}
label="Прошлый месяц"
label={t('dashboard.prevMonth')}
value={stats.isLoading ? '…' : `${fmtMoney(stats.data?.revenuePrevMonth ?? 0)}`}
hint="для сравнения"
hint={t('dashboard.forCompare')}
/>
</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">
<div className="flex items-center justify-between mb-3">
<div>
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-100">Выручка за 30 дней</h2>
<p className="text-xs text-slate-500 mt-0.5">Сумма продаж по дням, проведённые чеки</p>
<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">{t('dashboard.chartSubtitle')}</p>
</div>
</div>
{stats.isLoading ? (
// Shimmer на месте графика: примерно той же высоты, чтобы layout не прыгал.
<Skeleton variant="block" className="h-72 w-full" />
) : !hasAnySales ? (
<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" />
<div>Чеков пока нет.</div>
<div className="text-xs">График появится когда появятся первые продажи.</div>
<div>{t('dashboard.noSales')}</div>
<div className="text-xs">{t('dashboard.noSalesHint')}</div>
</div>
) : (
<SalesChart series={stats.data!.series} currencyCode="₸" />
@ -193,13 +192,13 @@ export function DashboardPage() {
{/* Каталог */}
<div>
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3 uppercase tracking-wide">
Каталог
{t('dashboard.catalogSection')}
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<MiniCard icon={Package} label="Товаров" value={products.data} isLoading={products.isLoading} />
<MiniCard icon={Users} label="Контрагентов" value={counterparties.data} isLoading={counterparties.isLoading} />
<MiniCard icon={Warehouse} label="Складов" value={stores.data} isLoading={stores.isLoading} />
<MiniCard icon={Store} label="Точек продаж" value={retailPoints.data} isLoading={retailPoints.isLoading} />
<MiniCard icon={Package} label={t('dashboard.productsCount')} value={products.data} isLoading={products.isLoading} />
<MiniCard icon={Users} label={t('dashboard.counterpartiesCount')} value={counterparties.data} isLoading={counterparties.isLoading} />
<MiniCard icon={Warehouse} label={t('dashboard.storesCount')} value={stores.data} isLoading={stores.isLoading} />
<MiniCard icon={Store} label={t('dashboard.retailPointsCount')} value={retailPoints.data} isLoading={retailPoints.isLoading} />
</div>
</div>
</div>