Add Nova Poshta tracking: TTN status, COD amount, payment status

Adds NpTrackingClient (Protocol + real/stub impls) and an ARQ worker that
polls Nova Poshta every minute for orders without a receipt, writing
status, net COD amount (Контроль оплати), and payment status onto the
order. Surfaced in the orders table and detail modal. Marks plan stages
3-4 done in README.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 22:33:58 +03:00
co-authored by Claude Sonnet 5
parent f4072be451
commit d13e7ce2b3
20 changed files with 496 additions and 14 deletions
@@ -27,6 +27,24 @@ export function OrderDetailModal({ order, onClose }: OrderDetailModalProps) {
<span className="order-modal-label">Номер ТТН</span>
<span>{order.waybill_number || '—'}</span>
</div>
<div>
<span className="order-modal-label">Статус ТТН</span>
<span>{order.np_status || 'Нет данных'}</span>
</div>
<div>
<span className="order-modal-label">Наложенный платёж</span>
<span>{order.np_cod_amount ? `${order.np_cod_amount} ₴` : '—'}</span>
</div>
<div>
<span className="order-modal-label">Оплачено</span>
<span>
{order.np_payment_status === 'Payed'
? 'Да'
: order.np_payment_status === 'NeedPayment'
? 'Нет'
: '—'}
</span>
</div>
<div>
<span className="order-modal-label">Клиент</span>
<span>{order.recipient_name || '—'}</span>
+4
View File
@@ -25,4 +25,8 @@ export interface Order {
total_amount: string
goods: OrderGood[]
has_receipt: boolean
np_status: string | null
np_status_code: string | null
np_cod_amount: string | null
np_payment_status: string | null
}
+10
View File
@@ -229,6 +229,11 @@
color: #166534;
}
.orders-status--danger {
background: #fee2e2;
color: #991b1b;
}
@media (prefers-color-scheme: dark) {
.orders-status--processing {
background: rgba(217, 119, 6, 0.18);
@@ -249,6 +254,11 @@
background: rgba(22, 163, 74, 0.18);
color: #86efac;
}
.orders-status--danger {
background: rgba(220, 38, 38, 0.18);
color: #fca5a5;
}
}
.orders-actions-cell {
+36 -7
View File
@@ -21,6 +21,20 @@ const TABS: { key: Tab; label: string }[] = [
{ key: 'has_receipt', label: 'Выписаны чеки' },
]
function npStatusTone(order: Order): 'delivered' | 'processing' | 'danger' | 'new' {
const text = (order.np_status ?? '').toLowerCase()
if (text.includes('видано') || text.includes('отримано')) return 'delivered'
if (text.includes('відмова') || text.includes('не забра') || text.includes('поверн')) return 'danger'
if (text) return 'processing'
return 'new'
}
function paymentBadge(order: Order): { label: string; tone: 'delivered' | 'processing' } | null {
if (order.np_payment_status === 'Payed') return { label: 'Оплачено', tone: 'delivered' }
if (order.np_payment_status === 'NeedPayment') return { label: 'Не оплачено', tone: 'processing' }
return null
}
export function DashboardPage() {
const { user, logout } = useAuth()
const [tab, setTab] = useState<Tab>('no_receipt')
@@ -134,29 +148,33 @@ export function DashboardPage() {
<th>Номер ТТН</th>
<th>Клиент</th>
<th>Сумма</th>
<th>Наложка</th>
<th>Оплачено</th>
<th>Предоплата</th>
<th>Статус</th>
<th>Статус ТТН</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={8} className="orders-empty">
<td colSpan={10} className="orders-empty">
Загрузка…
</td>
</tr>
)}
{isError && (
<tr>
<td colSpan={8} className="orders-empty">
<td colSpan={10} className="orders-empty">
Не удалось загрузить заказы из CRM
</td>
</tr>
)}
{!isLoading &&
!isError &&
filtered.map((order) => (
filtered.map((order) => {
const badge = paymentBadge(order)
return (
<tr key={order.id}>
<td>
<input
@@ -170,11 +188,21 @@ export function DashboardPage() {
<td>{order.waybill_number || '—'}</td>
<td>{order.recipient_name || '—'}</td>
<td>{order.total_amount} ₴</td>
<td>{order.np_cod_amount ? `${order.np_cod_amount} ₴` : '—'}</td>
<td>
{badge ? (
<span className={`orders-status orders-status--${badge.tone}`}>{badge.label}</span>
) : (
'—'
)}
</td>
<td>
<input type="text" className="orders-prepayment-input" placeholder="0%" />
</td>
<td>
<span className="orders-status orders-status--approved">Одобрен</span>
<span className={`orders-status orders-status--${npStatusTone(order)}`}>
{order.np_status || 'Нет данных'}
</span>
</td>
<td className="orders-actions-cell">
<button type="button" className="orders-view-btn" onClick={() => setViewingOrder(order)}>
@@ -195,10 +223,11 @@ export function DashboardPage() {
)}
</td>
</tr>
))}
)
})}
{!isLoading && !isError && filtered.length === 0 && (
<tr>
<td colSpan={8} className="orders-empty">
<td colSpan={10} className="orders-empty">
Ничего не найдено
</td>
</tr>