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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user