mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
Fourth of the four ideas picked from the Maybe/Sure feature audit, and the last one queued up. A 3-step wizard (account -> column mapping -> preview) at /transactions/import, parsed entirely client-side (new lib/csv.ts — delimiter auto-detect, 5 date formats, thousands/decimal-separator-aware amounts) and submitted through a new all-or-nothing POST /transactions/bulk endpoint, mirroring backup restore's validate-everything-before-touching- anything approach. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
870 lines
51 KiB
TypeScript
870 lines
51 KiB
TypeScript
import { useSyncExternalStore } from "react";
|
||
|
||
export type Language = "ru" | "en";
|
||
|
||
const STORAGE_KEY = "aurum:language";
|
||
|
||
const ru = {
|
||
"nav.dashboard": "Дашборд",
|
||
"nav.netWorth": "Капитал",
|
||
"nav.roi": "Доходность",
|
||
"nav.transactions": "Транзакции",
|
||
"nav.accounts": "Счета",
|
||
"nav.categories": "Категории",
|
||
"nav.cashFlow": "Движение денег",
|
||
"nav.reports": "Отчёты",
|
||
"nav.budget": "Бюджет",
|
||
"nav.recurring": "Регулярные",
|
||
"nav.goals": "Цели",
|
||
"nav.advice": "Советы",
|
||
"nav.settings": "Настройки",
|
||
"nav.comingSoon": "скоро",
|
||
|
||
"sidebar.collapse": "Свернуть",
|
||
"sidebar.collapseMenu": "Свернуть меню",
|
||
"sidebar.expandMenu": "Развернуть меню",
|
||
"sidebar.closeMenu": "Закрыть меню",
|
||
|
||
"topbar.openMenu": "Открыть меню",
|
||
|
||
"common.loading": "Загрузка…",
|
||
"common.back": "Назад",
|
||
"common.next": "Далее",
|
||
"common.clear": "Очистить",
|
||
"common.cancel": "Отмена",
|
||
"common.save": "Сохранить",
|
||
"common.saving": "Сохранение…",
|
||
"common.edit": "Изменить",
|
||
"common.delete": "Удалить",
|
||
"common.close": "Закрыть",
|
||
"common.add": "Добавить",
|
||
"common.pageOf": "Стр. {{page}} из {{total}}",
|
||
"common.totalCount": "{{count}} всего",
|
||
"common.perMonth": "/мес",
|
||
"common.allShort": "Всё",
|
||
|
||
"dashboard.errorLoading": "Не удалось загрузить данные дашборда. Проверьте, что backend запущен.",
|
||
"dashboard.statRealIncomeLabel": "Реальный доход",
|
||
"dashboard.statRealIncomeCaption": "зарплаты, возвраты",
|
||
"dashboard.statSpentLabel": "Расходы",
|
||
"dashboard.statSpentCaption": "без учёта переводов",
|
||
"dashboard.statNetLabel": "Баланс",
|
||
"dashboard.statNetCaption": "доход − расход",
|
||
"dashboard.statSavingsRateLabel": "Норма сбережений",
|
||
"dashboard.statSavingsRateCaption": "доля дохода, оставшаяся после расходов",
|
||
"dashboard.spendingByCategoryTitle": "Расходы по категориям",
|
||
"dashboard.noExpensesThisMonth": "За этот месяц пока нет расходов. Добавьте первую транзакцию на вкладке «Транзакции».",
|
||
"dashboard.recentTransactionsTitle": "Последние транзакции",
|
||
"dashboard.allTransactionsLink": "Все транзакции",
|
||
"dashboard.noTransactionsYet": "Транзакций пока нет.",
|
||
|
||
"netWorth.periodSuffix": "за период",
|
||
"netWorth.noChartData": "Пока нет данных для графика.",
|
||
"netWorth.assetsTitlePrefix": "Активы",
|
||
"netWorth.myAssetsTitle": "Мои активы",
|
||
"netWorth.confirmDeleteAsset": "Удалить актив «{{name}}»?",
|
||
"netWorth.capitalByType": "Капитал по типу",
|
||
|
||
"netWorth.assetClass.investments": "Инвестиции",
|
||
"netWorth.assetClass.crypto": "Криптовалюта",
|
||
"netWorth.assetClass.real_estate": "Недвижимость",
|
||
"netWorth.assetClass.vehicles": "Транспорт",
|
||
"netWorth.assetClass.precious_metals": "Драгметаллы",
|
||
"netWorth.assetClass.other": "Другое",
|
||
"netWorth.assetClass.cash": "Наличные",
|
||
|
||
"netWorth.capitalRole.income": "Доходный",
|
||
"netWorth.capitalRole.neutral": "Нейтральный",
|
||
"netWorth.capitalRole.drain": "Убыточный",
|
||
|
||
"netWorth.capitalRoleHint.income": "приносит деньги",
|
||
"netWorth.capitalRoleHint.neutral": "просто есть, не тянет и не даёт",
|
||
"netWorth.capitalRoleHint.drain": "тянет деньги каждый месяц",
|
||
|
||
"netWorth.capitalRoleFormHint.income": "приносит деньги — сдаётся в аренду и т.п.",
|
||
"netWorth.capitalRoleFormHint.neutral": "просто есть/используется — техника, мебель",
|
||
"netWorth.capitalRoleFormHint.drain": "тянет деньги каждый месяц — машина и т.п.",
|
||
|
||
"netWorth.riskAllocationTitle": "Капитал по уровню риска",
|
||
"netWorth.riskAllocationOfCapital": "от капитала",
|
||
"netWorth.riskLevel.low": "Низкий",
|
||
"netWorth.riskLevel.medium": "Средний",
|
||
"netWorth.riskLevel.high": "Высокий",
|
||
"netWorth.riskLevelFormHint.low": "риск потери минимален — депозиты, наличные, гособлигации",
|
||
"netWorth.riskLevelFormHint.medium": "умеренные колебания стоимости — недвижимость, диверсифицированные инвестиции",
|
||
"netWorth.riskLevelFormHint.high": "может значительно потерять в цене — крипта, отдельные акции, стартапы",
|
||
|
||
"netWorth.assetsTable.empty": "Активов пока нет. Добавьте инвестиции, крипту, недвижимость или другое имущество.",
|
||
"netWorth.assetsTable.asOf": "на {{date}}",
|
||
"netWorth.assetsTable.annualRoi": "{{percent}}% годовых",
|
||
|
||
"roi.calculator.title": "Калькулятор ROI",
|
||
"roi.calculator.investmentLabel": "Сумма инвестиции",
|
||
"roi.calculator.monthlyIncomeLabel": "Доход в месяц",
|
||
"roi.calculator.annualRoiLabel": "Доходность в год",
|
||
"roi.calculator.payback": "Окупится за {{years}} лет",
|
||
"roi.calculator.noPayback": "Не окупается — доход не покрывает вложение",
|
||
"roi.calculator.hint": "Впишите стоимость и ожидаемый доход в месяц, чтобы увидеть годовую доходность, срок окупаемости и прогноз на годы вперёд.",
|
||
"roi.calculator.projectionTitle": "Прогноз со сложным процентом",
|
||
"roi.calculator.projectionHint": "Если доходность {{percent}}% в год сохранится и весь доход будет реинвестироваться обратно в ту же инвестицию — расчёт только на основе введённых вами цифр, без данных из ваших активов.",
|
||
"roi.calculator.chartCompoundLabel": "Со сложным процентом",
|
||
"roi.calculator.chartSimpleLabel": "Без реинвестирования",
|
||
"roi.calculator.tableTitle": "Сравнение по годам",
|
||
"roi.calculator.colCompound": "Со сложным процентом",
|
||
"roi.calculator.colSimple": "Без реинвестирования",
|
||
"roi.calculator.colDifference": "Разница",
|
||
"roi.calculator.yearOne": "Через 1 год",
|
||
"roi.calculator.yearN": "Через {{years}} лет",
|
||
|
||
"netWorth.form.editTitle": "Изменить актив",
|
||
"netWorth.form.newTitle": "Новый актив",
|
||
"netWorth.form.nameLabel": "Название",
|
||
"netWorth.form.namePlaceholder": "Брокерский счёт, BTC, квартира…",
|
||
"netWorth.form.classLabel": "Категория",
|
||
"netWorth.form.currentValueLabel": "Текущая стоимость",
|
||
"netWorth.form.valueLabel": "Стоимость",
|
||
"netWorth.form.dateLabel": "Дата оценки",
|
||
"netWorth.form.roleLabel": "Тип капитала",
|
||
"netWorth.form.riskLevelLabel": "Уровень риска",
|
||
"netWorth.form.cashFlowLabel": "Денежный поток в месяц (необязательно)",
|
||
"netWorth.form.cashFlowPlaceholder": "800 для дохода, -150 для расхода",
|
||
"netWorth.form.cashFlowHint": "Ориентировочно — реальная сумма из месяца в месяц меняется, это не строгий учёт.",
|
||
"netWorth.form.notesLabel": "Заметки (необязательно)",
|
||
"netWorth.form.saveError": "Не удалось сохранить актив. Проверьте данные и попробуйте снова.",
|
||
|
||
"transactions.addButton": "Добавить",
|
||
"transactions.importButton": "Импорт CSV",
|
||
"transactions.import.title": "Импорт транзакций из CSV",
|
||
"transactions.import.accountLabel": "Счёт для импорта",
|
||
"transactions.import.accountHint": "Все транзакции из файла будут добавлены на этот счёт.",
|
||
"transactions.import.uploadTitle": "Файл",
|
||
"transactions.import.uploadHint": "Выгрузка транзакций из банка или другого приложения в формате CSV (с заголовками колонок в первой строке).",
|
||
"transactions.import.chooseFile": "Выбрать файл",
|
||
"transactions.import.pickAccountFirst": "Сначала выберите счёт.",
|
||
"transactions.import.errorEmptyFile": "В файле нет ни одной строки с данными (кроме заголовка).",
|
||
"transactions.import.mapTitle": "Сопоставьте колонки",
|
||
"transactions.import.dateColumnLabel": "Колонка с датой",
|
||
"transactions.import.dateFormatLabel": "Формат даты",
|
||
"transactions.import.selectColumn": "Выберите колонку…",
|
||
"transactions.import.amountColumnLabel": "Колонка с суммой",
|
||
"transactions.import.amountHint": "Отрицательные суммы — расход, положительные — доход.",
|
||
"transactions.import.descriptionColumnLabel": "Колонка с описанием",
|
||
"transactions.import.notMapped": "— не сопоставлено —",
|
||
"transactions.import.categoryHint": "Если текст в колонке точно совпадёт с названием существующей категории — она подставится автоматически.",
|
||
"transactions.import.startOver": "Начать заново",
|
||
"transactions.import.previewButton": "Показать превью",
|
||
"transactions.import.previewTitle": "Превью импорта",
|
||
"transactions.import.summary": "Готово к импорту: {{valid}}. Пропущено из-за ошибок: {{skipped}}.",
|
||
"transactions.import.andMore": "…и ещё {{count}}",
|
||
"transactions.import.skippedRow": "Строка {{row}}: {{reason}}",
|
||
"transactions.import.importButton": "Импортировать {{count}}",
|
||
"transactions.import.importSuccess": "Готово — добавлено транзакций: {{count}}.",
|
||
"transactions.import.goToTransactions": "К транзакциям",
|
||
"transactions.import.importError": "Не удалось импортировать. Проверьте данные и попробуйте снова.",
|
||
"transactions.import.errorBadDate": "не удалось распознать дату «{{value}}»",
|
||
"transactions.import.errorBadAmount": "не удалось распознать сумму «{{value}}»",
|
||
"transactions.import.errorNoDescription": "нет описания",
|
||
"transactions.allTypes": "Все типы",
|
||
"transactions.expense": "Расходы",
|
||
"transactions.income": "Доходы",
|
||
"transactions.transfer": "Переводы",
|
||
"transactions.allCategories": "Все категории",
|
||
"transactions.allTags": "Все теги",
|
||
"transactions.failedToLoad": "Не удалось загрузить транзакции.",
|
||
"transactions.noneFound": "Транзакций не найдено.",
|
||
"transactions.searchPlaceholder": "Поиск по описанию, продавцу или заметке…",
|
||
"transactions.searchAcrossAllTime": "Поиск ведётся по всем периодам, а не только за выбранный месяц.",
|
||
"transactions.searchNoneFound": "Ничего не найдено. Попробуйте другой запрос.",
|
||
"transactions.jumpToMonth": "Перейти к этому месяцу",
|
||
"transactions.confirmDelete": "Удалить транзакцию «{{description}}»?",
|
||
"transactions.transferSuffix": "→ перевод",
|
||
"transactions.sortDateDesc": "Сначала новые",
|
||
"transactions.sortAmountDesc": "Сумма: сначала больше",
|
||
"transactions.sortAmountAsc": "Сумма: сначала меньше",
|
||
|
||
"transactions.form.editTitle": "Изменить транзакцию",
|
||
"transactions.form.newTitle": "Новая транзакция",
|
||
"transactions.form.typeLabel": "Тип",
|
||
"transactions.form.typeExpense": "Расход",
|
||
"transactions.form.typeIncome": "Доход",
|
||
"transactions.form.typeTransfer": "Перевод",
|
||
"transactions.form.amountLabel": "Сумма",
|
||
"transactions.form.dateLabel": "Дата",
|
||
"transactions.form.descriptionLabel": "Описание",
|
||
"transactions.form.descriptionPlaceholder": "Продукты, зарплата, аренда…",
|
||
"transactions.form.accountLabel": "Счёт",
|
||
"transactions.form.selectAccount": "Выберите счёт",
|
||
"transactions.form.transferAccountLabel": "Счёт назначения",
|
||
"transactions.form.categoryLabel": "Категория",
|
||
"transactions.form.noCategory": "Без категории",
|
||
"transactions.form.merchantLabel": "Продавец (необязательно)",
|
||
"transactions.form.notesLabel": "Заметка (необязательно)",
|
||
"transactions.form.tagsLabel": "Теги (необязательно)",
|
||
"transactions.form.tagsPlaceholder": "Начните вводить, чтобы найти или создать тег…",
|
||
"transactions.form.removeTag": "Убрать тег «{{name}}»",
|
||
"transactions.form.createTag": "Создать тег «{{name}}»",
|
||
"transactions.viewNote": "Посмотреть заметку",
|
||
"transactions.noteDialogTitle": "Заметка",
|
||
"transactions.form.errorSelectAccount": "Выберите счёт",
|
||
"transactions.form.errorSelectDestination": "Выберите счёт назначения для перевода",
|
||
"transactions.form.errorSameAccount": "Счёт назначения должен отличаться от исходного счёта",
|
||
"transactions.form.saveError": "Не удалось сохранить транзакцию. Проверьте данные и попробуйте снова.",
|
||
|
||
"reports.categoryLabel": "Категория",
|
||
"reports.expenseGroup": "Расходы",
|
||
"reports.incomeGroup": "Доходы",
|
||
"reports.rangeAll": "Всё время",
|
||
"reports.rangeThisYear": "Этот год",
|
||
"reports.range5y": "5 лет",
|
||
"reports.rangeCustom": "Диапазон лет",
|
||
"reports.transactionsTitle": "Транзакции",
|
||
"reports.defaultChartTitle": "Расходы по категории",
|
||
"reports.averagePerMonth": "{{amount}}/мес в среднем",
|
||
"reports.noDataForPeriod": "Нет данных за выбранный период.",
|
||
"reports.categoryRankingTitle": "Топ категорий за период",
|
||
"reports.noRankingData": "Нет расходов за выбранный период.",
|
||
|
||
"settings.language": "Язык",
|
||
"settings.languageRussian": "Русский",
|
||
"settings.languageEnglish": "English",
|
||
"settings.theme": "Тема оформления",
|
||
"settings.themeLight": "Светлая",
|
||
"settings.themeDark": "Тёмная",
|
||
"settings.themeSystem": "Системная",
|
||
"settings.currency": "Основная валюта",
|
||
"settings.currencyHint":
|
||
"Меняет только отображение сумм — уже введённые значения не пересчитываются и не конвертируются.",
|
||
"settings.alertsTitle": "Проактивные предупреждения",
|
||
"settings.alertsHint": "Через сколько месяцев подряд показывать предупреждение на Dashboard и «Капитал».",
|
||
"settings.negativeCashFlowThresholdLabel": "Расходы превышают доходы, мес. подряд",
|
||
"settings.netWorthDeclineThresholdLabel": "Капитал снижается, мес. подряд",
|
||
"settings.riskyAllocationThresholdLabel": "Допустимая доля рискового капитала, %",
|
||
"settings.idleCashThresholdAmountLabel": "Простаивающий кэш — сумма от",
|
||
"settings.idleCashThresholdDaysLabel": "Простаивающий кэш — без движения, дней",
|
||
|
||
"backup.title": "Резервное копирование",
|
||
"backup.description":
|
||
"Экспортируйте все свои данные (счета, категории, транзакции, активы) в один JSON-файл и храните его в надёжном месте. Позже этот файл можно загрузить обратно, чтобы полностью восстановить данные — например, после переустановки или переезда на другой сервер.",
|
||
"backup.exportButton": "Экспортировать бэкап",
|
||
"backup.exporting": "Экспорт…",
|
||
"backup.importButton": "Восстановить из файла",
|
||
"backup.importing": "Восстановление…",
|
||
"backup.exportSuccess": "Бэкап скачан.",
|
||
"backup.exportError": "Не удалось скачать бэкап.",
|
||
"backup.importSuccess": "Данные восстановлены из бэкапа.",
|
||
"backup.importError": "Не удалось восстановить бэкап.",
|
||
"backup.invalidFile": "Файл повреждён или не является JSON-бэкапом Aurum.",
|
||
"backup.confirmImport":
|
||
"Восстановить данные из «{{filename}}»?\n\nЭто ПОЛНОСТЬЮ заменит все текущие счета, категории, транзакции и активы данными из файла. Отменить это действие нельзя — если хотите сохранить текущие данные, сначала сделайте экспорт.",
|
||
"backup.footerWarning":
|
||
"Восстановление полностью заменяет текущие данные содержимым файла — это нельзя отменить.",
|
||
|
||
"insights.negativeCashFlow": "Расходы превышают доходы {{months}} мес. подряд (по последним завершённым месяцам)",
|
||
"insights.netWorthDecline": "Капитал снижается {{months}} мес. подряд",
|
||
"insights.budgetExceededOne": "Превышен бюджет в 1 категории в этом месяце",
|
||
"insights.budgetExceeded": "Превышен бюджет в {{count}} категориях в этом месяце",
|
||
"insights.riskyAllocationExceeded": "Рискованная часть капитала — {{percent}}%, превышает установленный лимит {{threshold}}%",
|
||
"insights.idleCashOne": "На 1 счёте лежит крупная сумма без движения {{days}}+ дней — возможно, эти деньги стоит куда-то вложить",
|
||
"insights.idleCash": "На {{count}} счетах лежит крупная сумма без движения {{days}}+ дней — возможно, эти деньги стоит куда-то вложить",
|
||
|
||
"budget.empty": "Бюджетов пока нет. Задайте лимит для категории расходов, чтобы отслеживать её здесь.",
|
||
"budget.remaining": "{{amount}} осталось",
|
||
"budget.overBy": "Превышен на {{amount}}",
|
||
"budget.confirmDelete": "Удалить бюджет для «{{name}}»?",
|
||
"budget.form.newTitle": "Новый бюджет",
|
||
"budget.form.editTitle": "Изменить бюджет",
|
||
"budget.form.categoryLabel": "Категория",
|
||
"budget.form.limitLabel": "Лимит в месяц",
|
||
"budget.form.noCategoriesAvailable": "Все категории расходов уже добавлены.",
|
||
"budget.form.saveError": "Не удалось сохранить бюджет. Проверьте данные и попробуйте снова.",
|
||
|
||
"advice.empty": "Пока нет советов — недостаточно данных, или всё в порядке.",
|
||
"advice.risingCategory":
|
||
"Траты на {{category}} выросли на {{percent}}% по сравнению со средним за последние 3 месяца: {{current}} против {{average}}.",
|
||
"advice.unbudgetedTopCategory":
|
||
"На «{{category}}» уходит больше всего в этом месяце — {{amount}}. Задайте для неё бюджет на вкладке «Бюджет», чтобы отслеживать лимит.",
|
||
"advice.savingsRateTrend": "Норма сбережений: {{rate}}% ({{diff}} п.п. к прошлому месяцу).",
|
||
|
||
"goal.empty": "Целей пока нет. Добавьте цель и начните копить.",
|
||
"goal.remaining": "{{amount}} осталось",
|
||
"goal.remainingWithDate": "{{amount}} осталось · до {{date}}",
|
||
"goal.reached": "Цель достигнута!",
|
||
"goal.contributeLabel": "Добавить взнос",
|
||
"goal.confirmDelete": "Удалить цель «{{name}}»?",
|
||
"goal.form.newTitle": "Новая цель",
|
||
"goal.form.editTitle": "Изменить цель",
|
||
"goal.form.nameLabel": "Название",
|
||
"goal.form.namePlaceholder": "Подушка безопасности, отпуск, новый ноутбук…",
|
||
"goal.form.targetAmountLabel": "Целевая сумма",
|
||
"goal.form.targetDateLabel": "Дата (необязательно)",
|
||
"goal.form.saveError": "Не удалось сохранить цель. Проверьте данные и попробуйте снова.",
|
||
"goal.contribution.title": "Взнос: {{name}}",
|
||
"goal.contribution.amountLabel": "Сумма",
|
||
"goal.contribution.amountPlaceholder": "100 для взноса, -50 для снятия",
|
||
"goal.contribution.dateLabel": "Дата",
|
||
"goal.contribution.hint": "Можно указать отрицательную сумму, если пришлось снять деньги с цели.",
|
||
"goal.contribution.saveError": "Не удалось сохранить взнос. Проверьте данные и попробуйте снова.",
|
||
|
||
"recurring.empty":
|
||
"Регулярных платежей пока нет. Добавьте зарплату, аренду или подписку, чтобы не заводить их вручную каждый раз.",
|
||
"recurring.postLabel": "Провести платёж",
|
||
"recurring.confirmDelete": "Удалить регулярный платёж «{{name}}»?",
|
||
"recurring.overdueDays": "Просрочено на {{days}} дн.",
|
||
"recurring.dueToday": "Пора оплатить сегодня",
|
||
"recurring.dueInDays": "Через {{days}} дн. · {{date}}",
|
||
"recurring.frequency.weekly": "Еженедельно",
|
||
"recurring.frequency.monthly": "Ежемесячно",
|
||
"recurring.frequency.yearly": "Ежегодно",
|
||
"recurring.form.newTitle": "Новый регулярный платёж",
|
||
"recurring.form.editTitle": "Изменить регулярный платёж",
|
||
"recurring.form.frequencyLabel": "Периодичность",
|
||
"recurring.form.anchorDateLabel": "Дата начала",
|
||
"recurring.form.saveError": "Не удалось сохранить регулярный платёж. Проверьте данные и попробуйте снова.",
|
||
|
||
"account.empty": "Счетов пока нет. Добавьте первый — например, текущий счёт или наличные.",
|
||
"account.showArchived": "Показывать архивные",
|
||
"account.archivedBadge": "архив",
|
||
"account.archiveLabel": "Архивировать",
|
||
"account.unarchiveLabel": "Вернуть из архива",
|
||
"account.confirmDelete":
|
||
"Удалить счёт «{{name}}»?\n\nЭто безвозвратно удалит и ВСЕ транзакции по этому счёту. Если хотите сохранить историю, заархивируйте счёт вместо удаления.",
|
||
"account.type.checking": "Текущий счёт",
|
||
"account.type.savings": "Накопительный счёт",
|
||
"account.type.credit_card": "Кредитная карта",
|
||
"account.type.cash": "Наличные",
|
||
"account.type.investment": "Инвестиционный счёт",
|
||
"account.type.other": "Другое",
|
||
"account.form.newTitle": "Новый счёт",
|
||
"account.form.editTitle": "Изменить счёт",
|
||
"account.form.nameLabel": "Название",
|
||
"account.form.namePlaceholder": "Основной счёт, накопления, наличные…",
|
||
"account.form.typeLabel": "Тип",
|
||
"account.form.saveError": "Не удалось сохранить счёт. Проверьте данные и попробуйте снова.",
|
||
|
||
"category.expenseSectionTitle": "Категории расходов",
|
||
"category.incomeSectionTitle": "Категории доходов",
|
||
"category.empty": "Категорий пока нет.",
|
||
"category.defaultBadge": "стандартная",
|
||
"category.defaultCannotDelete": "Стандартные категории нельзя удалить",
|
||
"category.confirmDelete": "Удалить категорию «{{name}}»?\n\nЕё транзакции останутся без категории.",
|
||
"category.kind.expense": "Расход",
|
||
"category.kind.income": "Доход",
|
||
"category.form.newTitle": "Новая категория",
|
||
"category.form.editTitle": "Изменить категорию",
|
||
"category.form.nameLabel": "Название",
|
||
"category.form.namePlaceholder": "Продукты, транспорт, подписки…",
|
||
"category.form.kindLabel": "Тип",
|
||
"category.form.parentLabel": "Родительская категория",
|
||
"category.form.noParent": "— без родителя (верхний уровень) —",
|
||
"category.form.hasChildrenHint": "У категории уже есть подкатегории — она не может сама стать подкатегорией.",
|
||
"category.form.iconLabel": "Иконка",
|
||
"category.form.colorLabel": "Цвет",
|
||
"category.form.customColorLabel": "Свой цвет",
|
||
"category.form.saveError": "Не удалось сохранить категорию. Проверьте данные и попробуйте снова.",
|
||
|
||
"cashFlow.income": "Доход",
|
||
"cashFlow.expense": "Расход",
|
||
"cashFlow.net": "Баланс",
|
||
"cashFlow.noData": "Нет данных за выбранный период.",
|
||
|
||
// Default seeded categories (backend/app/db/seed.py) — translateCategoryName()
|
||
// matches on the exact seeded English name and falls through unchanged
|
||
// for anything else (a renamed default, or a custom category added via
|
||
// the category-management UI).
|
||
"category.housingUtilities": "Жильё и коммуналка",
|
||
"category.groceries": "Продукты",
|
||
"category.diningOut": "Кафе и рестораны",
|
||
"category.transportation": "Транспорт",
|
||
"category.healthFitness": "Здоровье и фитнес",
|
||
"category.shopping": "Покупки",
|
||
"category.entertainment": "Развлечения",
|
||
"category.subscriptions": "Подписки",
|
||
"category.salary": "Зарплата",
|
||
"category.freelance": "Фриланс",
|
||
"category.investments": "Инвестиции",
|
||
"category.gifts": "Подарки",
|
||
"category.businessIncome": "Доход от бизнеса",
|
||
"category.rentalIncome": "Сдача недвижимости",
|
||
"category.benefits": "Пособия и соцвыплаты",
|
||
"category.itemSales": "Продажа личных вещей",
|
||
"category.otherIncome": "Прочий доход",
|
||
|
||
"months.jan": "Янв",
|
||
"months.feb": "Фев",
|
||
"months.mar": "Мар",
|
||
"months.apr": "Апр",
|
||
"months.may": "Май",
|
||
"months.jun": "Июн",
|
||
"months.jul": "Июл",
|
||
"months.aug": "Авг",
|
||
"months.sep": "Сен",
|
||
"months.oct": "Окт",
|
||
"months.nov": "Ноя",
|
||
"months.dec": "Дек",
|
||
};
|
||
|
||
const en: Record<keyof typeof ru, string> = {
|
||
"nav.dashboard": "Dashboard",
|
||
"nav.netWorth": "Net Worth",
|
||
"nav.roi": "Returns",
|
||
"nav.transactions": "Transactions",
|
||
"nav.accounts": "Accounts",
|
||
"nav.categories": "Categories",
|
||
"nav.cashFlow": "Cash Flow",
|
||
"nav.reports": "Reports",
|
||
"nav.budget": "Budget",
|
||
"nav.recurring": "Recurring",
|
||
"nav.goals": "Goals",
|
||
"nav.advice": "Advice",
|
||
"nav.settings": "Settings",
|
||
"nav.comingSoon": "soon",
|
||
|
||
"sidebar.collapse": "Collapse",
|
||
"sidebar.collapseMenu": "Collapse menu",
|
||
"sidebar.expandMenu": "Expand menu",
|
||
"sidebar.closeMenu": "Close menu",
|
||
|
||
"topbar.openMenu": "Open menu",
|
||
|
||
"common.loading": "Loading…",
|
||
"common.back": "Back",
|
||
"common.next": "Next",
|
||
"common.clear": "Clear",
|
||
"common.cancel": "Cancel",
|
||
"common.save": "Save",
|
||
"common.saving": "Saving…",
|
||
"common.edit": "Edit",
|
||
"common.delete": "Delete",
|
||
"common.close": "Close",
|
||
"common.add": "Add",
|
||
"common.pageOf": "Page {{page}} of {{total}}",
|
||
"common.totalCount": "{{count}} total",
|
||
"common.perMonth": "/mo",
|
||
"common.allShort": "All",
|
||
|
||
"dashboard.errorLoading": "Failed to load dashboard data. Check that the backend is running.",
|
||
"dashboard.statRealIncomeLabel": "Real income",
|
||
"dashboard.statRealIncomeCaption": "paychecks, refunds",
|
||
"dashboard.statSpentLabel": "Spent",
|
||
"dashboard.statSpentCaption": "excludes transfers",
|
||
"dashboard.statNetLabel": "Net",
|
||
"dashboard.statNetCaption": "income − spend",
|
||
"dashboard.statSavingsRateLabel": "Savings rate",
|
||
"dashboard.statSavingsRateCaption": "share of income left after spending",
|
||
"dashboard.spendingByCategoryTitle": "Spending by category",
|
||
"dashboard.noExpensesThisMonth": "No expenses yet this month. Add your first transaction on the Transactions tab.",
|
||
"dashboard.recentTransactionsTitle": "Recent transactions",
|
||
"dashboard.allTransactionsLink": "All transactions",
|
||
"dashboard.noTransactionsYet": "No transactions yet.",
|
||
|
||
"netWorth.periodSuffix": "over period",
|
||
"netWorth.noChartData": "No data for the chart yet.",
|
||
"netWorth.assetsTitlePrefix": "Assets",
|
||
"netWorth.myAssetsTitle": "My assets",
|
||
"netWorth.confirmDeleteAsset": "Delete asset “{{name}}”?",
|
||
"netWorth.capitalByType": "Capital by type",
|
||
|
||
"netWorth.assetClass.investments": "Investments",
|
||
"netWorth.assetClass.crypto": "Crypto",
|
||
"netWorth.assetClass.real_estate": "Real estate",
|
||
"netWorth.assetClass.vehicles": "Vehicles",
|
||
"netWorth.assetClass.precious_metals": "Precious metals",
|
||
"netWorth.assetClass.other": "Other",
|
||
"netWorth.assetClass.cash": "Cash",
|
||
|
||
"netWorth.capitalRole.income": "Income",
|
||
"netWorth.capitalRole.neutral": "Neutral",
|
||
"netWorth.capitalRole.drain": "Drain",
|
||
|
||
"netWorth.capitalRoleHint.income": "brings in money",
|
||
"netWorth.capitalRoleHint.neutral": "just sits there — no gain, no drain",
|
||
"netWorth.capitalRoleHint.drain": "costs money every month",
|
||
|
||
"netWorth.capitalRoleFormHint.income": "brings in money — rented out, etc.",
|
||
"netWorth.capitalRoleFormHint.neutral": "just there / in use — electronics, furniture",
|
||
"netWorth.capitalRoleFormHint.drain": "costs money every month — a car, etc.",
|
||
|
||
"netWorth.riskAllocationTitle": "Capital by risk level",
|
||
"netWorth.riskAllocationOfCapital": "of capital",
|
||
"netWorth.riskLevel.low": "Low",
|
||
"netWorth.riskLevel.medium": "Medium",
|
||
"netWorth.riskLevel.high": "High",
|
||
"netWorth.riskLevelFormHint.low": "minimal risk of loss — deposits, cash, government bonds",
|
||
"netWorth.riskLevelFormHint.medium": "moderate value swings — real estate, diversified investments",
|
||
"netWorth.riskLevelFormHint.high": "can lose significant value — crypto, individual stocks, startups",
|
||
|
||
"netWorth.assetsTable.empty": "No assets yet. Add investments, crypto, real estate, or other property.",
|
||
"netWorth.assetsTable.asOf": "as of {{date}}",
|
||
"netWorth.assetsTable.annualRoi": "{{percent}}%/year",
|
||
|
||
"roi.calculator.title": "ROI Calculator",
|
||
"roi.calculator.investmentLabel": "Investment amount",
|
||
"roi.calculator.monthlyIncomeLabel": "Monthly income",
|
||
"roi.calculator.annualRoiLabel": "Annual return",
|
||
"roi.calculator.payback": "Pays back in {{years}} years",
|
||
"roi.calculator.noPayback": "Doesn't pay back — income doesn't cover the investment",
|
||
"roi.calculator.hint": "Enter the price and expected monthly income to see the annual return, payback period, and a multi-year projection.",
|
||
"roi.calculator.projectionTitle": "Compound interest projection",
|
||
"roi.calculator.projectionHint": "If the {{percent}}% annual return holds and all income is reinvested back into the same investment — based only on the numbers you entered, not your tracked assets.",
|
||
"roi.calculator.chartCompoundLabel": "With compounding",
|
||
"roi.calculator.chartSimpleLabel": "Without reinvesting",
|
||
"roi.calculator.tableTitle": "Year-by-year comparison",
|
||
"roi.calculator.colCompound": "With compounding",
|
||
"roi.calculator.colSimple": "Without reinvesting",
|
||
"roi.calculator.colDifference": "Difference",
|
||
"roi.calculator.yearOne": "In 1 year",
|
||
"roi.calculator.yearN": "In {{years}} years",
|
||
|
||
"netWorth.form.editTitle": "Edit asset",
|
||
"netWorth.form.newTitle": "New asset",
|
||
"netWorth.form.nameLabel": "Name",
|
||
"netWorth.form.namePlaceholder": "Brokerage account, BTC, apartment…",
|
||
"netWorth.form.classLabel": "Category",
|
||
"netWorth.form.currentValueLabel": "Current value",
|
||
"netWorth.form.valueLabel": "Value",
|
||
"netWorth.form.dateLabel": "Valuation date",
|
||
"netWorth.form.roleLabel": "Capital type",
|
||
"netWorth.form.riskLevelLabel": "Risk level",
|
||
"netWorth.form.cashFlowLabel": "Monthly cash flow (optional)",
|
||
"netWorth.form.cashFlowPlaceholder": "800 for income, -150 for expense",
|
||
"netWorth.form.cashFlowHint": "Approximate — the real amount varies month to month, this isn't strict accounting.",
|
||
"netWorth.form.notesLabel": "Notes (optional)",
|
||
"netWorth.form.saveError": "Failed to save the asset. Check the data and try again.",
|
||
|
||
"transactions.addButton": "Add",
|
||
"transactions.importButton": "Import CSV",
|
||
"transactions.import.title": "Import transactions from CSV",
|
||
"transactions.import.accountLabel": "Account to import into",
|
||
"transactions.import.accountHint": "Every transaction from the file will be added to this account.",
|
||
"transactions.import.uploadTitle": "File",
|
||
"transactions.import.uploadHint": "A transaction export from your bank or another app, in CSV format (with column headers in the first row).",
|
||
"transactions.import.chooseFile": "Choose file",
|
||
"transactions.import.pickAccountFirst": "Pick an account first.",
|
||
"transactions.import.errorEmptyFile": "The file has no data rows (besides the header).",
|
||
"transactions.import.mapTitle": "Map the columns",
|
||
"transactions.import.dateColumnLabel": "Date column",
|
||
"transactions.import.dateFormatLabel": "Date format",
|
||
"transactions.import.selectColumn": "Select a column…",
|
||
"transactions.import.amountColumnLabel": "Amount column",
|
||
"transactions.import.amountHint": "Negative amounts are expenses, positive are income.",
|
||
"transactions.import.descriptionColumnLabel": "Description column",
|
||
"transactions.import.notMapped": "— not mapped —",
|
||
"transactions.import.categoryHint": "If the column's text exactly matches an existing category name, it's filled in automatically.",
|
||
"transactions.import.startOver": "Start over",
|
||
"transactions.import.previewButton": "Show preview",
|
||
"transactions.import.previewTitle": "Import preview",
|
||
"transactions.import.summary": "Ready to import: {{valid}}. Skipped due to errors: {{skipped}}.",
|
||
"transactions.import.andMore": "…and {{count}} more",
|
||
"transactions.import.skippedRow": "Row {{row}}: {{reason}}",
|
||
"transactions.import.importButton": "Import {{count}}",
|
||
"transactions.import.importSuccess": "Done — {{count}} transactions added.",
|
||
"transactions.import.goToTransactions": "Go to transactions",
|
||
"transactions.import.importError": "Import failed. Check the data and try again.",
|
||
"transactions.import.errorBadDate": "couldn't parse the date \"{{value}}\"",
|
||
"transactions.import.errorBadAmount": "couldn't parse the amount \"{{value}}\"",
|
||
"transactions.import.errorNoDescription": "no description",
|
||
"transactions.allTypes": "All types",
|
||
"transactions.expense": "Expenses",
|
||
"transactions.income": "Income",
|
||
"transactions.transfer": "Transfers",
|
||
"transactions.allCategories": "All categories",
|
||
"transactions.allTags": "All tags",
|
||
"transactions.failedToLoad": "Failed to load transactions.",
|
||
"transactions.noneFound": "No transactions found.",
|
||
"transactions.searchPlaceholder": "Search by description, merchant, or note…",
|
||
"transactions.searchAcrossAllTime": "Searching across every period, not just the selected month.",
|
||
"transactions.searchNoneFound": "Nothing found. Try a different search.",
|
||
"transactions.jumpToMonth": "Jump to this month",
|
||
"transactions.confirmDelete": "Delete transaction “{{description}}”?",
|
||
"transactions.transferSuffix": "→ transfer",
|
||
"transactions.sortDateDesc": "Newest first",
|
||
"transactions.sortAmountDesc": "Amount: high to low",
|
||
"transactions.sortAmountAsc": "Amount: low to high",
|
||
|
||
"transactions.form.editTitle": "Edit transaction",
|
||
"transactions.form.newTitle": "New transaction",
|
||
"transactions.form.typeLabel": "Type",
|
||
"transactions.form.typeExpense": "Expense",
|
||
"transactions.form.typeIncome": "Income",
|
||
"transactions.form.typeTransfer": "Transfer",
|
||
"transactions.form.amountLabel": "Amount",
|
||
"transactions.form.dateLabel": "Date",
|
||
"transactions.form.descriptionLabel": "Description",
|
||
"transactions.form.descriptionPlaceholder": "Groceries, salary, rent…",
|
||
"transactions.form.accountLabel": "Account",
|
||
"transactions.form.selectAccount": "Select account",
|
||
"transactions.form.transferAccountLabel": "Destination account",
|
||
"transactions.form.categoryLabel": "Category",
|
||
"transactions.form.noCategory": "No category",
|
||
"transactions.form.merchantLabel": "Merchant (optional)",
|
||
"transactions.form.notesLabel": "Note (optional)",
|
||
"transactions.form.tagsLabel": "Tags (optional)",
|
||
"transactions.form.tagsPlaceholder": "Type to find or create a tag…",
|
||
"transactions.form.removeTag": "Remove tag \"{{name}}\"",
|
||
"transactions.form.createTag": "Create tag \"{{name}}\"",
|
||
"transactions.viewNote": "View note",
|
||
"transactions.noteDialogTitle": "Note",
|
||
"transactions.form.errorSelectAccount": "Select an account",
|
||
"transactions.form.errorSelectDestination": "Select a destination account for the transfer",
|
||
"transactions.form.errorSameAccount": "The destination account must be different from the source account",
|
||
"transactions.form.saveError": "Failed to save the transaction. Check the data and try again.",
|
||
|
||
"reports.categoryLabel": "Category",
|
||
"reports.expenseGroup": "Expenses",
|
||
"reports.incomeGroup": "Income",
|
||
"reports.rangeAll": "All time",
|
||
"reports.rangeThisYear": "This year",
|
||
"reports.range5y": "5 years",
|
||
"reports.rangeCustom": "Year range",
|
||
"reports.transactionsTitle": "Transactions",
|
||
"reports.defaultChartTitle": "Spending by category",
|
||
"reports.averagePerMonth": "{{amount}}/mo average",
|
||
"reports.noDataForPeriod": "No data for the selected period.",
|
||
"reports.categoryRankingTitle": "Top categories for period",
|
||
"reports.noRankingData": "No expenses for the selected period.",
|
||
|
||
"settings.language": "Language",
|
||
"settings.languageRussian": "Русский",
|
||
"settings.languageEnglish": "English",
|
||
"settings.theme": "Theme",
|
||
"settings.themeLight": "Light",
|
||
"settings.themeDark": "Dark",
|
||
"settings.themeSystem": "System",
|
||
"settings.currency": "Primary currency",
|
||
"settings.currencyHint":
|
||
"Only changes how amounts are displayed — values you've already entered aren't recalculated or converted.",
|
||
"settings.alertsTitle": "Proactive alerts",
|
||
"settings.alertsHint": "How many consecutive months before showing a warning on Dashboard and Net Worth.",
|
||
"settings.negativeCashFlowThresholdLabel": "Expenses exceed income, consecutive months",
|
||
"settings.netWorthDeclineThresholdLabel": "Net worth declining, consecutive months",
|
||
"settings.riskyAllocationThresholdLabel": "Max % of capital allowed at risk",
|
||
"settings.idleCashThresholdAmountLabel": "Idle cash — balance at or above",
|
||
"settings.idleCashThresholdDaysLabel": "Idle cash — no activity for, days",
|
||
|
||
"backup.title": "Backup & Restore",
|
||
"backup.description":
|
||
"Export all your data (accounts, categories, transactions, assets) into a single JSON file and keep it somewhere safe. Later, you can load that file back in to fully restore your data — for example, after a reinstall or moving to a different server.",
|
||
"backup.exportButton": "Export backup",
|
||
"backup.exporting": "Exporting…",
|
||
"backup.importButton": "Restore from file",
|
||
"backup.importing": "Restoring…",
|
||
"backup.exportSuccess": "Backup downloaded.",
|
||
"backup.exportError": "Failed to download the backup.",
|
||
"backup.importSuccess": "Data restored from backup.",
|
||
"backup.importError": "Failed to restore the backup.",
|
||
"backup.invalidFile": "The file is corrupted or is not an Aurum JSON backup.",
|
||
"backup.confirmImport":
|
||
"Restore data from “{{filename}}”?\n\nThis will COMPLETELY replace all current accounts, categories, transactions, and assets with the data from the file. This action cannot be undone — if you want to keep your current data, export it first.",
|
||
"backup.footerWarning":
|
||
"Restoring completely replaces your current data with the file's contents — this cannot be undone.",
|
||
|
||
"insights.negativeCashFlow": "Expenses have exceeded income for {{months}} months in a row (based on the last completed months)",
|
||
"insights.netWorthDecline": "Net worth has been declining for {{months}} months in a row",
|
||
"insights.budgetExceededOne": "Budget exceeded in 1 category this month",
|
||
"insights.budgetExceeded": "Budget exceeded in {{count}} categories this month",
|
||
"insights.riskyAllocationExceeded": "Risky share of capital is {{percent}}%, above your {{threshold}}% limit",
|
||
"insights.idleCashOne": "1 account is holding a large balance with no activity for {{days}}+ days — that money might be worth putting to work",
|
||
"insights.idleCash": "{{count}} accounts are holding a large balance with no activity for {{days}}+ days — that money might be worth putting to work",
|
||
|
||
"budget.empty": "No budgets yet. Set a limit for an expense category to start tracking it here.",
|
||
"budget.remaining": "{{amount}} left",
|
||
"budget.overBy": "Over by {{amount}}",
|
||
"budget.confirmDelete": "Delete the budget for “{{name}}”?",
|
||
"budget.form.newTitle": "New budget",
|
||
"budget.form.editTitle": "Edit budget",
|
||
"budget.form.categoryLabel": "Category",
|
||
"budget.form.limitLabel": "Monthly limit",
|
||
"budget.form.noCategoriesAvailable": "Every expense category already has a budget.",
|
||
"budget.form.saveError": "Failed to save the budget. Check the data and try again.",
|
||
|
||
"advice.empty": "No advice right now — not enough data, or everything looks fine.",
|
||
"advice.risingCategory":
|
||
"Spending on {{category}} is up {{percent}}% vs. the average of the last 3 months: {{current}} vs {{average}}.",
|
||
"advice.unbudgetedTopCategory":
|
||
"\"{{category}}\" is your biggest expense this month — {{amount}}. Set a budget for it on the Budget tab to track a limit.",
|
||
"advice.savingsRateTrend": "Savings rate: {{rate}}% ({{diff}} pts vs last month).",
|
||
|
||
"goal.empty": "No goals yet. Add one and start saving.",
|
||
"goal.remaining": "{{amount}} left",
|
||
"goal.remainingWithDate": "{{amount}} left · by {{date}}",
|
||
"goal.reached": "Goal reached!",
|
||
"goal.contributeLabel": "Add contribution",
|
||
"goal.confirmDelete": "Delete the goal “{{name}}”?",
|
||
"goal.form.newTitle": "New goal",
|
||
"goal.form.editTitle": "Edit goal",
|
||
"goal.form.nameLabel": "Name",
|
||
"goal.form.namePlaceholder": "Emergency fund, vacation, new laptop…",
|
||
"goal.form.targetAmountLabel": "Target amount",
|
||
"goal.form.targetDateLabel": "Target date (optional)",
|
||
"goal.form.saveError": "Failed to save the goal. Check the data and try again.",
|
||
"goal.contribution.title": "Contribution: {{name}}",
|
||
"goal.contribution.amountLabel": "Amount",
|
||
"goal.contribution.amountPlaceholder": "100 to deposit, -50 to withdraw",
|
||
"goal.contribution.dateLabel": "Date",
|
||
"goal.contribution.hint": "You can enter a negative amount if you had to withdraw from the goal.",
|
||
"goal.contribution.saveError": "Failed to save the contribution. Check the data and try again.",
|
||
|
||
"recurring.empty":
|
||
"No recurring transactions yet. Add your salary, rent, or a subscription so you don't have to enter it manually every time.",
|
||
"recurring.postLabel": "Post now",
|
||
"recurring.confirmDelete": "Delete the recurring transaction “{{name}}”?",
|
||
"recurring.overdueDays": "{{days}} days overdue",
|
||
"recurring.dueToday": "Due today",
|
||
"recurring.dueInDays": "In {{days}} days · {{date}}",
|
||
"recurring.frequency.weekly": "Weekly",
|
||
"recurring.frequency.monthly": "Monthly",
|
||
"recurring.frequency.yearly": "Yearly",
|
||
"recurring.form.newTitle": "New recurring transaction",
|
||
"recurring.form.editTitle": "Edit recurring transaction",
|
||
"recurring.form.frequencyLabel": "Frequency",
|
||
"recurring.form.anchorDateLabel": "Start date",
|
||
"recurring.form.saveError": "Failed to save the recurring transaction. Check the data and try again.",
|
||
|
||
"account.empty": "No accounts yet. Add your first one — a checking account or cash, for example.",
|
||
"account.showArchived": "Show archived",
|
||
"account.archivedBadge": "archived",
|
||
"account.archiveLabel": "Archive",
|
||
"account.unarchiveLabel": "Unarchive",
|
||
"account.confirmDelete":
|
||
"Delete the account “{{name}}”?\n\nThis will permanently delete ALL transactions on this account too. If you want to keep the history, archive the account instead of deleting it.",
|
||
"account.type.checking": "Checking",
|
||
"account.type.savings": "Savings",
|
||
"account.type.credit_card": "Credit card",
|
||
"account.type.cash": "Cash",
|
||
"account.type.investment": "Investment",
|
||
"account.type.other": "Other",
|
||
"account.form.newTitle": "New account",
|
||
"account.form.editTitle": "Edit account",
|
||
"account.form.nameLabel": "Name",
|
||
"account.form.namePlaceholder": "Main account, savings, cash…",
|
||
"account.form.typeLabel": "Type",
|
||
"account.form.saveError": "Failed to save the account. Check the data and try again.",
|
||
|
||
"category.expenseSectionTitle": "Expense categories",
|
||
"category.incomeSectionTitle": "Income categories",
|
||
"category.empty": "No categories yet.",
|
||
"category.defaultBadge": "default",
|
||
"category.defaultCannotDelete": "Default categories cannot be deleted",
|
||
"category.confirmDelete": "Delete the category “{{name}}”?\n\nIts transactions will be left uncategorized.",
|
||
"category.kind.expense": "Expense",
|
||
"category.kind.income": "Income",
|
||
"category.form.newTitle": "New category",
|
||
"category.form.editTitle": "Edit category",
|
||
"category.form.nameLabel": "Name",
|
||
"category.form.namePlaceholder": "Groceries, transport, subscriptions…",
|
||
"category.form.kindLabel": "Type",
|
||
"category.form.parentLabel": "Parent category",
|
||
"category.form.noParent": "— no parent (top level) —",
|
||
"category.form.hasChildrenHint": "This category already has subcategories — it can't become a subcategory itself.",
|
||
"category.form.iconLabel": "Icon",
|
||
"category.form.colorLabel": "Color",
|
||
"category.form.customColorLabel": "Custom color",
|
||
"category.form.saveError": "Failed to save the category. Check the data and try again.",
|
||
|
||
"cashFlow.income": "Income",
|
||
"cashFlow.expense": "Expense",
|
||
"cashFlow.net": "Net",
|
||
"cashFlow.noData": "No data for the selected period.",
|
||
|
||
"category.housingUtilities": "Housing & Utilities",
|
||
"category.groceries": "Groceries",
|
||
"category.diningOut": "Dining Out",
|
||
"category.transportation": "Transportation",
|
||
"category.healthFitness": "Health & Fitness",
|
||
"category.shopping": "Shopping",
|
||
"category.entertainment": "Entertainment",
|
||
"category.subscriptions": "Subscriptions",
|
||
"category.salary": "Salary",
|
||
"category.freelance": "Freelance",
|
||
"category.investments": "Investments",
|
||
"category.gifts": "Gifts",
|
||
"category.businessIncome": "Business Income",
|
||
"category.rentalIncome": "Rental Income",
|
||
"category.benefits": "Benefits",
|
||
"category.itemSales": "Item Sales",
|
||
"category.otherIncome": "Other Income",
|
||
|
||
"months.jan": "Jan",
|
||
"months.feb": "Feb",
|
||
"months.mar": "Mar",
|
||
"months.apr": "Apr",
|
||
"months.may": "May",
|
||
"months.jun": "Jun",
|
||
"months.jul": "Jul",
|
||
"months.aug": "Aug",
|
||
"months.sep": "Sep",
|
||
"months.oct": "Oct",
|
||
"months.nov": "Nov",
|
||
"months.dec": "Dec",
|
||
};
|
||
|
||
const translations: Record<Language, Record<keyof typeof ru, string>> = { ru, en };
|
||
|
||
export type TranslationKey = keyof typeof ru;
|
||
|
||
function readInitialLanguage(): Language {
|
||
const stored = localStorage.getItem(STORAGE_KEY);
|
||
if (stored === "ru" || stored === "en") return stored;
|
||
return "ru";
|
||
}
|
||
|
||
let currentLanguage: Language = readInitialLanguage();
|
||
// The primary display currency (ISO 4217 code, e.g. "USD"/"UAH"/"EUR"). Unlike
|
||
// language this isn't client-only — it's persisted server-side (GET/PATCH
|
||
// /api/settings) since it also drives what new accounts/assets are created
|
||
// with. App.tsx syncs this from the server once on boot via setCurrency();
|
||
// until that resolves, formatCurrency() falls back to this default.
|
||
let currentCurrency = "USD";
|
||
const listeners = new Set<() => void>();
|
||
|
||
export function getLanguage(): Language {
|
||
return currentLanguage;
|
||
}
|
||
|
||
export function setLanguage(language: Language): void {
|
||
if (language === currentLanguage) return;
|
||
currentLanguage = language;
|
||
localStorage.setItem(STORAGE_KEY, language);
|
||
listeners.forEach((listener) => listener());
|
||
}
|
||
|
||
export function getCurrency(): string {
|
||
return currentCurrency;
|
||
}
|
||
|
||
/** Updates the reactive local mirror only — call this after the server
|
||
* fetch/update succeeds, not in place of it. */
|
||
export function setCurrency(currency: string): void {
|
||
if (currency === currentCurrency) return;
|
||
currentCurrency = currency;
|
||
listeners.forEach((listener) => listener());
|
||
}
|
||
|
||
function subscribe(listener: () => void): () => void {
|
||
listeners.add(listener);
|
||
return () => listeners.delete(listener);
|
||
}
|
||
|
||
function interpolate(template: string, params?: Record<string, string | number>): string {
|
||
if (!params) return template;
|
||
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
||
const value = params[key];
|
||
return value === undefined ? match : String(value);
|
||
});
|
||
}
|
||
|
||
/** Usable outside React (e.g. api/ modules) — reads the current language at call time. */
|
||
export function t(key: TranslationKey, params?: Record<string, string | number>): string {
|
||
return interpolate(translations[currentLanguage][key], params);
|
||
}
|
||
|
||
/** Subscribes the component to language AND currency changes so it re-renders
|
||
* on either toggle — most components that call formatCurrency() already call
|
||
* this hook for text, so that alone is enough to pick up a fresh currency on
|
||
* their next render even without reading `currency` from the return value. */
|
||
export function useTranslation() {
|
||
const language = useSyncExternalStore(subscribe, () => currentLanguage);
|
||
const currency = useSyncExternalStore(subscribe, () => currentCurrency);
|
||
return { t, language, setLanguage, currency, setCurrency };
|
||
}
|