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:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user