feat: Equipment page — KKT hides connection, Windows printer, COM retry; agent update banner

- EquipmentPage: KKT hides USB/COM/Network buttons (DTO handles connection)
- EquipmentPage: Printer adds Windows connection type — lists installed printers from agent
- EquipmentPage: COM port section has retry button (↺)
- api.ts: add listPrinters, extend connection type
- workstations.ts: add /printers endpoint (list_printers command)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-27 18:30:06 +03:00
parent 13dac3f227
commit 1358dda7f9
3 changed files with 116 additions and 17 deletions

View File

@@ -262,6 +262,18 @@ const workstationRoutes: FastifyPluginAsync = async (fastify) => {
},
)
// ── GET /api/hotels/:slug/workstations/:id/printers ─────────────────────────
fastify.get<WsParam>('/api/hotels/:slug/workstations/:id/printers', { onRequest: [fastify.authenticate] }, async (req, reply) => {
const { id } = req.params
try {
const result = await sendCommandAndWait(id, { type: 'list_printers' }, 8000) as { ok: boolean; printers?: { name: string; isDefault: boolean }[]; error?: string }
if (!result.ok) return reply.code(502).send({ error: result.error ?? 'Агент недоступен' })
return { printers: result.printers ?? [] }
} catch (err) {
return reply.code(502).send({ error: err instanceof Error ? err.message : 'Ошибка' })
}
})
// ── GET /api/hotels/:slug/workstations/:id/ports ────────────────────────────
fastify.get<WsParam>(
'/api/hotels/:slug/workstations/:id/ports',

View File

@@ -359,6 +359,8 @@ export const api = {
req<{ ok: true; sent: number }>('POST', `/api/hotels/${slug}/workstations/update-all`),
listPorts: (slug: string, wsId: string) =>
req<{ ports: { port: string; description?: string }[] }>('GET', `/api/hotels/${slug}/workstations/${wsId}/ports`),
listPrinters: (slug: string, wsId: string) =>
req<{ printers: { name: string; isDefault: boolean }[] }>('GET', `/api/hotels/${slug}/workstations/${wsId}/printers`),
},
// ── Agents ────────────────────────────────────────────────────────────────
@@ -1001,7 +1003,7 @@ export interface WorkstationDevice {
workstationId: string
type: 'kkt' | 'printer' | 'netup'
name: string
connection: 'usb' | 'network' | 'com'
connection: 'usb' | 'network' | 'com' | 'windows'
networkHost?: string
networkPort?: number
comPort?: string

View File

@@ -192,9 +192,10 @@ function DeviceForm({
const handleTypeChange = (t: 'kkt' | 'printer' | 'netup') => {
setType(t)
if (t === 'netup') setConnection('network')
if (t === 'kkt') setConnection('com')
}
const [name, setName] = useState(initial?.name ?? '')
const [connection, setConnection] = useState<'usb' | 'network' | 'com'>(initial?.connection ?? 'network')
const [connection, setConnection] = useState<'usb' | 'network' | 'com' | 'windows'>(initial?.connection ?? 'network')
const [networkHost, setNetworkHost] = useState(initial?.networkHost ?? '')
const [networkPort, setNetworkPort] = useState(String(initial?.networkPort ?? 9100))
const [comPort, setComPort] = useState(initial?.comPort ?? '')
@@ -205,33 +206,70 @@ function DeviceForm({
const [dtoPass, setDtoPass] = useState(String(initial?.config?.dto_pass ?? '30'))
const [dtoDeviceId, setDtoDeviceId] = useState(String(initial?.config?.dto_device_id ?? ''))
const [saving, setSaving] = useState(false)
const [availablePorts, setAvailablePorts] = useState<{ port: string; description?: string }[] | null>(null)
const [portsLoading, setPortsLoading] = useState(false)
const [availablePorts, setAvailablePorts] = useState<{ port: string; description?: string }[] | null>(null)
const [portsLoading, setPortsLoading] = useState(false)
const [availablePrinters, setAvailablePrinters] = useState<{ name: string; isDefault: boolean }[] | null>(null)
const [printersLoading, setPrintersLoading] = useState(false)
const [windowsPrinter, setWindowsPrinter] = useState(String(initial?.config?.windows_printer ?? ''))
// Загружаем порты когда выбирается COM
useEffect(() => {
if (connection !== 'com' || !wsOnline || availablePorts !== null) return
const loadPorts = () => {
if (!wsOnline) return
setAvailablePorts(null)
setPortsLoading(true)
api.workstations.listPorts(slug, wsId)
.then(r => setAvailablePorts(r.ports))
.catch(() => setAvailablePorts([]))
.finally(() => setPortsLoading(false))
}, [connection, wsOnline, slug, wsId, availablePorts])
}
const loadPrinters = () => {
if (!wsOnline) return
setAvailablePrinters(null)
setPrintersLoading(true)
api.workstations.listPrinters(slug, wsId)
.then(r => setAvailablePrinters(r.printers))
.catch(() => setAvailablePrinters([]))
.finally(() => setPrintersLoading(false))
}
// Загружаем порты когда выбирается COM
useEffect(() => {
if (connection !== 'com' || !wsOnline || availablePorts !== null) return
loadPorts()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connection, wsOnline])
// Загружаем принтеры когда выбирается Windows
useEffect(() => {
if (connection !== 'windows' || !wsOnline || availablePrinters !== null) return
loadPrinters()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connection, wsOnline])
const handleSave = async () => {
if (!name.trim()) return
setSaving(true)
const configData: Record<string, unknown> = {}
if (type === 'kkt' && fiscalMode) {
configData.dto_user = dtoUser
configData.dto_pass = dtoPass
if (dtoDeviceId.trim()) configData.dto_device_id = dtoDeviceId.trim()
}
if (type === 'printer' && connection === 'windows' && windowsPrinter.trim()) {
configData.windows_printer = windowsPrinter.trim()
}
await onSave({
type,
name: name.trim(),
connection,
connection: type === 'kkt' ? 'com' : connection,
networkHost: connection === 'network' ? networkHost.trim() : undefined,
networkPort: connection === 'network' ? Number(networkPort) : undefined,
comPort: connection === 'com' ? comPort.trim() : undefined,
purpose: purpose as WorkstationDevice['purpose'],
fiscalMode: type === 'kkt' ? fiscalMode : undefined,
dtoPort: type === 'kkt' && fiscalMode ? Number(dtoPort) : undefined,
config: type === 'kkt' && fiscalMode ? { dto_user: dtoUser, dto_pass: dtoPass, dto_device_id: dtoDeviceId.trim() || undefined } : undefined,
config: Object.keys(configData).length ? configData : undefined,
})
setSaving(false)
}
@@ -354,14 +392,15 @@ function DeviceForm({
/>
</div>
{type !== 'netup' && (
{type === 'printer' && (
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Подключение</label>
<div className="flex gap-2">
<div className="flex gap-2 flex-wrap">
{([
{ id: 'network', icon: <Network size={14} />, label: 'Сеть (TCP)' },
{ id: 'windows', icon: <Monitor size={14} />, label: 'Windows принтер' },
{ id: 'com', icon: <Cable size={14} />, label: 'COM-порт' },
{ id: 'usb', icon: <Usb size={14} />, label: 'USB' },
{ id: 'network', icon: <Network size={14} />, label: 'Сеть (TCP)' },
{ id: 'com', icon: <Cable size={14} />, label: 'COM-порт' },
] as const).map(c => (
<button
key={c.id}
@@ -403,11 +442,57 @@ function DeviceForm({
</div>
)}
{connection === 'windows' && (
<div>
<label className="block text-xs font-medium text-slate-500 mb-1 flex items-center gap-2">
Принтер Windows
{printersLoading && <span className="text-slate-400 font-normal">загрузка...</span>}
{wsOnline && !printersLoading && (
<button type="button" onClick={loadPrinters} className="text-brand-500 hover:text-brand-700 font-normal">
<RefreshCw size={11} />
</button>
)}
</label>
{wsOnline && availablePrinters && availablePrinters.length > 0 ? (
<select
value={windowsPrinter}
onChange={e => setWindowsPrinter(e.target.value)}
className="w-full input text-sm"
>
<option value=""> выберите принтер </option>
{availablePrinters.map(p => (
<option key={p.name} value={p.name}>
{p.name}{p.isDefault ? ' (по умолчанию)' : ''}
</option>
))}
</select>
) : (
<input
value={windowsPrinter}
onChange={e => setWindowsPrinter(e.target.value)}
className="w-full input text-sm"
placeholder="Microsoft Print to PDF"
/>
)}
{wsOnline && availablePrinters !== null && availablePrinters.length === 0 && (
<p className="text-xs text-amber-500 mt-1">Принтеры не найдены. Убедитесь что принтер установлен в Windows.</p>
)}
{!wsOnline && (
<p className="text-xs text-slate-400 mt-1">Терминал офлайн введите имя принтера вручную</p>
)}
</div>
)}
{connection === 'com' && (
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">
<label className="block text-xs font-medium text-slate-500 mb-1 flex items-center gap-2">
COM-порт
{portsLoading && <span className="ml-2 text-slate-400 font-normal">загрузка...</span>}
{portsLoading && <span className="text-slate-400 font-normal">загрузка...</span>}
{wsOnline && !portsLoading && (
<button type="button" onClick={loadPorts} className="text-brand-500 hover:text-brand-700 font-normal">
<RefreshCw size={11} />
</button>
)}
</label>
{wsOnline && availablePorts && availablePorts.length > 0 ? (
<select
@@ -431,7 +516,7 @@ function DeviceForm({
/>
)}
{wsOnline && availablePorts !== null && availablePorts.length === 0 && (
<p className="text-xs text-amber-500 mt-1">COM-порты не найдены на терминале</p>
<p className="text-xs text-amber-500 mt-1">COM-порты не найдены нажмите для повтора</p>
)}
{!wsOnline && (
<p className="text-xs text-slate-400 mt-1">Терминал офлайн введите порт вручную (COM1, COM3)</p>