Translate UI and user-facing messages to Ukrainian (#5)

- All frontend pages, labels, notices and errors; html lang=uk, uk-UA money format
- Brand "Assistant System" in the top bar and page title
- Backend error details returned to the UI (auth, orders, receipts,
  cash registers, Checkbox/CRM/NP errors) and CLI output
- Tests updated for the new messages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 15:27:39 +03:00
co-authored by Claude Opus 5.5
parent 47df3a78dc
commit b8f2fe6b6e
38 changed files with 232 additions and 230 deletions
+1 -1
View File
@@ -65,7 +65,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(result.user)
setStatus('authenticated')
} catch (err) {
const message = err instanceof ApiError ? err.message : 'Не удалось подключиться к серверу'
const message = err instanceof ApiError ? err.message : 'Не вдалося підключитися до сервера'
setError(message)
throw err
}
+3 -3
View File
@@ -37,7 +37,7 @@ export function LoginPage() {
try {
await login(email, password)
} catch (err) {
const message = err instanceof ApiError ? err.message : 'Не удалось подключиться к серверу'
const message = err instanceof ApiError ? err.message : 'Не вдалося підключитися до сервера'
setFormError(message)
} finally {
setSubmitting(false)
@@ -102,7 +102,7 @@ export function LoginPage() {
type="button"
className="login-toggle"
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? 'Скрыть пароль' : 'Показать пароль'}
aria-label={showPassword ? 'Приховати пароль' : 'Показати пароль'}
aria-pressed={showPassword}
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
@@ -116,7 +116,7 @@ export function LoginPage() {
<button type="submit" className="login-submit" disabled={submitting}>
{submitting && <span className="spinner" aria-hidden="true" />}
{submitting ? 'Входим…' : 'Войти'}
{submitting ? 'Входимо…' : 'Увійти'}
</button>
</form>
</div>
@@ -10,7 +10,7 @@ export function ProtectedRoute() {
if (status === 'loading') {
return (
<div className="page-center">
<span className="spinner" aria-label="Загрузка" />
<span className="spinner" aria-label="Завантаження" />
</div>
)
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { AuthContext } from '@/features/auth/AuthContext'
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) {
throw new Error('useAuth должен вызываться внутри <AuthProvider>')
throw new Error('useAuth має викликатися всередині <AuthProvider>')
}
return ctx
}
@@ -96,18 +96,18 @@ function orderDiscountOf(draft: Draft): number {
/** Проверяет черновик и собирает тело PATCH; строка — текст ошибки для кассира. */
function toPayload(draft: Draft): OrderUpdate | string {
if (draft.goods.length === 0) return 'Добавьте хотя бы один товар'
if (draft.goods.length === 0) return 'Додайте хоча б один товар'
const goods = []
for (const good of draft.goods) {
const label = good.name.trim() || 'без названия'
if (!good.name.trim()) return 'У товара не указано наименование'
const label = good.name.trim() || 'без назви'
if (!good.name.trim()) return 'У товару не вказано найменування'
const price = toKopecks(good.price)
const quantity = toThousandths(good.quantity)
const discount = toKopecks(good.discount)
if (price === null) return `Товар «${label}»: некорректная цена`
if (quantity === null || quantity <= 0) return `Товар «${label}»: некорректное количество`
if (discount === null) return `Товар «${label}»: некорректная скидка`
if ((lineAmount(good) ?? 0) < 0) return `Товар «${label}»: скидка больше суммы строки`
if (price === null) return `Товар «${label}»: некоректна ціна`
if (quantity === null || quantity <= 0) return `Товар «${label}»: некоректна кількість`
if (discount === null) return `Товар «${label}»: некоректна знижка`
if ((lineAmount(good) ?? 0) < 0) return `Товар «${label}»: знижка більша за суму рядка`
goods.push({
id: good.id,
sku: good.sku.trim(),
@@ -118,9 +118,9 @@ function toPayload(draft: Draft): OrderUpdate | string {
})
}
const total = toKopecks(draft.total)
if (total === null) return 'Некорректная сумма заказа'
if (total === null) return 'Некоректна сума замовлення'
const sum = goodsSum(draft.goods) ?? 0
if (total > sum) return `Сумма заказа больше суммы товаров (${formatKopecks(sum)} ₴)`
if (total > sum) return `Сума замовлення більша за суму товарів (${formatKopecks(sum)} ₴)`
return {
recipient_name: draft.recipient_name,
@@ -134,7 +134,7 @@ function toPayload(draft: Draft): OrderUpdate | string {
}
const TEXT_FIELDS: { field: TextField; label: string; type?: string }[] = [
{ field: 'recipient_name', label: 'Клиент' },
{ field: 'recipient_name', label: 'Клієнт' },
{ field: 'recipient_phone', label: 'Телефон', type: 'tel' },
{ field: 'recipient_email', label: 'Email', type: 'email' },
{ field: 'waybill_number', label: 'Номер ТТН' },
@@ -153,7 +153,7 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
const editable = canEdit && !order.has_receipt
function requestClose() {
if (dirty && !window.confirm('Есть несохранённые изменения. Закрыть без сохранения?')) return
if (dirty && !window.confirm('Є незбережені зміни. Закрити без збереження?')) return
onClose()
}
@@ -203,10 +203,10 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
setDraft(next)
setSaved(next)
setOrderDiscount(orderDiscountOf(next))
setMessage({ tone: 'ok', text: 'Изменения сохранены' })
setMessage({ tone: 'ok', text: 'Зміни збережено' })
await queryClient.invalidateQueries({ queryKey: ['orders'] })
} catch (err) {
setMessage({ tone: 'error', text: err instanceof Error ? err.message : 'Не удалось сохранить заказ' })
setMessage({ tone: 'error', text: err instanceof Error ? err.message : 'Не вдалося зберегти замовлення' })
} finally {
setSaving(false)
}
@@ -217,10 +217,10 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
<div className="order-modal">
<div className="order-modal-header">
<h3>
Заказ {order.id}
{order.edited && <span className="order-modal-edited">изменён вручную</span>}
Замовлення {order.id}
{order.edited && <span className="order-modal-edited">змінено вручну</span>}
</h3>
<button type="button" className="order-modal-close" onClick={requestClose} aria-label="Закрыть">
<button type="button" className="order-modal-close" onClick={requestClose} aria-label="Закрити">
×
</button>
</div>
@@ -232,19 +232,19 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
</div>
<div>
<span className="order-modal-label">Статус ТТН</span>
<span>{order.np_status || 'Нет данных'}</span>
<span>{order.np_status || 'Немає даних'}</span>
</div>
<div>
<span className="order-modal-label">Наложенный платёж</span>
<span className="order-modal-label">Накладений платіж</span>
<span>{order.np_cod_amount ? `${order.np_cod_amount} ₴` : '—'}</span>
</div>
<div>
<span className="order-modal-label">Оплачено</span>
<span>
{order.np_payment_status === 'Payed'
? 'Да'
? 'Так'
: order.np_payment_status === 'NeedPayment'
? 'Нет'
? 'Ні'
: '—'}
</span>
</div>
@@ -272,7 +272,7 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
{editable ? (
<label className="order-modal-notes">
<span className="order-modal-label">Заметки</span>
<span className="order-modal-label">Нотатки</span>
<textarea
className="order-modal-input"
rows={2}
@@ -283,7 +283,7 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
) : (
draft.notes && (
<div className="order-modal-notes">
<span className="order-modal-label">Заметки</span>
<span className="order-modal-label">Нотатки</span>
<p>{draft.notes}</p>
</div>
)
@@ -292,13 +292,13 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
<table className="order-modal-goods">
<thead>
<tr>
<th>Наименование</th>
<th>Найменування</th>
<th>SKU</th>
<th>Цена</th>
<th>Кол-во</th>
<th>Скидка</th>
<th>Сумма</th>
{editable && <th aria-label="Удалить" />}
<th>Ціна</th>
<th>К-сть</th>
<th>Знижка</th>
<th>Сума</th>
{editable && <th aria-label="Видалити" />}
</tr>
</thead>
<tbody>
@@ -350,7 +350,7 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
<button
type="button"
className="order-modal-remove"
aria-label={`Удалить товар ${good.name}`}
aria-label={`Видалити товар ${good.name}`}
onClick={() => setGoods((goods) => goods.filter((g) => g.key !== good.key))}
>
×
@@ -363,7 +363,7 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
{draft.goods.length === 0 && (
<tr>
<td colSpan={editable ? 7 : 6} className="order-modal-goods-empty">
Товары не указаны
Товари не вказані
</td>
</tr>
)}
@@ -372,19 +372,19 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
{editable && (
<button type="button" className="order-modal-add" onClick={addGood}>
+ Добавить товар
+ Додати товар
</button>
)}
<div className="order-modal-total">
<span>Итого</span>
<span>Разом</span>
{editable ? (
<span>
<input
className="order-modal-input order-modal-input--num"
inputMode="decimal"
value={draft.total}
title="Сумма заказа после всех скидок"
title="Сума замовлення після всіх знижок"
onChange={(e) => setTotal(e.target.value)}
/>{' '}
₴
@@ -403,12 +403,12 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
</span>
</div>
<div>
<span className="order-modal-label">Предоплата в чеке</span>
<span className="order-modal-label">Передоплата в чеку</span>
<span>{order.receipt_prepayment ? `${order.receipt_prepayment} ₴` : '—'}</span>
</div>
{order.receipt_error && (
<div>
<span className="order-modal-label">Ошибка</span>
<span className="order-modal-label">Помилка</span>
<span>{order.receipt_error}</span>
</div>
)}
@@ -425,11 +425,11 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
disabled={!dirty || saving}
onClick={() => void handleSave()}
>
{saving ? 'Сохранение…' : 'Сохранить'}
{saving ? 'Збереження…' : 'Зберегти'}
</button>
)}
<button type="button" className="order-modal-close-btn" onClick={requestClose}>
Закрыть
Закрити
</button>
</div>
</div>
+7 -7
View File
@@ -43,13 +43,13 @@ export interface ReceiptCreateResponse {
export type Tone = 'delivered' | 'processing' | 'danger' | 'new'
export const RECEIPT_STATUS: Record<ReceiptStatus, { label: string; tone: Tone }> = {
pending: { label: 'Отправляется', tone: 'new' },
created: { label: 'Ждёт оплаты', tone: 'processing' },
done: { label: 'Фискализирован', tone: 'delivered' },
returned: { label: 'Возврат посылки', tone: 'danger' },
receipt_error: { label: 'Ошибка фискализации', tone: 'danger' },
cancelled: { label: 'Отменён', tone: 'new' },
failed: { label: 'Не создан', tone: 'danger' },
pending: { label: 'Надсилається', tone: 'new' },
created: { label: 'Очікує оплати', tone: 'processing' },
done: { label: 'Фіскалізовано', tone: 'delivered' },
returned: { label: 'Повернення посилки', tone: 'danger' },
receipt_error: { label: 'Помилка фіскалізації', tone: 'danger' },
cancelled: { label: 'Скасовано', tone: 'new' },
failed: { label: 'Не створено', tone: 'danger' },
}
/** Статусы, из которых чек можно отменить (см. services/receipts.cancel_receipt). */