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:
@@ -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' })
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
@@ -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