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
+2 -2
View File
@@ -1,10 +1,10 @@
<!doctype html>
<html lang="ru">
<html lang="uk">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>lux_fiscal</title>
<title>Assistant System</title>
</head>
<body>
<div id="root"></div>
+5 -5
View File
@@ -27,10 +27,10 @@ async function readErrorMessage(response: Response): Promise<string> {
// Тело не JSON или пустое — используем сообщение по статусу ниже.
}
if (response.status === 401) return 'Требуется вход в систему'
if (response.status === 403) return 'Недостаточно прав для этого действия'
if (response.status >= 500) return 'Сервер временно недоступен, попробуйте позже'
return `Ошибка запроса (${response.status})`
if (response.status === 401) return 'Потрібно увійти в систему'
if (response.status === 403) return 'Недостатньо прав для цієї дії'
if (response.status >= 500) return 'Сервер тимчасово недоступний, спробуйте пізніше'
return `Помилка запиту (${response.status})`
}
// Параллельные 401 не должны порождать несколько запросов на refresh —
@@ -42,7 +42,7 @@ async function refreshAccessToken(): Promise<string> {
const refreshToken = getRefreshToken()
if (!refreshToken) {
throw new ApiError(401, 'Сессия истекла, войдите снова')
throw new ApiError(401, 'Сесія закінчилася, увійдіть знову')
}
refreshPromise = (async () => {
+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). */
+1 -1
View File
@@ -6,7 +6,7 @@ import '@/index.css'
const rootElement = document.getElementById('root')
if (!rootElement) {
throw new Error('Элемент #root не найден в index.html')
throw new Error('Елемент #root не знайдено в index.html')
}
createRoot(rootElement).render(
+38 -38
View File
@@ -64,7 +64,7 @@ export function CashRegistersPage() {
setMessage({ tone: 'ok', text: okText })
await queryClient.invalidateQueries({ queryKey: ['cash-registers'] })
} catch (err) {
setMessage({ tone: 'error', text: err instanceof Error ? err.message : 'Ошибка запроса' })
setMessage({ tone: 'error', text: err instanceof Error ? err.message : 'Помилка запиту' })
} finally {
setBusy(false)
}
@@ -101,7 +101,7 @@ export function CashRegistersPage() {
if (form.license_key) payload.license_key = form.license_key
if (form.pin_code) payload.pin_code = form.pin_code
if (form.np_api_key) payload.np_api_key = form.np_api_key
await run(() => updateCashRegister(editingId, payload), 'Касса обновлена')
await run(() => updateCashRegister(editingId, payload), 'Касу оновлено')
} else {
await run(
() =>
@@ -114,7 +114,7 @@ export function CashRegistersPage() {
tax_codes: parseTaxCodes(form.tax_codes),
is_default: form.is_default,
}),
'Касса добавлена',
'Касу додано',
)
}
resetForm()
@@ -123,21 +123,21 @@ export function CashRegistersPage() {
return (
<div className="dashboard-shell">
<header className="dashboard-topbar">
<span className="dashboard-brand">lux_fiscal</span>
<span className="dashboard-brand">Assistant System</span>
<Link to="/" className="orders-link-btn">
← К заказам
← До замовлень
</Link>
</header>
<main className="orders-body">
<div className="orders-toolbar">
<h2>Кассы Checkbox</h2>
<h2>Каси Checkbox</h2>
</div>
{message && (
<div className={`orders-notice orders-notice--${message.tone}`}>
<span>{message.text}</span>
<button type="button" className="orders-notice-close" onClick={() => setMessage(null)} aria-label="Скрыть">
<button type="button" className="orders-notice-close" onClick={() => setMessage(null)} aria-label="Сховати">
×
</button>
</div>
@@ -147,34 +147,34 @@ export function CashRegistersPage() {
<table className="orders-table">
<thead>
<tr>
<th>Название</th>
<th>Фиск. номер</th>
<th>Ключ лицензии</th>
<th>Ключ Новой Почты</th>
<th>Налоги</th>
<th>Назва</th>
<th>Фіск. номер</th>
<th>Ключ ліцензії</th>
<th>Ключ Нової Пошти</th>
<th>Податки</th>
<th>Статус</th>
<th>Действия</th>
<th>Дії</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={7} className="orders-empty">
Загрузка…
Завантаження…
</td>
</tr>
)}
{isError && (
<tr>
<td colSpan={7} className="orders-empty">
Не удалось загрузить кассы
Не вдалося завантажити каси
</td>
</tr>
)}
{registers?.length === 0 && (
<tr>
<td colSpan={7} className="orders-empty">
Кассы не настроены — чеки создавать нельзя
Каси не налаштовані — створювати чеки неможливо
</td>
</tr>
)}
@@ -182,15 +182,15 @@ export function CashRegistersPage() {
<tr key={register.id}>
<td>
{register.name}
{register.is_default && <span className="orders-status orders-status--delivered cr-default">основная</span>}
{register.is_default && <span className="orders-status orders-status--delivered cr-default">основна</span>}
</td>
<td>{register.fiscal_number || '—'}</td>
<td>{register.license_key_masked}</td>
<td>{register.np_api_key_masked ?? <span className="cr-missing">не задан</span>}</td>
<td>{register.tax_codes.length ? register.tax_codes.join(', ') : 'без налога'}</td>
<td>{register.np_api_key_masked ?? <span className="cr-missing">не задано</span>}</td>
<td>{register.tax_codes.length ? register.tax_codes.join(', ') : 'без податку'}</td>
<td>
<span className={`orders-status orders-status--${register.is_active ? 'delivered' : 'new'}`}>
{register.is_active ? 'Активна' : 'Отключена'}
{register.is_active ? 'Активна' : 'Вимкнена'}
</span>
</td>
<td className="orders-actions-cell">
@@ -198,12 +198,12 @@ export function CashRegistersPage() {
type="button"
className="orders-view-btn"
disabled={busy}
onClick={() => void run(() => checkCashRegister(register.id), `Вход в «${register.name}» успешен`)}
onClick={() => void run(() => checkCashRegister(register.id), `Вхід у «${register.name}» успішний`)}
>
Проверить
Перевірити
</button>
<button type="button" className="orders-view-btn" disabled={busy} onClick={() => startEdit(register)}>
Изменить
Змінити
</button>
<button
type="button"
@@ -212,11 +212,11 @@ export function CashRegistersPage() {
onClick={() =>
void run(
() => updateCashRegister(register.id, { is_active: !register.is_active }),
register.is_active ? 'Касса отключена' : 'Касса включена',
register.is_active ? 'Касу вимкнено' : 'Касу увімкнено',
)
}
>
{register.is_active ? 'Отключить' : 'Включить'}
{register.is_active ? 'Вимкнути' : 'Увімкнути'}
</button>
</td>
</tr>
@@ -226,48 +226,48 @@ export function CashRegistersPage() {
</div>
<form className="cr-form" onSubmit={(e) => void handleSubmit(e)}>
<h3>{editingId ? 'Изменить кассу' : 'Добавить кассу'}</h3>
<h3>{editingId ? 'Змінити касу' : 'Додати касу'}</h3>
<label>
Название
Назва
<input required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</label>
<label>
Фискальный номер
Фіскальний номер
<input value={form.fiscal_number} onChange={(e) => setForm({ ...form, fiscal_number: e.target.value })} />
</label>
<label>
Ключ лицензии кассы
Ключ ліцензії каси
<input
required={!editingId}
autoComplete="off"
placeholder={editingId ? 'не менять' : ''}
placeholder={editingId ? 'не змінювати' : ''}
value={form.license_key}
onChange={(e) => setForm({ ...form, license_key: e.target.value })}
/>
</label>
<label>
PIN-код кассира
PIN-код касира
<input
required={!editingId}
type="password"
autoComplete="new-password"
placeholder={editingId ? 'не менять' : ''}
placeholder={editingId ? 'не змінювати' : ''}
value={form.pin_code}
onChange={(e) => setForm({ ...form, pin_code: e.target.value })}
/>
</label>
<label>
Ключ API Новой Почты (посылки этого кабинета пробиваются через эту кассу)
Ключ API Нової Пошти (посилки цього кабінету пробиваються через цю касу)
<input
required={!editingId}
autoComplete="off"
placeholder={editingId ? 'не менять' : ''}
placeholder={editingId ? 'не змінювати' : ''}
value={form.np_api_key}
onChange={(e) => setForm({ ...form, np_api_key: e.target.value })}
/>
</label>
<label>
Коды налогов (через запятую, пусто — без налога)
Коди податків (через кому, порожньо — без податку)
<input value={form.tax_codes} onChange={(e) => setForm({ ...form, tax_codes: e.target.value })} />
</label>
<label className="cr-checkbox">
@@ -276,15 +276,15 @@ export function CashRegistersPage() {
checked={form.is_default}
onChange={(e) => setForm({ ...form, is_default: e.target.checked })}
/>
Основная касса (первой проверяет новые посылки)
Основна каса (першою перевіряє нові посилки)
</label>
<div className="cr-form-actions">
<button type="submit" className="orders-create-btn" disabled={busy}>
{editingId ? 'Сохранить' : 'Добавить'}
{editingId ? 'Зберегти' : 'Додати'}
</button>
{editingId && (
<button type="button" className="orders-view-btn" onClick={resetForm}>
Отмена
Скасувати
</button>
)}
</div>
+46 -46
View File
@@ -14,16 +14,16 @@ import { CANCELLABLE, RECEIPT_STATUS } from '@/features/receipts/types'
import type { ReceiptRequestItem } from '@/features/receipts/types'
const ROLE_LABEL: Record<string, string> = {
admin: 'Администратор',
cashier: 'Кассир',
viewer: 'Наблюдатель',
admin: 'Адміністратор',
cashier: 'Касир',
viewer: 'Спостерігач',
}
const TABS: { key: OrderTab; label: string }[] = [
{ key: 'no_receipt', label: 'Без чека' },
{ key: 'has_receipt', label: 'Выписаны чеки' },
{ key: 'received', label: 'Полученные' },
{ key: 'refused', label: 'Отказы' },
{ key: 'has_receipt', label: 'Виписані чеки' },
{ key: 'received', label: 'Отримані' },
{ key: 'refused', label: 'Відмови' },
]
function npStatusTone(order: Order): 'delivered' | 'processing' | 'danger' | 'new' {
@@ -45,7 +45,7 @@ function paymentBadge(order: Order): { label: string; tone: 'delivered' | 'proce
return null
}
const MONEY_FORMAT = new Intl.NumberFormat('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
const MONEY_FORMAT = new Intl.NumberFormat('uk-UA', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
function formatMoney(amount: string): string {
return MONEY_FORMAT.format(Number(amount))
@@ -80,7 +80,7 @@ export function DashboardPage() {
for (const order of targets) {
const prepayment = toKopecks(prepaymentOf(order))
if (prepayment === null) {
localErrors.push(`${order.id}: некорректная сумма предоплаты`)
localErrors.push(`${order.id}: некоректна сума передоплати`)
continue
}
items.push({ order_id: order.id, prepayment_kopecks: prepayment })
@@ -95,14 +95,14 @@ export function DashboardPage() {
const result = await createReceipts(items)
const errors = [...localErrors, ...Object.entries(result.errors).map(([id, msg]) => `${id}: ${msg}`)]
const lines = [
...(result.created.length ? [`Отправлено в Checkbox: ${result.created.length}`] : []),
...(result.created.length ? [`Надіслано в Checkbox: ${result.created.length}`] : []),
...errors,
]
setNotice({ tone: errors.length ? 'error' : 'ok', lines })
setSelected(new Set())
await queryClient.invalidateQueries({ queryKey: ['orders'] })
} catch (err) {
setNotice({ tone: 'error', lines: [err instanceof Error ? err.message : 'Не удалось создать чеки'] })
setNotice({ tone: 'error', lines: [err instanceof Error ? err.message : 'Не вдалося створити чеки'] })
} finally {
setSubmitting(false)
}
@@ -111,19 +111,19 @@ export function DashboardPage() {
async function handleCancel(order: Order) {
if (!order.receipt_id) return
const outcome = {
no_receipt: 'Заказ вернётся в очередь.',
has_receipt: 'Заказ вернётся в очередь.',
received: 'Заказ останется в полученных.',
refused: 'Заказ останется в отказах.',
no_receipt: 'Замовлення повернеться в чергу.',
has_receipt: 'Замовлення повернеться в чергу.',
received: 'Замовлення залишиться в отриманих.',
refused: 'Замовлення залишиться у відмовах.',
}[tab]
if (!window.confirm(`Отменить ЕТТН-чек по заказу ${order.id}? ${outcome}`)) return
if (!window.confirm(`Скасувати ЕТТН-чек за замовленням ${order.id}? ${outcome}`)) return
setCancellingId(order.id)
try {
await cancelReceipt(order.receipt_id)
setNotice({ tone: 'ok', lines: [`Чек по заказу ${order.id} отменён`] })
setNotice({ tone: 'ok', lines: [`Чек за замовленням ${order.id} скасовано`] })
await queryClient.invalidateQueries({ queryKey: ['orders'] })
} catch (err) {
setNotice({ tone: 'error', lines: [err instanceof Error ? err.message : 'Не удалось отменить чек'] })
setNotice({ tone: 'error', lines: [err instanceof Error ? err.message : 'Не вдалося скасувати чек'] })
} finally {
setCancellingId(null)
}
@@ -174,23 +174,23 @@ export function DashboardPage() {
return (
<div className="dashboard-shell">
<header className="dashboard-topbar">
<span className="dashboard-brand">lux_fiscal</span>
<span className="dashboard-brand">Assistant System</span>
<div className="dashboard-user">
<span>{user?.full_name}</span>
<span className="dashboard-role">{user ? (ROLE_LABEL[user.role] ?? user.role) : ''}</span>
<button type="button" className="dashboard-logout" onClick={() => void logout()}>
Выйти
Вийти
</button>
</div>
</header>
<main className="orders-body">
<div className="orders-toolbar">
<h2>Заказы</h2>
<h2>Замовлення</h2>
<div className="orders-toolbar-actions">
{user?.role === 'admin' && (
<Link to="/cash-registers" className="orders-link-btn">
Кассы
Каси
</Link>
)}
{tab === 'no_receipt' && canFiscalize && (
@@ -200,20 +200,20 @@ export function DashboardPage() {
disabled={selected.size === 0 || submitting}
onClick={() => void submitReceipts(filtered.filter((order) => selected.has(order.id)))}
>
Создать чеки по выбранным ({selected.size})
Створити чеки за вибраними ({selected.size})
</button>
)}
</div>
</div>
<div className="orders-summary">
<div className="orders-summary-card" title="Посылки с наложкой, которые ещё не забрали и по которым нет отказа">
<span className="orders-summary-label">Наложка в пути</span>
<div className="orders-summary-card" title="Посилки з післяплатою, які ще не забрали і за якими немає відмови">
<span className="orders-summary-label">Післяплата в дорозі</span>
<span className="orders-summary-value">
{summary ? `${formatMoney(summary.cod_in_transit_amount)} ₴` : '—'}
</span>
<span className="orders-summary-hint">
{summary ? `${summary.cod_in_transit_count} посылок` : ' '}
{summary ? `${summary.cod_in_transit_count} посилок` : ' '}
</span>
</div>
</div>
@@ -225,7 +225,7 @@ export function DashboardPage() {
<li key={line}>{line}</li>
))}
</ul>
<button type="button" className="orders-notice-close" onClick={() => setNotice(null)} aria-label="Скрыть">
<button type="button" className="orders-notice-close" onClick={() => setNotice(null)} aria-label="Сховати">
×
</button>
</div>
@@ -248,16 +248,16 @@ export function DashboardPage() {
</div>
<div className="orders-filterbar">
<span className="orders-selected-count">Выбрано: {selected.size} заказов</span>
<span className="orders-selected-count">Вибрано: {selected.size} замовлень</span>
<input
type="search"
className="orders-search"
placeholder="Поиск по ID, ТТН, клиенту"
placeholder="Пошук за ID, ТТН, клієнтом"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<button type="button" className="orders-filter-btn">
Фильтр
Фільтр
</button>
</div>
@@ -266,31 +266,31 @@ export function DashboardPage() {
<thead>
<tr>
<th className="orders-checkbox-col">
<input type="checkbox" checked={allSelected} onChange={toggleAll} aria-label="Выбрать всё" />
<input type="checkbox" checked={allSelected} onChange={toggleAll} aria-label="Вибрати все" />
</th>
<th>ID заказа</th>
<th>ID замовлення</th>
<th>Номер ТТН</th>
<th>Клиент</th>
<th>Сумма</th>
<th>Наложка</th>
<th>Клієнт</th>
<th>Сума</th>
<th>Післяплата</th>
<th>Оплачено</th>
<th>Предоплата, ₴</th>
<th>Передоплата, ₴</th>
<th>Статус ТТН</th>
<th>Действия</th>
<th>Дії</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={10} className="orders-empty">
Загрузка…
Завантаження…
</td>
</tr>
)}
{isError && (
<tr>
<td colSpan={10} className="orders-empty">
Не удалось загрузить заказы из CRM
Не вдалося завантажити замовлення з CRM
</td>
</tr>
)}
@@ -305,7 +305,7 @@ export function DashboardPage() {
type="checkbox"
checked={selected.has(order.id)}
onChange={() => toggleOne(order.id)}
aria-label={`Выбрать заказ ${order.id}`}
aria-label={`Вибрати замовлення ${order.id}`}
/>
</td>
<td>{order.id}</td>
@@ -334,7 +334,7 @@ export function DashboardPage() {
placeholder="0.00"
value={prepaymentOf(order)}
disabled={!canFiscalize}
title="Сумма заказа − предоплата должна равняться наложке"
title="Сума замовлення − передоплата має дорівнювати післяплаті"
onChange={(e) => setPrepayments((prev) => ({ ...prev, [order.id]: e.target.value }))}
/>
) : (
@@ -343,19 +343,19 @@ export function DashboardPage() {
</td>
<td>
<span className={`orders-status orders-status--${npStatusTone(order)}`}>
{order.np_status || 'Нет данных'}
{order.np_status || 'Немає даних'}
</span>
</td>
<td className="orders-actions-cell">
<button type="button" className="orders-view-btn" onClick={() => setViewingOrder(order)}>
Просмотр
Перегляд
</button>
{tab === 'no_receipt' && canFiscalize && (
<button
type="button"
className="orders-receipt-btn"
disabled={!canCreateReceipt(order) || submitting}
title={canCreateReceipt(order) ? 'Создать ЕТТН-чек в Checkbox' : 'Нет ТТН или наложки'}
title={canCreateReceipt(order) ? 'Створити ЕТТН-чек у Checkbox' : 'Немає ТТН або післяплати'}
onClick={() => void submitReceipts([order])}
>
Чек
@@ -376,7 +376,7 @@ export function DashboardPage() {
disabled={cancellingId === order.id}
onClick={() => void handleCancel(order)}
>
Отменить
Скасувати
</button>
)}
{canDelete && (tab === 'no_receipt' || ((tab === 'refused' || tab === 'received') && !order.has_receipt)) && (
@@ -386,7 +386,7 @@ export function DashboardPage() {
disabled={deletingId === order.id}
onClick={() => void handleDelete(order)}
>
Удалить
Видалити
</button>
)}
</td>
@@ -396,7 +396,7 @@ export function DashboardPage() {
{!isLoading && !isError && filtered.length === 0 && (
<tr>
<td colSpan={10} className="orders-empty">
Ничего не найдено
Нічого не знайдено
</td>
</tr>
)}