Add CRM order queue: live sync, view modal, receipt tabs, delete

Wires the CRM (exoCRM GetOrders) into the dashboard as a locally
persisted order queue instead of the previous static mockup:

- CrmClient Protocol + ExoCrmClient/StubCrmClient for the CRM's
  signed JSON-RPC API
- Order model + migration, synced from CRM on each queue view;
  soft-deleted orders stay hidden across re-syncs
- GET/DELETE /api/v1/orders with "no receipt"/"receipt issued" tabs
  (the latter is empty until Checkbox fiscalization lands)
- Dashboard: real order list, item-detail modal, tab switcher,
  one-click delete

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-22 21:39:03 +03:00
co-authored by Claude Sonnet 5
parent ef55689295
commit f4072be451
30 changed files with 1326 additions and 82 deletions
@@ -0,0 +1,113 @@
.order-modal {
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.order-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.order-modal-header h3 {
margin: 0;
font-size: 17px;
}
.order-modal-close {
border: none;
background: none;
color: var(--color-text-muted);
font-size: 22px;
line-height: 1;
cursor: pointer;
padding: 4px;
}
.order-modal-close:hover {
color: var(--color-text);
}
.order-modal-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 20px;
font-size: 14px;
}
.order-modal-meta div {
display: flex;
flex-direction: column;
gap: 2px;
}
.order-modal-label {
font-size: 11px;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.order-modal-notes p {
margin: 4px 0 0;
font-size: 13px;
white-space: pre-wrap;
color: var(--color-text-muted);
}
.order-modal-goods {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.order-modal-goods th,
.order-modal-goods td {
padding: 8px 10px;
text-align: left;
border-bottom: 1px solid var(--color-border);
}
.order-modal-goods th {
color: var(--color-text-muted);
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.order-modal-goods-empty {
text-align: center;
color: var(--color-text-muted);
padding: 16px 0;
}
.order-modal-total {
display: flex;
justify-content: flex-end;
gap: 12px;
font-size: 16px;
font-weight: 700;
}
.order-modal-actions {
display: flex;
justify-content: flex-end;
}
.order-modal-close-btn {
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
border-radius: 8px;
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
}
.order-modal-close-btn:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
@@ -0,0 +1,98 @@
import { Modal } from '@/components/Modal'
import '@/features/orders/OrderDetailModal.css'
import type { Order } from '@/features/orders/types'
interface OrderDetailModalProps {
order: Order
onClose: () => void
}
export function OrderDetailModal({ order, onClose }: OrderDetailModalProps) {
return (
<Modal onClose={onClose}>
<div className="order-modal">
<div className="order-modal-header">
<h3>Просмотр заказа {order.id}</h3>
<button type="button" className="order-modal-close" onClick={onClose} 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.waybill_number || '—'}</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>
)}
</div>
{order.notes && (
<div className="order-modal-notes">
<span className="order-modal-label">Заметки</span>
<p>{order.notes}</p>
</div>
)}
<table className="order-modal-goods">
<thead>
<tr>
<th>Наименование</th>
<th>SKU</th>
<th>Цена</th>
<th>Кол-во</th>
<th>Скидка</th>
<th>Сумма</th>
</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 && (
<tr>
<td colSpan={6} className="order-modal-goods-empty">
Товары не указаны
</td>
</tr>
)}
</tbody>
</table>
<div className="order-modal-total">
<span>Итого</span>
<span>{order.total_amount} ₴</span>
</div>
<div className="order-modal-actions">
<button type="button" className="order-modal-close-btn" onClick={onClose}>
Закрыть
</button>
</div>
</div>
</Modal>
)
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Типы, зеркалящие backend/app/schemas/orders.py (`OrderRowOut`/`OrderGoodOut`).
* Меняются синхронно с ними вручную — см. пояснение в @/api/types.ts.
*/
export interface OrderGood {
id: string
sku: string
name: string
price: string
quantity: string
discount_amount: string | null
discount_percent: string | null
amount: string
}
export interface Order {
id: string
create_date_time: string
recipient_name: string | null
recipient_phone: string | null
recipient_email: string | null
waybill_number: string | null
notes: string | null
total_amount: string
goods: OrderGood[]
has_receipt: boolean
}
@@ -0,0 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { getOrders } from '@/api/orders'
export function useOrders(hasReceipt: boolean) {
return useQuery({ queryKey: ['orders', hasReceipt], queryFn: () => getOrders(hasReceipt) })
}