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