- 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>
439 lines
16 KiB
TypeScript
439 lines
16 KiB
TypeScript
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>
|
||
)
|
||
}
|