Files
lux_fiscal/frontend/src/pages/CashRegistersPage.tsx
T
lauadminandClaude Opus 5.5 8b3c2b6d63 Bind Nova Poshta API key to cash register (#3)
Each cash register stores its own encrypted NP API key. Status polling uses
register keys and binds an order to the register whose key sees the TTN as
its own (PhoneSender present); ETTN receipts are created from that register.
Migration 0008 moves the old NOVA_POSHTA_API_KEY into the default register.

Closes #3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:02:40 +03:00

296 lines
10 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 { 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
np_api_key: string
tax_codes: string
is_default: boolean
}
const EMPTY_FORM: FormState = {
name: '',
fiscal_number: '',
license_key: '',
pin_code: '',
np_api_key: '',
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: '',
np_api_key: '',
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
if (form.np_api_key) payload.np_api_key = form.np_api_key
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,
np_api_key: form.np_api_key,
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>
<th>Действия</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={7} className="orders-empty">
Загрузка…
</td>
</tr>
)}
{isError && (
<tr>
<td colSpan={7} className="orders-empty">
Не удалось загрузить кассы
</td>
</tr>
)}
{registers?.length === 0 && (
<tr>
<td colSpan={7} 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.np_api_key_masked ?? <span className="cr-missing">не задан</span>}</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>
Ключ API Новой Почты (посылки этого кабинета пробиваются через эту кассу)
<input
required={!editingId}
autoComplete="off"
placeholder={editingId ? 'не менять' : ''}
value={form.np_api_key}
onChange={(e) => setForm({ ...form, np_api_key: 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>
)
}