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:
@@ -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 ────────────────────────────
|
// ── GET /api/hotels/:slug/workstations/:id/ports ────────────────────────────
|
||||||
fastify.get<WsParam>(
|
fastify.get<WsParam>(
|
||||||
'/api/hotels/:slug/workstations/:id/ports',
|
'/api/hotels/:slug/workstations/:id/ports',
|
||||||
|
|||||||
@@ -359,6 +359,8 @@ export const api = {
|
|||||||
req<{ ok: true; sent: number }>('POST', `/api/hotels/${slug}/workstations/update-all`),
|
req<{ ok: true; sent: number }>('POST', `/api/hotels/${slug}/workstations/update-all`),
|
||||||
listPorts: (slug: string, wsId: string) =>
|
listPorts: (slug: string, wsId: string) =>
|
||||||
req<{ ports: { port: string; description?: string }[] }>('GET', `/api/hotels/${slug}/workstations/${wsId}/ports`),
|
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 ────────────────────────────────────────────────────────────────
|
// ── Agents ────────────────────────────────────────────────────────────────
|
||||||
@@ -1001,7 +1003,7 @@ export interface WorkstationDevice {
|
|||||||
workstationId: string
|
workstationId: string
|
||||||
type: 'kkt' | 'printer' | 'netup'
|
type: 'kkt' | 'printer' | 'netup'
|
||||||
name: string
|
name: string
|
||||||
connection: 'usb' | 'network' | 'com'
|
connection: 'usb' | 'network' | 'com' | 'windows'
|
||||||
networkHost?: string
|
networkHost?: string
|
||||||
networkPort?: number
|
networkPort?: number
|
||||||
comPort?: string
|
comPort?: string
|
||||||
|
|||||||
@@ -192,9 +192,10 @@ function DeviceForm({
|
|||||||
const handleTypeChange = (t: 'kkt' | 'printer' | 'netup') => {
|
const handleTypeChange = (t: 'kkt' | 'printer' | 'netup') => {
|
||||||
setType(t)
|
setType(t)
|
||||||
if (t === 'netup') setConnection('network')
|
if (t === 'netup') setConnection('network')
|
||||||
|
if (t === 'kkt') setConnection('com')
|
||||||
}
|
}
|
||||||
const [name, setName] = useState(initial?.name ?? '')
|
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 [networkHost, setNetworkHost] = useState(initial?.networkHost ?? '')
|
||||||
const [networkPort, setNetworkPort] = useState(String(initial?.networkPort ?? 9100))
|
const [networkPort, setNetworkPort] = useState(String(initial?.networkPort ?? 9100))
|
||||||
const [comPort, setComPort] = useState(initial?.comPort ?? '')
|
const [comPort, setComPort] = useState(initial?.comPort ?? '')
|
||||||
@@ -207,31 +208,68 @@ function DeviceForm({
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [availablePorts, setAvailablePorts] = useState<{ port: string; description?: string }[] | null>(null)
|
const [availablePorts, setAvailablePorts] = useState<{ port: string; description?: string }[] | null>(null)
|
||||||
const [portsLoading, setPortsLoading] = useState(false)
|
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
|
const loadPorts = () => {
|
||||||
useEffect(() => {
|
if (!wsOnline) return
|
||||||
if (connection !== 'com' || !wsOnline || availablePorts !== null) return
|
setAvailablePorts(null)
|
||||||
setPortsLoading(true)
|
setPortsLoading(true)
|
||||||
api.workstations.listPorts(slug, wsId)
|
api.workstations.listPorts(slug, wsId)
|
||||||
.then(r => setAvailablePorts(r.ports))
|
.then(r => setAvailablePorts(r.ports))
|
||||||
.catch(() => setAvailablePorts([]))
|
.catch(() => setAvailablePorts([]))
|
||||||
.finally(() => setPortsLoading(false))
|
.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 () => {
|
const handleSave = async () => {
|
||||||
if (!name.trim()) return
|
if (!name.trim()) return
|
||||||
setSaving(true)
|
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({
|
await onSave({
|
||||||
type,
|
type,
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
connection,
|
connection: type === 'kkt' ? 'com' : connection,
|
||||||
networkHost: connection === 'network' ? networkHost.trim() : undefined,
|
networkHost: connection === 'network' ? networkHost.trim() : undefined,
|
||||||
networkPort: connection === 'network' ? Number(networkPort) : undefined,
|
networkPort: connection === 'network' ? Number(networkPort) : undefined,
|
||||||
comPort: connection === 'com' ? comPort.trim() : undefined,
|
comPort: connection === 'com' ? comPort.trim() : undefined,
|
||||||
purpose: purpose as WorkstationDevice['purpose'],
|
purpose: purpose as WorkstationDevice['purpose'],
|
||||||
fiscalMode: type === 'kkt' ? fiscalMode : undefined,
|
fiscalMode: type === 'kkt' ? fiscalMode : undefined,
|
||||||
dtoPort: type === 'kkt' && fiscalMode ? Number(dtoPort) : 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)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@@ -354,14 +392,15 @@ function DeviceForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{type !== 'netup' && (
|
{type === 'printer' && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-slate-500 mb-1">Подключение</label>
|
<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: 'usb', icon: <Usb size={14} />, label: 'USB' },
|
|
||||||
{ id: 'network', icon: <Network size={14} />, label: 'Сеть (TCP)' },
|
{ 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: 'com', icon: <Cable size={14} />, label: 'COM-порт' },
|
||||||
|
{ id: 'usb', icon: <Usb size={14} />, label: 'USB' },
|
||||||
] as const).map(c => (
|
] as const).map(c => (
|
||||||
<button
|
<button
|
||||||
key={c.id}
|
key={c.id}
|
||||||
@@ -403,11 +442,57 @@ function DeviceForm({
|
|||||||
</div>
|
</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' && (
|
{connection === 'com' && (
|
||||||
<div>
|
<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-порт
|
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>
|
</label>
|
||||||
{wsOnline && availablePorts && availablePorts.length > 0 ? (
|
{wsOnline && availablePorts && availablePorts.length > 0 ? (
|
||||||
<select
|
<select
|
||||||
@@ -431,7 +516,7 @@ function DeviceForm({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{wsOnline && availablePorts !== null && availablePorts.length === 0 && (
|
{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 && (
|
{!wsOnline && (
|
||||||
<p className="text-xs text-slate-400 mt-1">Терминал офлайн — введите порт вручную (COM1, COM3…)</p>
|
<p className="text-xs text-slate-400 mt-1">Терминал офлайн — введите порт вручную (COM1, COM3…)</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user