- `/` 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>
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 } 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>
|
||
)
|
||
}
|