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 /** Количество в тысячных (конвенция 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(() => toDraft(order)) const [saved, setSaved] = useState(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 (

Заказ {order.id} {order.edited && изменён вручную}

Дата {order.create_date_time}
Статус ТТН {order.np_status || 'Нет данных'}
Наложенный платёж {order.np_cod_amount ? `${order.np_cod_amount} ₴` : '—'}
Оплачено {order.np_payment_status === 'Payed' ? 'Да' : order.np_payment_status === 'NeedPayment' ? 'Нет' : '—'}
{TEXT_FIELDS.map(({ field, label, type }) => editable ? ( ) : ( (field !== 'recipient_email' || draft[field]) && (
{label} {draft[field] || '—'}
) ), )}
{editable ? (