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
+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>
)
}