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>
296 lines
10 KiB
TypeScript
296 lines
10 KiB
TypeScript
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>
|
||
)
|
||
}
|