Allow editing orders in the order card
Cashiers can edit recipient, TTN, notes, goods and total in the order
modal and save via PATCH /orders/{id}. Edited orders get edited_at and
are no longer overwritten by CRM sync. Editing is blocked once a receipt
exists; changing the TTN resets Nova Poshta tracking fields.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,20 +1,226 @@
|
||||
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 } from '@/features/orders/types'
|
||||
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
|
||||
}
|
||||
|
||||
export function OrderDetailModal({ order, onClose }: OrderDetailModalProps) {
|
||||
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={onClose}>
|
||||
<Modal onClose={requestClose} wide={editable}>
|
||||
<div className="order-modal">
|
||||
<div className="order-modal-header">
|
||||
<h3>Просмотр заказа {order.id}</h3>
|
||||
<button type="button" className="order-modal-close" onClick={onClose} aria-label="Закрыть">
|
||||
<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>
|
||||
@@ -24,10 +230,6 @@ export function OrderDetailModal({ order, onClose }: OrderDetailModalProps) {
|
||||
<span className="order-modal-label">Дата</span>
|
||||
<span>{order.create_date_time}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="order-modal-label">Номер ТТН</span>
|
||||
<span>{order.waybill_number || '—'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="order-modal-label">Статус ТТН</span>
|
||||
<span>{order.np_status || 'Нет данных'}</span>
|
||||
@@ -46,27 +248,45 @@ export function OrderDetailModal({ order, onClose }: OrderDetailModalProps) {
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="order-modal-label">Клиент</span>
|
||||
<span>{order.recipient_name || '—'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="order-modal-label">Телефон</span>
|
||||
<span>{order.recipient_phone || '—'}</span>
|
||||
</div>
|
||||
{order.recipient_email && (
|
||||
<div>
|
||||
<span className="order-modal-label">Email</span>
|
||||
<span>{order.recipient_email}</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>
|
||||
|
||||
{order.notes && (
|
||||
<div className="order-modal-notes">
|
||||
{editable ? (
|
||||
<label className="order-modal-notes">
|
||||
<span className="order-modal-label">Заметки</span>
|
||||
<p>{order.notes}</p>
|
||||
</div>
|
||||
<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">
|
||||
@@ -78,22 +298,71 @@ export function OrderDetailModal({ order, onClose }: OrderDetailModalProps) {
|
||||
<th>Кол-во</th>
|
||||
<th>Скидка</th>
|
||||
<th>Сумма</th>
|
||||
{editable && <th aria-label="Удалить" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{order.goods.map((good) => (
|
||||
<tr key={good.id}>
|
||||
<td>{good.name}</td>
|
||||
<td>{good.sku}</td>
|
||||
<td>{good.price}</td>
|
||||
<td>{good.quantity}</td>
|
||||
<td>{good.discount_amount && good.discount_amount !== '0.00' ? good.discount_amount : '—'}</td>
|
||||
<td>{good.amount}</td>
|
||||
</tr>
|
||||
))}
|
||||
{order.goods.length === 0 && (
|
||||
{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={6} className="order-modal-goods-empty">
|
||||
<td colSpan={editable ? 7 : 6} className="order-modal-goods-empty">
|
||||
Товары не указаны
|
||||
</td>
|
||||
</tr>
|
||||
@@ -101,9 +370,28 @@ export function OrderDetailModal({ order, onClose }: OrderDetailModalProps) {
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{editable && (
|
||||
<button type="button" className="order-modal-add" onClick={addGood}>
|
||||
+ Добавить товар
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="order-modal-total">
|
||||
<span>Итого</span>
|
||||
<span>{order.total_amount} ₴</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 && (
|
||||
@@ -127,8 +415,20 @@ export function OrderDetailModal({ order, onClose }: OrderDetailModalProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && <div className={`order-modal-message order-modal-message--${message.tone}`}>{message.text}</div>}
|
||||
|
||||
<div className="order-modal-actions">
|
||||
<button type="button" className="order-modal-close-btn" onClick={onClose}>
|
||||
{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>
|
||||
|
||||
Reference in New Issue
Block a user