From 301bf159247ced17b392fa40ccadae2a9fc601ae Mon Sep 17 00:00:00 2001 From: nns Date: Sun, 31 May 2026 20:03:33 +0500 Subject: [PATCH] =?UTF-8?q?feat(i18n):=20react-i18next=20ru/en=20+=20langu?= =?UTF-8?q?age=20switcher=20(P2-6a=20=E2=80=94=20=D0=B1=D0=B0=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=8F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Подключён 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-иконка + . Текущий язык хранится в + * 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 ( + + ) +} diff --git a/src/food-market.web/src/lib/i18n.ts b/src/food-market.web/src/lib/i18n.ts new file mode 100644 index 0000000..373bbd9 --- /dev/null +++ b/src/food-market.web/src/lib/i18n.ts @@ -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 } diff --git a/src/food-market.web/src/locales/en.json b/src/food-market.web/src/locales/en.json new file mode 100644 index 0000000..ee02d45 --- /dev/null +++ b/src/food-market.web/src/locales/en.json @@ -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" + } +} diff --git a/src/food-market.web/src/locales/ru.json b/src/food-market.web/src/locales/ru.json new file mode 100644 index 0000000..e4049f0 --- /dev/null +++ b/src/food-market.web/src/locales/ru.json @@ -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": "Настройки сохранены" + } +} diff --git a/src/food-market.web/src/main.tsx b/src/food-market.web/src/main.tsx index bef5202..2dd3234 100644 --- a/src/food-market.web/src/main.tsx +++ b/src/food-market.web/src/main.tsx @@ -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( diff --git a/src/food-market.web/src/pages/DashboardPage.tsx b/src/food-market.web/src/pages/DashboardPage.tsx index db6caf3..904eb61 100644 --- a/src/food-market.web/src/pages/DashboardPage.tsx +++ b/src/food-market.web/src/pages/DashboardPage.tsx @@ -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 && (
{delta.positive ? : } - {delta.positive ? '+' : ''}{delta.value.toFixed(1)}% к прошлому месяцу + {delta.positive ? '+' : ''}{delta.value.toFixed(1)}{delta.suffix ?? '% к прошлому месяцу'}
)} @@ -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('/api/me')).data, @@ -125,16 +127,14 @@ export function DashboardPage() { return (
+ {isConnected ? : } - {isConnected ? 'live' : 'offline'} + {isConnected ? t('common.live') : t('common.offline')} )} /> @@ -143,28 +143,28 @@ export function DashboardPage() {
= 0 } : undefined} + hint={t('dashboard.receiptsCount', { count: stats.data?.transactionsThisMonth ?? 0 })} + delta={monthDelta !== null ? { value: monthDelta, positive: monthDelta >= 0, suffix: t('dashboard.deltaSuffix') } : undefined} />
@@ -172,18 +172,17 @@ export function DashboardPage() {
-

Выручка за 30 дней

-

Сумма продаж по дням, проведённые чеки

+

{t('dashboard.chartTitle')}

+

{t('dashboard.chartSubtitle')}

{stats.isLoading ? ( - // Shimmer на месте графика: примерно той же высоты, чтобы layout не прыгал. ) : !hasAnySales ? (
-
Чеков пока нет.
-
График появится когда появятся первые продажи.
+
{t('dashboard.noSales')}
+
{t('dashboard.noSalesHint')}
) : ( @@ -193,13 +192,13 @@ export function DashboardPage() { {/* Каталог */}

- Каталог + {t('dashboard.catalogSection')}

- - - - + + + +