Files
lux_fiscal/frontend/src/features/orders/OrderDetailModal.tsx
T
lauadminandClaude Opus 5.5 b8f2fe6b6e 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>
2026-09-25 15:27:39 +03:00

439 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { updateOrder } from '@/api/orders'
import { Modal } from '@/components/Modal'
import '@/features/orders/OrderDetailModal.css'
import type { Order, OrderUpdate } from '@/features/orders/types'
import { formatKopecks, toKopecks } from '@/features/receipts/money'
import { RECEIPT_STATUS } from '@/features/receipts/types'
interface OrderDetailModalProps {
order: Order
/** Кассир/админ и по заказу ещё нет чека — карточку можно править. */
canEdit: boolean
onClose: () => void
}
interface GoodDraft {
key: string
id: string | null
sku: string
name: string
price: string
quantity: string
discount: string
}
interface Draft {
recipient_name: string
recipient_phone: string
recipient_email: string
waybill_number: string
notes: string
total: string
goods: GoodDraft[]
}
type TextField = Exclude<keyof Draft, 'goods' | 'total'>
/** Количество в тысячных (конвенция Checkbox: 1 шт = 1000). */
function toThousandths(value: string): number | null {
const normalized = value.trim().replace(',', '.')
if (!/^\d+(\.\d{1,3})?$/.test(normalized)) return null
return Math.round(Number(normalized) * 1000)
}
/** Сумма строки после скидки в копейках; null — в строке некорректные числа. */
function lineAmount(good: GoodDraft): number | null {
const price = toKopecks(good.price)
const quantity = toThousandths(good.quantity)
const discount = toKopecks(good.discount)
if (price === null || quantity === null || discount === null) return null
return Math.round((price * quantity) / 1000) - discount
}
function goodsSum(goods: GoodDraft[]): number | null {
let sum = 0
for (const good of goods) {
const amount = lineAmount(good)
if (amount === null) return null
sum += amount
}
return sum
}
let newGoodSeq = 0
function toDraft(order: Order): Draft {
return {
recipient_name: order.recipient_name ?? '',
recipient_phone: order.recipient_phone ?? '',
recipient_email: order.recipient_email ?? '',
waybill_number: order.waybill_number ?? '',
notes: order.notes ?? '',
total: order.total_amount,
goods: order.goods.map((good) => {
// Скидку строки выводим из amount, как бэкенд при сборке чека, — не из discount_amount/percent.
const gross = Math.round(((toKopecks(good.price) ?? 0) * (toThousandths(good.quantity) ?? 0)) / 1000)
const discount = Math.max(gross - (toKopecks(good.amount) ?? gross), 0)
return {
key: good.id,
id: good.id,
sku: good.sku,
name: good.name,
price: good.price,
quantity: good.quantity,
discount: formatKopecks(discount),
}
}),
}
}
function orderDiscountOf(draft: Draft): number {
return (goodsSum(draft.goods) ?? 0) - (toKopecks(draft.total) ?? 0)
}
/** Проверяет черновик и собирает тело PATCH; строка — текст ошибки для кассира. */
function toPayload(draft: Draft): OrderUpdate | string {
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 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}»: знижка більша за суму рядка`
goods.push({
id: good.id,
sku: good.sku.trim(),
name: good.name.trim(),
price: formatKopecks(price),
quantity: (quantity / 1000).toFixed(3),
discount_amount: formatKopecks(discount),
})
}
const total = toKopecks(draft.total)
if (total === null) return 'Некоректна сума замовлення'
const sum = goodsSum(draft.goods) ?? 0
if (total > sum) return `Сума замовлення більша за суму товарів (${formatKopecks(sum)} ₴)`
return {
recipient_name: draft.recipient_name,
recipient_phone: draft.recipient_phone,
recipient_email: draft.recipient_email,
waybill_number: draft.waybill_number,
notes: draft.notes,
total_amount: formatKopecks(total),
goods,
}
}
const TEXT_FIELDS: { field: TextField; label: string; type?: string }[] = [
{ field: 'recipient_name', label: 'Клієнт' },
{ field: 'recipient_phone', label: 'Телефон', type: 'tel' },
{ field: 'recipient_email', label: 'Email', type: 'email' },
{ field: 'waybill_number', label: 'Номер ТТН' },
]
export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalProps) {
const queryClient = useQueryClient()
const [draft, setDraft] = useState<Draft>(() => toDraft(order))
const [saved, setSaved] = useState<Draft>(draft)
// Скидка на весь заказ (итог товаров − сумма заказа): держим её при правке товаров.
const [orderDiscount, setOrderDiscount] = useState(() => orderDiscountOf(draft))
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<{ tone: 'ok' | 'error'; text: string } | null>(null)
const dirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(saved), [draft, saved])
const editable = canEdit && !order.has_receipt
function requestClose() {
if (dirty && !window.confirm('Є незбережені зміни. Закрити без збереження?')) return
onClose()
}
function setField(field: TextField, value: string) {
setDraft((prev) => ({ ...prev, [field]: value }))
setMessage(null)
}
/** Меняет товары и пересчитывает итог с сохранением скидки на заказ. */
function setGoods(update: (goods: GoodDraft[]) => GoodDraft[]) {
const goods = update(draft.goods)
const sum = goodsSum(goods)
setDraft({ ...draft, goods, total: sum === null ? draft.total : formatKopecks(Math.max(sum - orderDiscount, 0)) })
setMessage(null)
}
function setTotal(value: string) {
setDraft({ ...draft, total: value })
const sum = goodsSum(draft.goods)
const total = toKopecks(value)
if (sum !== null && total !== null) setOrderDiscount(sum - total)
setMessage(null)
}
function setGood(key: string, field: keyof GoodDraft, value: string) {
setGoods((goods) => goods.map((good) => (good.key === key ? { ...good, [field]: value } : good)))
}
function addGood() {
newGoodSeq += 1
setGoods((goods) => [
...goods,
{ key: `new-${newGoodSeq}`, id: null, sku: '', name: '', price: '0.00', quantity: '1', discount: '0.00' },
])
}
async function handleSave() {
const payload = toPayload(draft)
if (typeof payload === 'string') {
setMessage({ tone: 'error', text: payload })
return
}
setSaving(true)
try {
const updated = await updateOrder(order.id, payload)
const next = toDraft(updated)
setDraft(next)
setSaved(next)
setOrderDiscount(orderDiscountOf(next))
setMessage({ tone: 'ok', text: 'Зміни збережено' })
await queryClient.invalidateQueries({ queryKey: ['orders'] })
} catch (err) {
setMessage({ tone: 'error', text: err instanceof Error ? err.message : 'Не вдалося зберегти замовлення' })
} finally {
setSaving(false)
}
}
return (
<Modal onClose={requestClose} wide={editable}>
<div className="order-modal">
<div className="order-modal-header">
<h3>
Замовлення {order.id}
{order.edited && <span className="order-modal-edited">змінено вручну</span>}
</h3>
<button type="button" className="order-modal-close" onClick={requestClose} aria-label="Закрити">
×
</button>
</div>
<div className="order-modal-meta">
<div>
<span className="order-modal-label">Дата</span>
<span>{order.create_date_time}</span>
</div>
<div>
<span className="order-modal-label">Статус ТТН</span>
<span>{order.np_status || 'Немає даних'}</span>
</div>
<div>
<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>
{TEXT_FIELDS.map(({ field, label, type }) =>
editable ? (
<label key={field}>
<span className="order-modal-label">{label}</span>
<input
type={type ?? 'text'}
className="order-modal-input"
value={draft[field]}
onChange={(e) => setField(field, e.target.value)}
/>
</label>
) : (
(field !== 'recipient_email' || draft[field]) && (
<div key={field}>
<span className="order-modal-label">{label}</span>
<span>{draft[field] || '—'}</span>
</div>
)
),
)}
</div>
{editable ? (
<label className="order-modal-notes">
<span className="order-modal-label">Нотатки</span>
<textarea
className="order-modal-input"
rows={2}
value={draft.notes}
onChange={(e) => setField('notes', e.target.value)}
/>
</label>
) : (
draft.notes && (
<div className="order-modal-notes">
<span className="order-modal-label">Нотатки</span>
<p>{draft.notes}</p>
</div>
)
)}
<table className="order-modal-goods">
<thead>
<tr>
<th>Найменування</th>
<th>SKU</th>
<th>Ціна</th>
<th>К-сть</th>
<th>Знижка</th>
<th>Сума</th>
{editable && <th aria-label="Видалити" />}
</tr>
</thead>
<tbody>
{draft.goods.map((good) => {
const amount = lineAmount(good)
return (
<tr key={good.key}>
{editable ? (
<>
<td>
<input
className="order-modal-input"
value={good.name}
onChange={(e) => setGood(good.key, 'name', e.target.value)}
/>
</td>
<td>
<input
className="order-modal-input order-modal-input--sku"
value={good.sku}
onChange={(e) => setGood(good.key, 'sku', e.target.value)}
/>
</td>
{(['price', 'quantity', 'discount'] as const).map((field) => (
<td key={field}>
<input
className="order-modal-input order-modal-input--num"
inputMode="decimal"
value={good[field]}
onChange={(e) => setGood(good.key, field, e.target.value)}
/>
</td>
))}
</>
) : (
<>
<td>{good.name}</td>
<td>{good.sku}</td>
<td>{good.price}</td>
<td>{good.quantity}</td>
<td>{good.discount !== '0.00' ? good.discount : '—'}</td>
</>
)}
<td className={amount === null || amount < 0 ? 'order-modal-invalid' : undefined}>
{amount === null ? '—' : formatKopecks(amount)}
</td>
{editable && (
<td>
<button
type="button"
className="order-modal-remove"
aria-label={`Видалити товар ${good.name}`}
onClick={() => setGoods((goods) => goods.filter((g) => g.key !== good.key))}
>
×
</button>
</td>
)}
</tr>
)
})}
{draft.goods.length === 0 && (
<tr>
<td colSpan={editable ? 7 : 6} className="order-modal-goods-empty">
Товари не вказані
</td>
</tr>
)}
</tbody>
</table>
{editable && (
<button type="button" className="order-modal-add" onClick={addGood}>
+ Додати товар
</button>
)}
<div className="order-modal-total">
<span>Разом</span>
{editable ? (
<span>
<input
className="order-modal-input order-modal-input--num"
inputMode="decimal"
value={draft.total}
title="Сума замовлення після всіх знижок"
onChange={(e) => setTotal(e.target.value)}
/>{' '}
₴
</span>
) : (
<span>{order.total_amount} ₴</span>
)}
</div>
{order.receipt_status && (
<div className="order-modal-meta">
<div>
<span className="order-modal-label">ЕТТН-чек Checkbox</span>
<span className={`orders-status orders-status--${RECEIPT_STATUS[order.receipt_status].tone}`}>
{RECEIPT_STATUS[order.receipt_status].label}
</span>
</div>
<div>
<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>{order.receipt_error}</span>
</div>
)}
</div>
)}
{message && <div className={`order-modal-message order-modal-message--${message.tone}`}>{message.text}</div>}
<div className="order-modal-actions">
{editable && (
<button
type="button"
className="order-modal-save-btn"
disabled={!dirty || saving}
onClick={() => void handleSave()}
>
{saving ? 'Збереження…' : 'Зберегти'}
</button>
)}
<button type="button" className="order-modal-close-btn" onClick={requestClose}>
Закрити
</button>
</div>
</div>
</Modal>
)
}