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
+10
View File
@@ -0,0 +1,10 @@
import { apiFetch } from '@/api/client'
import type { Order } from '@/features/orders/types'
export function getOrders(hasReceipt: boolean): Promise<Order[]> {
return apiFetch<Order[]>(`/orders?has_receipt=${hasReceipt}`)
}
export function deleteOrder(orderId: string): Promise<void> {
return apiFetch<void>(`/orders/${orderId}`, { method: 'DELETE' })
}
+21
View File
@@ -0,0 +1,21 @@
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
z-index: 100;
}
.modal-content {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
box-shadow: var(--shadow-card);
max-width: 640px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
}
+27
View File
@@ -0,0 +1,27 @@
import { useEffect } from 'react'
import type { ReactNode } from 'react'
import '@/components/Modal.css'
interface ModalProps {
onClose: () => void
children: ReactNode
}
export function Modal({ onClose, children }: ModalProps) {
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [onClose])
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(event) => event.stopPropagation()}>
{children}
</div>
</div>
)
}
@@ -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) })
}
+75
View File
@@ -85,6 +85,32 @@
cursor: not-allowed;
}
.orders-tabs {
display: flex;
gap: 8px;
}
.orders-tab {
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-muted);
border-radius: 999px;
padding: 6px 16px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.orders-tab:hover {
color: var(--color-text);
}
.orders-tab--active {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
.orders-filterbar {
display: flex;
align-items: center;
@@ -198,6 +224,11 @@
color: #166534;
}
.orders-status--approved {
background: #dcfce7;
color: #166534;
}
@media (prefers-color-scheme: dark) {
.orders-status--processing {
background: rgba(217, 119, 6, 0.18);
@@ -213,6 +244,50 @@
background: rgba(22, 163, 74, 0.18);
color: #86efac;
}
.orders-status--approved {
background: rgba(22, 163, 74, 0.18);
color: #86efac;
}
}
.orders-actions-cell {
display: flex;
gap: 8px;
}
.orders-view-btn {
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
border-radius: 6px;
padding: 6px 12px;
font-size: 13px;
cursor: pointer;
}
.orders-view-btn:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.orders-delete-btn {
border: 1px solid var(--color-border);
background: var(--color-surface);
color: #b91c1c;
border-radius: 6px;
padding: 6px 12px;
font-size: 13px;
cursor: pointer;
}
.orders-delete-btn:hover:not(:disabled) {
border-color: #b91c1c;
}
.orders-delete-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.orders-receipt-btn {
+107 -62
View File
@@ -1,7 +1,12 @@
import { useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { deleteOrder } from '@/api/orders'
import '@/pages/DashboardPage.css'
import { useAuth } from '@/features/auth/useAuth'
import { OrderDetailModal } from '@/features/orders/OrderDetailModal'
import type { Order } from '@/features/orders/types'
import { useOrders } from '@/features/orders/useOrders'
const ROLE_LABEL: Record<string, string> = {
admin: 'Администратор',
@@ -9,53 +14,36 @@ const ROLE_LABEL: Record<string, string> = {
viewer: 'Наблюдатель',
}
type OrderStatus = 'processing' | 'new' | 'delivered'
type Tab = 'no_receipt' | 'has_receipt'
const STATUS_LABEL: Record<OrderStatus, string> = {
processing: 'Обработка',
new: 'Новый',
delivered: 'Доставлен',
}
interface OrderRow {
id: string
ttn: string
client: string
totalLabel: string
prepayment: string
status: OrderStatus
fiscalized: boolean
}
/**
* Вёрстка макета из ТЗ — статичные данные, реальная очередь «доставлено, чек
* не пробит» приедет на этапе 3+ вместе с CrmClient/StubCrmClient и заказами
* из БД. Пока не подключаем это к API, чтобы не гадать форму данных CRM.
*/
const MOCK_ORDERS: OrderRow[] = [
{ id: '#ORD-24810', ttn: '20400123456', client: 'Иван Смирнов', totalLabel: '45 990 ₴', prepayment: '', status: 'processing', fiscalized: false },
{ id: '#ORD-24809', ttn: '20400123457', client: 'Ольга Ткаченко', totalLabel: '12 450 ₴', prepayment: '0%', status: 'new', fiscalized: false },
{ id: '#ORD-24808', ttn: '20400123458', client: 'Иван Смирнов', totalLabel: '45 990 ₴', prepayment: '15%', status: 'new', fiscalized: false },
{ id: '#ORD-24807', ttn: '20400123459', client: 'Марія Бondar', totalLabel: '8 200 ₴', prepayment: '20%', status: 'delivered', fiscalized: false },
{ id: '#ORD-24806', ttn: '20400123460', client: 'Петро Коваль', totalLabel: '31 000 ₴', prepayment: '0%', status: 'delivered', fiscalized: false },
{ id: '#ORD-24805', ttn: '20400123461', client: 'Іван Смирнов', totalLabel: '45 990 ₴', prepayment: '0%', status: 'delivered', fiscalized: false },
const TABS: { key: Tab; label: string }[] = [
{ key: 'no_receipt', label: 'Без чека' },
{ key: 'has_receipt', label: 'Выписаны чеки' },
]
export function DashboardPage() {
const { user, logout } = useAuth()
const [tab, setTab] = useState<Tab>('no_receipt')
const { data: orders, isLoading, isError } = useOrders(tab === 'has_receipt')
const queryClient = useQueryClient()
const [selected, setSelected] = useState<Set<string>>(new Set())
const [search, setSearch] = useState('')
const [viewingOrder, setViewingOrder] = useState<Order | null>(null)
const [deletingId, setDeletingId] = useState<string | null>(null)
const canDelete = user?.role !== 'viewer'
const filtered = useMemo(() => {
const query = search.trim().toLowerCase()
if (!query) return MOCK_ORDERS
return MOCK_ORDERS.filter(
const list = orders ?? []
if (!query) return list
return list.filter(
(order) =>
order.id.toLowerCase().includes(query) ||
order.ttn.includes(query) ||
order.client.toLowerCase().includes(query),
(order.waybill_number ?? '').includes(query) ||
(order.recipient_name ?? '').toLowerCase().includes(query),
)
}, [search])
}, [orders, search])
const allSelected = filtered.length > 0 && filtered.every((order) => selected.has(order.id))
@@ -72,6 +60,21 @@ export function DashboardPage() {
})
}
async function handleDelete(order: Order) {
setDeletingId(order.id)
try {
await deleteOrder(order.id)
setSelected((prev) => {
const next = new Set(prev)
next.delete(order.id)
return next
})
await queryClient.invalidateQueries({ queryKey: ['orders'] })
} finally {
setDeletingId(null)
}
}
return (
<div className="dashboard-shell">
<header className="dashboard-topbar">
@@ -93,6 +96,19 @@ export function DashboardPage() {
</button>
</div>
<div className="orders-tabs">
{TABS.map((t) => (
<button
key={t.key}
type="button"
className={`orders-tab${tab === t.key ? ' orders-tab--active' : ''}`}
onClick={() => setTab(t.key)}
>
{t.label}
</button>
))}
</div>
<div className="orders-filterbar">
<span className="orders-selected-count">Выбрано: {selected.size} заказов</span>
<input
@@ -124,36 +140,63 @@ export function DashboardPage() {
</tr>
</thead>
<tbody>
{filtered.map((order) => (
<tr key={order.id}>
<td>
<input
type="checkbox"
checked={selected.has(order.id)}
onChange={() => toggleOne(order.id)}
aria-label={`Выбрать заказ ${order.id}`}
/>
</td>
<td>{order.id}</td>
<td>{order.ttn}</td>
<td>{order.client}</td>
<td>{order.totalLabel}</td>
<td>
<input type="text" className="orders-prepayment-input" defaultValue={order.prepayment} placeholder="0%" />
</td>
<td>
<span className={`orders-status orders-status--${order.status}`}>
{STATUS_LABEL[order.status]}
</span>
</td>
<td>
<button type="button" className="orders-receipt-btn" disabled={order.status !== 'delivered'}>
Чек
</button>
{isLoading && (
<tr>
<td colSpan={8} className="orders-empty">
Загрузка…
</td>
</tr>
))}
{filtered.length === 0 && (
)}
{isError && (
<tr>
<td colSpan={8} className="orders-empty">
Не удалось загрузить заказы из CRM
</td>
</tr>
)}
{!isLoading &&
!isError &&
filtered.map((order) => (
<tr key={order.id}>
<td>
<input
type="checkbox"
checked={selected.has(order.id)}
onChange={() => toggleOne(order.id)}
aria-label={`Выбрать заказ ${order.id}`}
/>
</td>
<td>{order.id}</td>
<td>{order.waybill_number || '—'}</td>
<td>{order.recipient_name || '—'}</td>
<td>{order.total_amount} ₴</td>
<td>
<input type="text" className="orders-prepayment-input" placeholder="0%" />
</td>
<td>
<span className="orders-status orders-status--approved">Одобрен</span>
</td>
<td className="orders-actions-cell">
<button type="button" className="orders-view-btn" onClick={() => setViewingOrder(order)}>
Просмотр
</button>
<button type="button" className="orders-receipt-btn" disabled>
Чек
</button>
{canDelete && (
<button
type="button"
className="orders-delete-btn"
disabled={deletingId === order.id}
onClick={() => void handleDelete(order)}
>
Удалить
</button>
)}
</td>
</tr>
))}
{!isLoading && !isError && filtered.length === 0 && (
<tr>
<td colSpan={8} className="orders-empty">
Ничего не найдено
@@ -164,6 +207,8 @@ export function DashboardPage() {
</table>
</div>
</main>
{viewingOrder && <OrderDetailModal order={viewingOrder} onClose={() => setViewingOrder(null)} />}
</div>
)
}