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,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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user