Files
lux_fiscal/frontend/src/pages/DashboardPage.tsx
T
lauadminandClaude Opus 5.5 a6072800b2 Add COD-in-transit summary and received tab to the dashboard
- GET /orders/summary: count and sum of cash-on-delivery for parcels not
  yet picked up (excludes received, refused, deleted/unknown TTNs, paid COD);
  shown as a card above the tabs.
- New «Полученные» tab: orders with NP received codes (9, 10, 11, 106) move
  there automatically, regardless of receipt; 106 is now a final status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 01:23:39 +03:00

418 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { deleteOrder } from '@/api/orders'
import { cancelReceipt, createReceipts } from '@/api/receipts'
import '@/pages/DashboardPage.css'
import { useAuth } from '@/features/auth/useAuth'
import { OrderDetailModal } from '@/features/orders/OrderDetailModal'
import type { Order, OrderTab } from '@/features/orders/types'
import { useOrders, useOrdersSummary } from '@/features/orders/useOrders'
import { defaultPrepayment, prepaymentMatches, toKopecks } from '@/features/receipts/money'
import { CANCELLABLE, RECEIPT_STATUS } from '@/features/receipts/types'
import type { ReceiptRequestItem } from '@/features/receipts/types'
const ROLE_LABEL: Record<string, string> = {
admin: 'Администратор',
cashier: 'Кассир',
viewer: 'Наблюдатель',
}
const TABS: { key: OrderTab; label: string }[] = [
{ key: 'no_receipt', label: 'Без чека' },
{ key: 'has_receipt', label: 'Выписаны чеки' },
{ key: 'received', label: 'Полученные' },
{ key: 'refused', 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'
}
/** Заказ можно отправить в Checkbox: есть ТТН и сумма контроля оплаты. */
function canCreateReceipt(order: Order): boolean {
return Boolean(order.waybill_number && order.np_cod_amount)
}
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
}
const MONEY_FORMAT = new Intl.NumberFormat('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
function formatMoney(amount: string): string {
return MONEY_FORMAT.format(Number(amount))
}
export function DashboardPage() {
const { user, logout } = useAuth()
const [tab, setTab] = useState<OrderTab>('no_receipt')
const { data: orders, isLoading, isError } = useOrders(tab)
const { data: summary } = useOrdersSummary()
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 [prepayments, setPrepayments] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [cancellingId, setCancellingId] = useState<string | null>(null)
const [notice, setNotice] = useState<{ tone: 'ok' | 'error'; lines: string[] } | null>(null)
const canDelete = user?.role !== 'viewer'
const canFiscalize = user?.role === 'admin' || user?.role === 'cashier'
function prepaymentOf(order: Order): string {
return prepayments[order.id] ?? defaultPrepayment(order.total_amount, order.np_cod_amount)
}
async function submitReceipts(targets: Order[]) {
const items: ReceiptRequestItem[] = []
const localErrors: string[] = []
for (const order of targets) {
const prepayment = toKopecks(prepaymentOf(order))
if (prepayment === null) {
localErrors.push(`${order.id}: некорректная сумма предоплаты`)
continue
}
items.push({ order_id: order.id, prepayment_kopecks: prepayment })
}
if (items.length === 0) {
setNotice({ tone: 'error', lines: localErrors })
return
}
setSubmitting(true)
try {
const result = await createReceipts(items)
const errors = [...localErrors, ...Object.entries(result.errors).map(([id, msg]) => `${id}: ${msg}`)]
const lines = [
...(result.created.length ? [`Отправлено в Checkbox: ${result.created.length}`] : []),
...errors,
]
setNotice({ tone: errors.length ? 'error' : 'ok', lines })
setSelected(new Set())
await queryClient.invalidateQueries({ queryKey: ['orders'] })
} catch (err) {
setNotice({ tone: 'error', lines: [err instanceof Error ? err.message : 'Не удалось создать чеки'] })
} finally {
setSubmitting(false)
}
}
async function handleCancel(order: Order) {
if (!order.receipt_id) return
const outcome = {
no_receipt: 'Заказ вернётся в очередь.',
has_receipt: 'Заказ вернётся в очередь.',
received: 'Заказ останется в полученных.',
refused: 'Заказ останется в отказах.',
}[tab]
if (!window.confirm(`Отменить ЕТТН-чек по заказу ${order.id}? ${outcome}`)) return
setCancellingId(order.id)
try {
await cancelReceipt(order.receipt_id)
setNotice({ tone: 'ok', lines: [`Чек по заказу ${order.id} отменён`] })
await queryClient.invalidateQueries({ queryKey: ['orders'] })
} catch (err) {
setNotice({ tone: 'error', lines: [err instanceof Error ? err.message : 'Не удалось отменить чек'] })
} finally {
setCancellingId(null)
}
}
const filtered = useMemo(() => {
const query = search.trim().toLowerCase()
const list = orders ?? []
if (!query) return list
return list.filter(
(order) =>
order.id.toLowerCase().includes(query) ||
(order.waybill_number ?? '').includes(query) ||
(order.recipient_name ?? '').toLowerCase().includes(query),
)
}, [orders, search])
const allSelected = filtered.length > 0 && filtered.every((order) => selected.has(order.id))
function toggleAll() {
setSelected(allSelected ? new Set() : new Set(filtered.map((order) => order.id)))
}
function toggleOne(id: string) {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
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">
<span className="dashboard-brand">lux_fiscal</span>
<div className="dashboard-user">
<span>{user?.full_name}</span>
<span className="dashboard-role">{user ? (ROLE_LABEL[user.role] ?? user.role) : ''}</span>
<button type="button" className="dashboard-logout" onClick={() => void logout()}>
Выйти
</button>
</div>
</header>
<main className="orders-body">
<div className="orders-toolbar">
<h2>Заказы</h2>
<div className="orders-toolbar-actions">
{user?.role === 'admin' && (
<Link to="/cash-registers" className="orders-link-btn">
Кассы
</Link>
)}
{tab === 'no_receipt' && canFiscalize && (
<button
type="button"
className="orders-create-btn"
disabled={selected.size === 0 || submitting}
onClick={() => void submitReceipts(filtered.filter((order) => selected.has(order.id)))}
>
Создать чеки по выбранным ({selected.size})
</button>
)}
</div>
</div>
<div className="orders-summary">
<div className="orders-summary-card" title="Посылки с наложкой, которые ещё не забрали и по которым нет отказа">
<span className="orders-summary-label">Наложка в пути</span>
<span className="orders-summary-value">
{summary ? `${formatMoney(summary.cod_in_transit_amount)} ₴` : '—'}
</span>
<span className="orders-summary-hint">
{summary ? `${summary.cod_in_transit_count} посылок` : ' '}
</span>
</div>
</div>
{notice && (
<div className={`orders-notice orders-notice--${notice.tone}`}>
<ul>
{notice.lines.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
<button type="button" className="orders-notice-close" onClick={() => setNotice(null)} aria-label="Скрыть">
×
</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)
setSelected(new Set())
}}
>
{t.label}
</button>
))}
</div>
<div className="orders-filterbar">
<span className="orders-selected-count">Выбрано: {selected.size} заказов</span>
<input
type="search"
className="orders-search"
placeholder="Поиск по ID, ТТН, клиенту"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<button type="button" className="orders-filter-btn">
Фильтр
</button>
</div>
<div className="orders-table-wrap">
<table className="orders-table">
<thead>
<tr>
<th className="orders-checkbox-col">
<input type="checkbox" checked={allSelected} onChange={toggleAll} aria-label="Выбрать всё" />
</th>
<th>ID заказа</th>
<th>Номер ТТН</th>
<th>Клиент</th>
<th>Сумма</th>
<th>Наложка</th>
<th>Оплачено</th>
<th>Предоплата, ₴</th>
<th>Статус ТТН</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={10} className="orders-empty">
Загрузка…
</td>
</tr>
)}
{isError && (
<tr>
<td colSpan={10} className="orders-empty">
Не удалось загрузить заказы из CRM
</td>
</tr>
)}
{!isLoading &&
!isError &&
filtered.map((order) => {
const badge = paymentBadge(order)
return (
<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>{order.np_cod_amount ? `${order.np_cod_amount} ₴` : '—'}</td>
<td>
{badge ? (
<span className={`orders-status orders-status--${badge.tone}`}>{badge.label}</span>
) : (
'—'
)}
</td>
<td>
{tab === 'no_receipt' ? (
<input
type="text"
inputMode="decimal"
className={`orders-prepayment-input${
canCreateReceipt(order) &&
!prepaymentMatches(order.total_amount, order.np_cod_amount, prepaymentOf(order))
? ' orders-prepayment-input--invalid'
: ''
}`}
placeholder="0.00"
value={prepaymentOf(order)}
disabled={!canFiscalize}
title="Сумма заказа − предоплата должна равняться наложке"
onChange={(e) => setPrepayments((prev) => ({ ...prev, [order.id]: e.target.value }))}
/>
) : (
(order.receipt_prepayment ?? '—')
)}
</td>
<td>
<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)}>
Просмотр
</button>
{tab === 'no_receipt' && canFiscalize && (
<button
type="button"
className="orders-receipt-btn"
disabled={!canCreateReceipt(order) || submitting}
title={canCreateReceipt(order) ? 'Создать ЕТТН-чек в Checkbox' : 'Нет ТТН или наложки'}
onClick={() => void submitReceipts([order])}
>
Чек
</button>
)}
{order.receipt_status && (tab !== 'no_receipt' || !CANCELLABLE.has(order.receipt_status)) && (
<span
className={`orders-status orders-status--${RECEIPT_STATUS[order.receipt_status].tone}`}
title={order.receipt_error ?? undefined}
>
{RECEIPT_STATUS[order.receipt_status].label}
</span>
)}
{tab !== 'no_receipt' && canFiscalize && order.receipt_status && CANCELLABLE.has(order.receipt_status) && (
<button
type="button"
className="orders-delete-btn"
disabled={cancellingId === order.id}
onClick={() => void handleCancel(order)}
>
Отменить
</button>
)}
{canDelete && (tab === 'no_receipt' || ((tab === 'refused' || tab === 'received') && !order.has_receipt)) && (
<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={10} className="orders-empty">
Ничего не найдено
</td>
</tr>
)}
</tbody>
</table>
</div>
</main>
{viewingOrder && (
<OrderDetailModal
order={orders?.find((order) => order.id === viewingOrder.id) ?? viewingOrder}
canEdit={canFiscalize && tab === 'no_receipt'}
onClose={() => setViewingOrder(null)}
/>
)}
</div>
)
}