Add Checkbox ETTN receipts for Nova Poshta COD waybills
Cashier creates an ETTN receipt in Checkbox bound to the TTN with payment control; Checkbox fiscalizes it itself when the parcel is paid for. - cash_registers (Fernet-encrypted license key / PIN) and receipts tables - Checkbox HTTP client + stub (ETTN does not work on test registers) - two-phase create via ARQ job, timeout reconciliation, cron status polling - /receipts and /cash-registers API, audit records - dashboard: per-order and bulk create, prepayment, cancel; cash registers page Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import { apiFetch } from '@/api/client'
|
||||
import type {
|
||||
CashRegister,
|
||||
CashRegisterCreate,
|
||||
CashRegisterUpdate,
|
||||
} from '@/features/cashRegisters/types'
|
||||
|
||||
export function getCashRegisters(): Promise<CashRegister[]> {
|
||||
return apiFetch<CashRegister[]>('/cash-registers')
|
||||
}
|
||||
|
||||
export function createCashRegister(payload: CashRegisterCreate): Promise<CashRegister> {
|
||||
return apiFetch<CashRegister>('/cash-registers', { method: 'POST', body: payload })
|
||||
}
|
||||
|
||||
export function updateCashRegister(id: string, payload: CashRegisterUpdate): Promise<CashRegister> {
|
||||
return apiFetch<CashRegister>(`/cash-registers/${id}`, { method: 'PATCH', body: payload })
|
||||
}
|
||||
|
||||
export function checkCashRegister(id: string): Promise<void> {
|
||||
return apiFetch<void>(`/cash-registers/${id}/check`, { method: 'POST' })
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { apiFetch } from '@/api/client'
|
||||
import type { Receipt, ReceiptCreateResponse, ReceiptRequestItem } from '@/features/receipts/types'
|
||||
|
||||
export function createReceipts(items: ReceiptRequestItem[]): Promise<ReceiptCreateResponse> {
|
||||
return apiFetch<ReceiptCreateResponse>('/receipts', { method: 'POST', body: { items } })
|
||||
}
|
||||
|
||||
export function cancelReceipt(receiptId: string): Promise<Receipt> {
|
||||
return apiFetch<Receipt>(`/receipts/${receiptId}/cancel`, { method: 'POST' })
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
|
||||
import { LoginPage } from '@/features/auth/LoginPage'
|
||||
import { ProtectedRoute } from '@/features/auth/ProtectedRoute'
|
||||
import { CashRegistersPage } from '@/pages/CashRegistersPage'
|
||||
import { DashboardPage } from '@/pages/DashboardPage'
|
||||
|
||||
export function AppRoutes() {
|
||||
@@ -11,6 +12,7 @@ export function AppRoutes() {
|
||||
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/cash-registers" element={<CashRegistersPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Зеркало `CashRegisterOut`/`CashRegisterCreate`/`CashRegisterUpdate` из backend/app/schemas/receipts.py. */
|
||||
|
||||
export interface CashRegister {
|
||||
id: string
|
||||
name: string
|
||||
fiscal_number: string | null
|
||||
license_key_masked: string
|
||||
tax_codes: (number | string)[]
|
||||
is_active: boolean
|
||||
is_default: boolean
|
||||
}
|
||||
|
||||
export interface CashRegisterCreate {
|
||||
name: string
|
||||
fiscal_number: string | null
|
||||
license_key: string
|
||||
pin_code: string
|
||||
tax_codes: (number | string)[]
|
||||
is_default: boolean
|
||||
}
|
||||
|
||||
export type CashRegisterUpdate = Partial<CashRegisterCreate> & { is_active?: boolean }
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Modal } from '@/components/Modal'
|
||||
import '@/features/orders/OrderDetailModal.css'
|
||||
import type { Order } from '@/features/orders/types'
|
||||
import { RECEIPT_STATUS } from '@/features/receipts/types'
|
||||
|
||||
interface OrderDetailModalProps {
|
||||
order: Order
|
||||
@@ -105,6 +106,27 @@ export function OrderDetailModal({ order, onClose }: OrderDetailModalProps) {
|
||||
<span>{order.total_amount} ₴</span>
|
||||
</div>
|
||||
|
||||
{order.receipt_status && (
|
||||
<div className="order-modal-meta">
|
||||
<div>
|
||||
<span className="order-modal-label">ЕТТН-чек Checkbox</span>
|
||||
<span className={`orders-status orders-status--${RECEIPT_STATUS[order.receipt_status].tone}`}>
|
||||
{RECEIPT_STATUS[order.receipt_status].label}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="order-modal-label">Предоплата в чеке</span>
|
||||
<span>{order.receipt_prepayment ? `${order.receipt_prepayment} ₴` : '—'}</span>
|
||||
</div>
|
||||
{order.receipt_error && (
|
||||
<div>
|
||||
<span className="order-modal-label">Ошибка</span>
|
||||
<span>{order.receipt_error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="order-modal-actions">
|
||||
<button type="button" className="order-modal-close-btn" onClick={onClose}>
|
||||
Закрыть
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* Меняются синхронно с ними вручную — см. пояснение в @/api/types.ts.
|
||||
*/
|
||||
|
||||
import type { ReceiptStatus } from '@/features/receipts/types'
|
||||
|
||||
export interface OrderGood {
|
||||
id: string
|
||||
sku: string
|
||||
@@ -29,4 +31,9 @@ export interface Order {
|
||||
np_status_code: string | null
|
||||
np_cod_amount: string | null
|
||||
np_payment_status: string | null
|
||||
// Последний ЕТТН-чек по заказу (в т.ч. отменённый/неудачный — для показа причины).
|
||||
receipt_id: string | null
|
||||
receipt_status: ReceiptStatus | null
|
||||
receipt_error: string | null
|
||||
receipt_prepayment: string | null
|
||||
}
|
||||
|
||||
@@ -2,6 +2,17 @@ import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { getOrders } from '@/api/orders'
|
||||
|
||||
/** Пока чек отправляется в Checkbox — опрашиваем часто, иначе статусы чеков обновляет worker раз в минуту. */
|
||||
const PENDING_POLL_MS = 3_000
|
||||
const RECEIPTS_POLL_MS = 30_000
|
||||
|
||||
export function useOrders(hasReceipt: boolean) {
|
||||
return useQuery({ queryKey: ['orders', hasReceipt], queryFn: () => getOrders(hasReceipt) })
|
||||
return useQuery({
|
||||
queryKey: ['orders', hasReceipt],
|
||||
queryFn: () => getOrders(hasReceipt),
|
||||
refetchInterval: (query) => {
|
||||
if (query.state.data?.some((order) => order.receipt_status === 'pending')) return PENDING_POLL_MS
|
||||
return hasReceipt ? RECEIPTS_POLL_MS : false
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/** Деньги на фронте приходят строками "1200.00"; в API чеков уходят integer-копейками. */
|
||||
|
||||
export function toKopecks(value: string): number | null {
|
||||
const normalized = value.trim().replace(/\s/g, '').replace(',', '.')
|
||||
if (normalized === '') return 0
|
||||
if (!/^\d+(\.\d{1,2})?$/.test(normalized)) return null
|
||||
return Math.round(Number(normalized) * 100)
|
||||
}
|
||||
|
||||
export function formatKopecks(kopecks: number): string {
|
||||
return (kopecks / 100).toFixed(2)
|
||||
}
|
||||
|
||||
/** Предоплата по умолчанию: сумма заказа − наложка НП (как считает бэкенд). */
|
||||
export function defaultPrepayment(total: string, cod: string | null): string {
|
||||
if (!cod) return ''
|
||||
const diff = (toKopecks(total) ?? 0) - (toKopecks(cod) ?? 0)
|
||||
return diff > 0 ? formatKopecks(diff) : '0.00'
|
||||
}
|
||||
|
||||
/** Проверка инварианта ЕТТН-чека: сумма − предоплата = наложка. */
|
||||
export function prepaymentMatches(total: string, cod: string | null, prepayment: string): boolean {
|
||||
if (!cod) return false
|
||||
const prep = toKopecks(prepayment)
|
||||
if (prep === null) return false
|
||||
return (toKopecks(total) ?? 0) - prep === toKopecks(cod)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Типы, зеркалящие backend/app/schemas/receipts.py и `ReceiptStatus`
|
||||
* (backend/app/db/models/receipt.py). Меняются синхронно с ними вручную.
|
||||
*/
|
||||
|
||||
export type ReceiptStatus =
|
||||
| 'pending'
|
||||
| 'created'
|
||||
| 'done'
|
||||
| 'returned'
|
||||
| 'receipt_error'
|
||||
| 'cancelled'
|
||||
| 'failed'
|
||||
|
||||
export interface Receipt {
|
||||
id: string
|
||||
order_id: string
|
||||
waybill_number: string
|
||||
status: ReceiptStatus
|
||||
total_amount: string
|
||||
prepayment_amount: string
|
||||
cod_amount: string
|
||||
checkbox_ettn_id: string | null
|
||||
checkbox_status: string | null
|
||||
checkbox_receipt_id: string | null
|
||||
error: string | null
|
||||
created_at: string
|
||||
last_checked_at: string | null
|
||||
}
|
||||
|
||||
export interface ReceiptRequestItem {
|
||||
order_id: string
|
||||
/** null — предоплата = сумма заказа − наложка (считает бэкенд). */
|
||||
prepayment_kopecks: number | null
|
||||
}
|
||||
|
||||
export interface ReceiptCreateResponse {
|
||||
created: Receipt[]
|
||||
/** order_id → причина, по которой чек не создан. */
|
||||
errors: Record<string, string>
|
||||
}
|
||||
|
||||
export type Tone = 'delivered' | 'processing' | 'danger' | 'new'
|
||||
|
||||
export const RECEIPT_STATUS: Record<ReceiptStatus, { label: string; tone: Tone }> = {
|
||||
pending: { label: 'Отправляется', tone: 'new' },
|
||||
created: { label: 'Ждёт оплаты', tone: 'processing' },
|
||||
done: { label: 'Фискализирован', tone: 'delivered' },
|
||||
returned: { label: 'Возврат посылки', tone: 'danger' },
|
||||
receipt_error: { label: 'Ошибка фискализации', tone: 'danger' },
|
||||
cancelled: { label: 'Отменён', tone: 'new' },
|
||||
failed: { label: 'Не создан', tone: 'danger' },
|
||||
}
|
||||
|
||||
/** Статусы, из которых чек можно отменить (см. services/receipts.cancel_receipt). */
|
||||
export const CANCELLABLE: ReadonlySet<ReceiptStatus> = new Set(['created', 'receipt_error'])
|
||||
@@ -0,0 +1,46 @@
|
||||
.cr-default {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.cr-form {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
max-width: 480px;
|
||||
margin-top: 24px;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.cr-form h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cr-form label {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.cr-form input:not([type='checkbox']) {
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cr-form .cr-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.cr-form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { Link, Navigate } from 'react-router-dom'
|
||||
|
||||
import {
|
||||
checkCashRegister,
|
||||
createCashRegister,
|
||||
getCashRegisters,
|
||||
updateCashRegister,
|
||||
} from '@/api/cashRegisters'
|
||||
import '@/pages/DashboardPage.css'
|
||||
import '@/pages/CashRegistersPage.css'
|
||||
import { useAuth } from '@/features/auth/useAuth'
|
||||
import type { CashRegister, CashRegisterUpdate } from '@/features/cashRegisters/types'
|
||||
|
||||
interface FormState {
|
||||
name: string
|
||||
fiscal_number: string
|
||||
license_key: string
|
||||
pin_code: string
|
||||
tax_codes: string
|
||||
is_default: boolean
|
||||
}
|
||||
|
||||
const EMPTY_FORM: FormState = {
|
||||
name: '',
|
||||
fiscal_number: '',
|
||||
license_key: '',
|
||||
pin_code: '',
|
||||
tax_codes: '',
|
||||
is_default: true,
|
||||
}
|
||||
|
||||
function parseTaxCodes(value: string): (number | string)[] {
|
||||
return value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.map((item) => (/^\d+$/.test(item) ? Number(item) : item))
|
||||
}
|
||||
|
||||
export function CashRegistersPage() {
|
||||
const { user } = useAuth()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: registers, isLoading, isError } = useQuery({
|
||||
queryKey: ['cash-registers'],
|
||||
queryFn: getCashRegisters,
|
||||
enabled: user?.role === 'admin',
|
||||
})
|
||||
const [form, setForm] = useState<FormState>(EMPTY_FORM)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState<{ tone: 'ok' | 'error'; text: string } | null>(null)
|
||||
|
||||
if (user && user.role !== 'admin') return <Navigate to="/" replace />
|
||||
|
||||
async function run(action: () => Promise<unknown>, okText: string) {
|
||||
setBusy(true)
|
||||
try {
|
||||
await action()
|
||||
setMessage({ tone: 'ok', text: okText })
|
||||
await queryClient.invalidateQueries({ queryKey: ['cash-registers'] })
|
||||
} catch (err) {
|
||||
setMessage({ tone: 'error', text: err instanceof Error ? err.message : 'Ошибка запроса' })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(register: CashRegister) {
|
||||
setEditingId(register.id)
|
||||
setForm({
|
||||
name: register.name,
|
||||
fiscal_number: register.fiscal_number ?? '',
|
||||
license_key: '',
|
||||
pin_code: '',
|
||||
tax_codes: register.tax_codes.join(', '),
|
||||
is_default: register.is_default,
|
||||
})
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setEditingId(null)
|
||||
setForm(EMPTY_FORM)
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
if (editingId) {
|
||||
// Пустые ключ/PIN при редактировании — «не менять».
|
||||
const payload: CashRegisterUpdate = {
|
||||
name: form.name,
|
||||
fiscal_number: form.fiscal_number || null,
|
||||
tax_codes: parseTaxCodes(form.tax_codes),
|
||||
is_default: form.is_default,
|
||||
}
|
||||
if (form.license_key) payload.license_key = form.license_key
|
||||
if (form.pin_code) payload.pin_code = form.pin_code
|
||||
await run(() => updateCashRegister(editingId, payload), 'Касса обновлена')
|
||||
} else {
|
||||
await run(
|
||||
() =>
|
||||
createCashRegister({
|
||||
name: form.name,
|
||||
fiscal_number: form.fiscal_number || null,
|
||||
license_key: form.license_key,
|
||||
pin_code: form.pin_code,
|
||||
tax_codes: parseTaxCodes(form.tax_codes),
|
||||
is_default: form.is_default,
|
||||
}),
|
||||
'Касса добавлена',
|
||||
)
|
||||
}
|
||||
resetForm()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dashboard-shell">
|
||||
<header className="dashboard-topbar">
|
||||
<span className="dashboard-brand">lux_fiscal</span>
|
||||
<Link to="/" className="orders-link-btn">
|
||||
← К заказам
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<main className="orders-body">
|
||||
<div className="orders-toolbar">
|
||||
<h2>Кассы Checkbox</h2>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`orders-notice orders-notice--${message.tone}`}>
|
||||
<span>{message.text}</span>
|
||||
<button type="button" className="orders-notice-close" onClick={() => setMessage(null)} aria-label="Скрыть">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="orders-table-wrap">
|
||||
<table className="orders-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Название</th>
|
||||
<th>Фиск. номер</th>
|
||||
<th>Ключ лицензии</th>
|
||||
<th>Налоги</th>
|
||||
<th>Статус</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td colSpan={6} className="orders-empty">
|
||||
Загрузка…
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{isError && (
|
||||
<tr>
|
||||
<td colSpan={6} className="orders-empty">
|
||||
Не удалось загрузить кассы
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{registers?.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="orders-empty">
|
||||
Кассы не настроены — чеки создавать нельзя
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{registers?.map((register) => (
|
||||
<tr key={register.id}>
|
||||
<td>
|
||||
{register.name}
|
||||
{register.is_default && <span className="orders-status orders-status--delivered cr-default">основная</span>}
|
||||
</td>
|
||||
<td>{register.fiscal_number || '—'}</td>
|
||||
<td>{register.license_key_masked}</td>
|
||||
<td>{register.tax_codes.length ? register.tax_codes.join(', ') : 'без налога'}</td>
|
||||
<td>
|
||||
<span className={`orders-status orders-status--${register.is_active ? 'delivered' : 'new'}`}>
|
||||
{register.is_active ? 'Активна' : 'Отключена'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="orders-actions-cell">
|
||||
<button
|
||||
type="button"
|
||||
className="orders-view-btn"
|
||||
disabled={busy}
|
||||
onClick={() => void run(() => checkCashRegister(register.id), `Вход в «${register.name}» успешен`)}
|
||||
>
|
||||
Проверить
|
||||
</button>
|
||||
<button type="button" className="orders-view-btn" disabled={busy} onClick={() => startEdit(register)}>
|
||||
Изменить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="orders-delete-btn"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void run(
|
||||
() => updateCashRegister(register.id, { is_active: !register.is_active }),
|
||||
register.is_active ? 'Касса отключена' : 'Касса включена',
|
||||
)
|
||||
}
|
||||
>
|
||||
{register.is_active ? 'Отключить' : 'Включить'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<form className="cr-form" onSubmit={(e) => void handleSubmit(e)}>
|
||||
<h3>{editingId ? 'Изменить кассу' : 'Добавить кассу'}</h3>
|
||||
<label>
|
||||
Название
|
||||
<input required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
Фискальный номер
|
||||
<input value={form.fiscal_number} onChange={(e) => setForm({ ...form, fiscal_number: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
Ключ лицензии кассы
|
||||
<input
|
||||
required={!editingId}
|
||||
autoComplete="off"
|
||||
placeholder={editingId ? 'не менять' : ''}
|
||||
value={form.license_key}
|
||||
onChange={(e) => setForm({ ...form, license_key: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
PIN-код кассира
|
||||
<input
|
||||
required={!editingId}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={editingId ? 'не менять' : ''}
|
||||
value={form.pin_code}
|
||||
onChange={(e) => setForm({ ...form, pin_code: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Коды налогов (через запятую, пусто — без налога)
|
||||
<input value={form.tax_codes} onChange={(e) => setForm({ ...form, tax_codes: e.target.value })} />
|
||||
</label>
|
||||
<label className="cr-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.is_default}
|
||||
onChange={(e) => setForm({ ...form, is_default: e.target.checked })}
|
||||
/>
|
||||
Основная касса (по ней создаются чеки)
|
||||
</label>
|
||||
<div className="cr-form-actions">
|
||||
<button type="submit" className="orders-create-btn" disabled={busy}>
|
||||
{editingId ? 'Сохранить' : 'Добавить'}
|
||||
</button>
|
||||
{editingId && (
|
||||
<button type="button" className="orders-view-btn" onClick={resetForm}>
|
||||
Отмена
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -325,3 +325,68 @@
|
||||
color: var(--color-text-muted);
|
||||
padding: 32px 0;
|
||||
}
|
||||
|
||||
.orders-toolbar-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.orders-link-btn {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 9px 14px;
|
||||
font-size: 14px;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.orders-prepayment-input--invalid {
|
||||
border-color: #dc2626;
|
||||
}
|
||||
|
||||
.orders-notice {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.orders-notice ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.orders-notice--ok {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.orders-notice--error {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.orders-notice-close {
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.orders-notice--ok {
|
||||
background: rgba(22, 163, 74, 0.18);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.orders-notice--error {
|
||||
background: rgba(220, 38, 38, 0.18);
|
||||
color: #fca5a5;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
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 } from '@/features/orders/types'
|
||||
import { useOrders } 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: 'Администратор',
|
||||
@@ -29,6 +34,11 @@ function npStatusTone(order: Order): 'delivered' | 'processing' | 'danger' | 'ne
|
||||
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' }
|
||||
@@ -44,8 +54,67 @@ export function DashboardPage() {
|
||||
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
|
||||
if (!window.confirm(`Отменить ЕТТН-чек по заказу ${order.id}? Заказ вернётся в очередь.`)) 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()
|
||||
@@ -105,18 +174,48 @@ export function DashboardPage() {
|
||||
<main className="orders-body">
|
||||
<div className="orders-toolbar">
|
||||
<h2>Заказы</h2>
|
||||
<button type="button" className="orders-create-btn" disabled={selected.size === 0}>
|
||||
Создать чеки по выбранным ({selected.size})
|
||||
</button>
|
||||
<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>
|
||||
|
||||
{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)}
|
||||
onClick={() => {
|
||||
setTab(t.key)
|
||||
setSelected(new Set())
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
@@ -150,7 +249,7 @@ export function DashboardPage() {
|
||||
<th>Сумма</th>
|
||||
<th>Наложка</th>
|
||||
<th>Оплачено</th>
|
||||
<th>Предоплата</th>
|
||||
<th>Предоплата, ₴</th>
|
||||
<th>Статус ТТН</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
@@ -197,7 +296,25 @@ export function DashboardPage() {
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" className="orders-prepayment-input" placeholder="0%" />
|
||||
{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)}`}>
|
||||
@@ -208,10 +325,36 @@ export function DashboardPage() {
|
||||
<button type="button" className="orders-view-btn" onClick={() => setViewingOrder(order)}>
|
||||
Просмотр
|
||||
</button>
|
||||
<button type="button" className="orders-receipt-btn" disabled>
|
||||
Чек
|
||||
</button>
|
||||
{canDelete && (
|
||||
{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 === 'has_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 === 'has_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' && (
|
||||
<button
|
||||
type="button"
|
||||
className="orders-delete-btn"
|
||||
@@ -237,7 +380,12 @@ export function DashboardPage() {
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{viewingOrder && <OrderDetailModal order={viewingOrder} onClose={() => setViewingOrder(null)} />}
|
||||
{viewingOrder && (
|
||||
<OrderDetailModal
|
||||
order={orders?.find((order) => order.id === viewingOrder.id) ?? viewingOrder}
|
||||
onClose={() => setViewingOrder(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user