feat: COM-port support for workstation devices + agent UI fixes

- EquipmentPage: add COM-port connection type with port field (COM1, /dev/ttyUSB0)
- api.ts: add comPort field to WorkstationDevice type
- workstations route: pass com_port to INSERT/PATCH
- migration 042: extend connection check constraint to include 'com', add com_port column
- agent index.html: highlight first cell on load, reset input on unpair

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-27 12:41:31 +03:00
parent c5413566dd
commit b2b2034f4c
4 changed files with 51 additions and 19 deletions

View File

@@ -0,0 +1,13 @@
-- Добавляем поддержку COM-порта для устройств рабочего места
-- Расширяем CHECK constraint для connection (добавляем 'com')
ALTER TABLE workstation_devices
DROP CONSTRAINT IF EXISTS workstation_devices_connection_check;
ALTER TABLE workstation_devices
ADD CONSTRAINT workstation_devices_connection_check
CHECK (connection IN ('usb', 'network', 'com'));
-- Добавляем поле com_port (например: COM1, COM3, /dev/ttyUSB0)
ALTER TABLE workstation_devices
ADD COLUMN IF NOT EXISTS com_port VARCHAR(50);

View File

@@ -209,17 +209,17 @@ const workstationRoutes: FastifyPluginAsync = async (fastify) => {
// ── POST /api/hotels/:slug/workstations/:id/devices ─────────────────────────
fastify.post<WsParam & { Body: {
type: string; name: string; connection: string
network_host?: string; network_port?: number; purpose?: string; config?: object
network_host?: string; network_port?: number; com_port?: string; purpose?: string; config?: object
} }>(
'/api/hotels/:slug/workstations/:id/devices',
{ onRequest: [fastify.authenticate] },
async (req, reply) => {
const { id } = req.params
const { type, name, connection, network_host, network_port, purpose, config } = req.body
const { type, name, connection, network_host, network_port, com_port, purpose, config } = req.body
const { rows } = await db.query(
`INSERT INTO workstation_devices (workstation_id, type, name, connection, network_host, network_port, purpose, config)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
[id, type, name, connection, network_host ?? null, network_port ?? null, purpose ?? 'other', config ?? {}],
`INSERT INTO workstation_devices (workstation_id, type, name, connection, network_host, network_port, com_port, purpose, config)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *`,
[id, type, name, connection, network_host ?? null, network_port ?? null, com_port ?? null, purpose ?? 'other', config ?? {}],
)
return reply.code(201).send(rows[0])
},
@@ -232,7 +232,7 @@ const workstationRoutes: FastifyPluginAsync = async (fastify) => {
async (req, reply) => {
const { deviceId } = req.params
const body = req.body as Record<string, unknown>
const fields = ['name', 'connection', 'network_host', 'network_port', 'purpose', 'config']
const fields = ['name', 'connection', 'network_host', 'network_port', 'com_port', 'purpose', 'config']
const sets: string[] = []
const vals: unknown[] = []
fields.forEach(f => {

View File

@@ -972,9 +972,10 @@ export interface WorkstationDevice {
workstationId: string
type: 'kkt' | 'printer'
name: string
connection: 'usb' | 'network'
connection: 'usb' | 'network' | 'com'
networkHost?: string
networkPort?: number
comPort?: string
purpose: 'fiscal' | 'kitchen' | 'bar' | 'receipt' | 'other'
config: Record<string, unknown>
}

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react'
import {
Monitor, Plus, Trash2, Pencil, Check, X, RefreshCw,
Wifi, WifiOff, Printer, CreditCard, Usb, Network,
Wifi, WifiOff, Printer, CreditCard, Usb, Network, Cable,
ChevronDown, ChevronRight, Copy, Clock, AlertCircle,
} from 'lucide-react'
import { cn } from '../lib/utils'
@@ -171,9 +171,10 @@ function DeviceForm({
}) {
const [type, setType] = useState<'kkt' | 'printer'>(initial?.type ?? 'printer')
const [name, setName] = useState(initial?.name ?? '')
const [connection, setConnection] = useState<'usb' | 'network'>(initial?.connection ?? 'network')
const [connection, setConnection] = useState<'usb' | 'network' | 'com'>(initial?.connection ?? 'network')
const [networkHost, setNetworkHost] = useState(initial?.networkHost ?? '')
const [networkPort, setNetworkPort] = useState(String(initial?.networkPort ?? 9100))
const [comPort, setComPort] = useState(initial?.comPort ?? 'COM1')
const [purpose, setPurpose] = useState(initial?.purpose ?? 'receipt')
const [saving, setSaving] = useState(false)
@@ -184,8 +185,9 @@ function DeviceForm({
type,
name: name.trim(),
connection,
networkHost: connection === 'network' ? networkHost.trim() : undefined,
networkPort: connection === 'network' ? Number(networkPort) : undefined,
networkHost: connection === 'network' ? networkHost.trim() : undefined,
networkPort: connection === 'network' ? Number(networkPort) : undefined,
comPort: connection === 'com' ? comPort.trim() : undefined,
purpose: purpose as WorkstationDevice['purpose'],
})
setSaving(false)
@@ -232,19 +234,22 @@ function DeviceForm({
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Подключение</label>
<div className="flex gap-2">
{(['usb', 'network'] as const).map(c => (
{([
{ 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}
onClick={() => setConnection(c)}
key={c.id}
onClick={() => setConnection(c.id)}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors',
connection === c
connection === c.id
? 'bg-brand-600 border-brand-600 text-white'
: 'border-slate-300 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-brand-400',
)}
>
{c === 'usb' ? <Usb size={14} /> : <Network size={14} />}
{c === 'usb' ? 'USB' : 'Сеть (TCP)'}
{c.icon}{c.label}
</button>
))}
</div>
@@ -273,6 +278,19 @@ function DeviceForm({
</div>
)}
{connection === 'com' && (
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">COM-порт</label>
<input
value={comPort}
onChange={e => setComPort(e.target.value)}
className="w-full input text-sm font-mono"
placeholder="COM1"
/>
<p className="text-xs text-slate-400 mt-1">Windows: COM1, COM3 и т.д. Linux: /dev/ttyUSB0</p>
</div>
)}
<div className="flex gap-2 pt-1">
<button
onClick={handleSave}
@@ -441,8 +459,8 @@ function WorkstationCard({
<span className="text-xs text-slate-500">{PURPOSE_LABELS[device.purpose]}</span>
<span className="text-slate-300 dark:text-slate-600">·</span>
<span className="inline-flex items-center gap-1 text-xs text-slate-500">
{device.connection === 'usb' ? <Usb size={10} /> : <Network size={10} />}
{device.connection === 'usb' ? 'USB' : `${device.networkHost}:${device.networkPort}`}
{device.connection === 'usb' ? <Usb size={10} /> : device.connection === 'com' ? <Cable size={10} /> : <Network size={10} />}
{device.connection === 'usb' ? 'USB' : device.connection === 'com' ? device.comPort : `${device.networkHost}:${device.networkPort}`}
</span>
</div>
</div>