Files
lux_fiscal/frontend/src/pages/CashRegistersPage.tsx
T
lauadminandClaude Opus 5.5 8fffdc3ce6 Add main menu page with role-based modules (#7)
- `/` is now the main menu; orders/receipts page moved to `/receipts`
- Module registry (features/menu/modules.ts) drives both the menu tiles
  and route guards (ModuleRoute); admin sees every module
- Menu: module search with Ctrl+K, profile with logout, responsive grid,
  palette matching the login page
- «← Головне меню» link on receipts and cash registers pages;
  «Каси» moved from the receipts toolbar into the menu

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 17:11:57 +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 } 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)
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">
<div className="dashboard-topbar-start">
<Link to="/" className="orders-link-btn">
← Головне меню
</Link>
<span className="dashboard-brand">Assistant System</span>
</div>
</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>
)
}