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