- All frontend pages, labels, notices and errors; html lang=uk, uk-UA money format - Brand "Assistant System" in the top bar and page title - Backend error details returned to the UI (auth, orders, receipts, cash registers, Checkbox/CRM/NP errors) and CLI output - Tests updated for the new messages Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
418 lines
17 KiB
TypeScript
418 lines
17 KiB
TypeScript
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('uk-UA', { 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">Assistant System</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>
|
||
)
|
||
}
|