From f837a84db534b011305cdff8a7065dff7ba22b5a Mon Sep 17 00:00:00 2001
From: HotelSync
Date: Tue, 31 Mar 2026 23:40:29 +0300
Subject: [PATCH] feat: TTLock encoder per workstation (Option A)
- Migration 051: add ttlock_com_port to workstations, drop com_port/workstation_id from hotel_ttlock_config
- Backend workstations PATCH: accepts ttlock_com_port alongside name
- Backend ttlock: test/issue-card use per-workstation com_port; auto-select online encoder if workstationId not specified
- TTLockPage Step 2: per-workstation table with COM port input, Save, Test buttons per row
- BookingDetailPanel: auto-issue if 1 encoder online, picker modal if multiple
- api.ts: TTLockConfig/Update cleaned up, Workstation gets ttlockComPort, issueCard/testEncoder updated
Co-Authored-By: Claude Sonnet 4.6
---
.../051_workstation_ttlock_com_port.sql | 9 +
backend/src/routes/ttlock.ts | 152 +++++++++-------
backend/src/routes/workstations.ts | 16 +-
.../bookings/BookingDetailPanel.tsx | 57 +++++-
src/lib/api.ts | 34 ++--
src/pages/EquipmentPage.tsx | 2 +-
src/pages/TTLockPage.tsx | 168 ++++++++----------
7 files changed, 263 insertions(+), 175 deletions(-)
create mode 100644 backend/migrations/051_workstation_ttlock_com_port.sql
diff --git a/backend/migrations/051_workstation_ttlock_com_port.sql b/backend/migrations/051_workstation_ttlock_com_port.sql
new file mode 100644
index 0000000..a72aae3
--- /dev/null
+++ b/backend/migrations/051_workstation_ttlock_com_port.sql
@@ -0,0 +1,9 @@
+-- Move TTLock encoder COM port from global config to per-workstation
+ALTER TABLE workstations
+ ADD COLUMN IF NOT EXISTS ttlock_com_port TEXT;
+
+-- Remove workstation_id and com_port from global TTLock config
+-- (com_port and workstation_id are now per-workstation)
+ALTER TABLE hotel_ttlock_config
+ DROP COLUMN IF EXISTS workstation_id,
+ DROP COLUMN IF EXISTS com_port;
diff --git a/backend/src/routes/ttlock.ts b/backend/src/routes/ttlock.ts
index 0edab4d..3bc1f8f 100644
--- a/backend/src/routes/ttlock.ts
+++ b/backend/src/routes/ttlock.ts
@@ -20,8 +20,9 @@
* GET /api/hotels/:slug/bookings/:bookingId/cards
*/
import { FastifyPluginAsync } from 'fastify'
+import { WebSocket } from 'ws'
import { db } from '../db'
-import { sendCommandAndWait } from '../agent-ws'
+import { sendCommandAndWait, getAgentSocket } from '../agent-ws'
type SlugParam = { Params: { slug: string } }
type RoomParam = { Params: { slug: string; roomId: string } }
@@ -46,20 +47,17 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { rows } = await db.query(
- `SELECT is_enabled, client_id, card_sectors, com_port, workstation_id
- FROM hotel_ttlock_config WHERE hotel_id = $1`,
+ `SELECT is_enabled, client_id, card_sectors FROM hotel_ttlock_config WHERE hotel_id = $1`,
[hotelId],
)
if (!rows[0]) {
- return { isEnabled: false, clientId: '', cardSectors: '1,2,3,4,5,6,7,8,9,10', comPort: '', workstationId: null }
+ return { isEnabled: false, clientId: '', cardSectors: '1,2,3,4,5,6,7,8,9,10' }
}
const r = rows[0]
return {
- isEnabled: r.is_enabled,
- clientId: r.client_id,
- cardSectors: r.card_sectors,
- comPort: r.com_port,
- workstationId: r.workstation_id,
+ isEnabled: r.is_enabled,
+ clientId: r.client_id,
+ cardSectors: r.card_sectors,
}
})
@@ -70,8 +68,6 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
clientId?: string
clientSecret?: string
cardSectors?: string
- comPort?: string
- workstationId?: string | null
}
}>('/api/hotels/:slug/ttlock/config', { onRequest: [fastify.authenticate] }, async (req, reply) => {
const { slug } = req.params
@@ -80,28 +76,23 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
- const { isEnabled, clientId, clientSecret, cardSectors, comPort, workstationId } = req.body
+ const { isEnabled, clientId, clientSecret, cardSectors } = req.body
await db.query(
- `INSERT INTO hotel_ttlock_config
- (hotel_id, is_enabled, client_id, client_secret, card_sectors, com_port, workstation_id)
- VALUES ($1, $2, $3, $4, $5, $6, $7)
+ `INSERT INTO hotel_ttlock_config (hotel_id, is_enabled, client_id, client_secret, card_sectors)
+ VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (hotel_id) DO UPDATE SET
- is_enabled = COALESCE($2, hotel_ttlock_config.is_enabled),
- client_id = COALESCE($3, hotel_ttlock_config.client_id),
- client_secret = COALESCE(NULLIF($4, ''), hotel_ttlock_config.client_secret),
- card_sectors = COALESCE($5, hotel_ttlock_config.card_sectors),
- com_port = COALESCE($6, hotel_ttlock_config.com_port),
- workstation_id = $7,
- updated_at = NOW()`,
+ is_enabled = COALESCE($2, hotel_ttlock_config.is_enabled),
+ client_id = COALESCE($3, hotel_ttlock_config.client_id),
+ client_secret = COALESCE(NULLIF($4, ''), hotel_ttlock_config.client_secret),
+ card_sectors = COALESCE($5, hotel_ttlock_config.card_sectors),
+ updated_at = NOW()`,
[
hotelId,
- isEnabled ?? null,
- clientId ?? null,
+ isEnabled ?? null,
+ clientId ?? null,
clientSecret ?? '',
- cardSectors ?? null,
- comPort ?? null,
- workstationId ?? null,
+ cardSectors ?? null,
],
)
return { ok: true }
@@ -109,34 +100,41 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
// ── POST /api/hotels/:slug/ttlock/test ──────────────────────────────────────
// Проверяем: агент онлайн, энкодер отвечает на COM-порту
- fastify.post('/api/hotels/:slug/ttlock/test', { onRequest: [fastify.authenticate] }, async (req, reply) => {
- const { slug } = req.params
- if (!canManage(req.user.hotelSlug, req.user.role, slug))
- return reply.code(403).send({ error: 'Forbidden' })
- const hotelId = await getHotelId(slug)
- if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
+ fastify.post(
+ '/api/hotels/:slug/ttlock/test',
+ { onRequest: [fastify.authenticate] },
+ async (req, reply) => {
+ const { slug } = req.params
+ if (!canManage(req.user.hotelSlug, req.user.role, slug))
+ return reply.code(403).send({ error: 'Forbidden' })
+ const hotelId = await getHotelId(slug)
+ if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
- const { rows } = await db.query(
- 'SELECT workstation_id, com_port FROM hotel_ttlock_config WHERE hotel_id = $1',
- [hotelId],
- )
- const cfg = rows[0]
- if (!cfg?.workstation_id) return reply.code(400).send({ error: 'Рабочее место не выбрано' })
- if (!cfg.com_port) return reply.code(400).send({ error: 'COM-порт не указан' })
+ const { workstationId } = req.body
+ if (!workstationId) return reply.code(400).send({ error: 'workstationId required' })
- try {
- const result = await sendCommandAndWait(cfg.workstation_id, {
- type: 'ttlock:ping_encoder',
- comPort: cfg.com_port,
- }, 10_000) as { ok?: boolean; error?: string }
+ const { rows: wsRows } = await db.query(
+ 'SELECT ttlock_com_port FROM workstations WHERE id = $1 AND hotel_id = $2',
+ [workstationId, hotelId],
+ )
+ const ws = wsRows[0]
+ if (!ws) return reply.code(400).send({ error: 'Рабочее место не найдено' })
+ if (!ws.ttlock_com_port) return reply.code(400).send({ error: 'COM-порт не указан для этого рабочего места' })
- if (!result.ok) return reply.code(502).send({ error: result.error ?? 'Энкодер не ответил' })
- return { ok: true }
- } catch (err) {
- const msg = err instanceof Error ? err.message : 'Агент не ответил'
- return reply.code(502).send({ error: msg })
- }
- })
+ try {
+ const result = await sendCommandAndWait(workstationId, {
+ type: 'ttlock:ping_encoder',
+ comPort: ws.ttlock_com_port,
+ }, 10_000) as { ok?: boolean; error?: string }
+
+ if (!result.ok) return reply.code(502).send({ error: result.error ?? 'Энкодер не ответил' })
+ return { ok: true }
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : 'Агент не ответил'
+ return reply.code(502).send({ error: msg })
+ }
+ },
+ )
// ── GET /api/hotels/:slug/ttlock/room-locks ──────────────────────────────────
fastify.get('/api/hotels/:slug/ttlock/room-locks', { onRequest: [fastify.authenticate] }, async (req, reply) => {
@@ -205,7 +203,7 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
})
// ── POST /api/hotels/:slug/bookings/:bookingId/issue-card ────────────────────
- fastify.post(
+ fastify.post(
'/api/hotels/:slug/bookings/:bookingId/issue-card',
{ onRequest: [fastify.authenticate] },
async (req, reply) => {
@@ -233,16 +231,48 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
const lock = lockRows[0]
if (!lock) return reply.code(400).send({ error: `Номер "${booking.room_name}" не привязан к замку TTLock` })
- // Конфигурация TTHotel (client_id, com_port, sectors, workstation)
+ // Конфигурация TTHotel (client_id, card_sectors)
const { rows: cfgRows } = await db.query(
- 'SELECT client_id, client_secret, card_sectors, com_port, workstation_id, is_enabled FROM hotel_ttlock_config WHERE hotel_id = $1',
+ 'SELECT client_id, client_secret, card_sectors, is_enabled FROM hotel_ttlock_config WHERE hotel_id = $1',
[hotelId],
)
const cfg = cfgRows[0]
- if (!cfg?.is_enabled) return reply.code(400).send({ error: 'TTLock модуль не активирован' })
- if (!cfg.workstation_id) return reply.code(400).send({ error: 'Рабочее место с энкодером не выбрано' })
- if (!cfg.com_port) return reply.code(400).send({ error: 'COM-порт энкодера не указан' })
- if (!cfg.client_id) return reply.code(400).send({ error: 'client_id не заполнен' })
+ if (!cfg?.is_enabled) return reply.code(400).send({ error: 'TTLock модуль не активирован' })
+ if (!cfg.client_id) return reply.code(400).send({ error: 'client_id не заполнен' })
+
+ // Определяем рабочее место и COM-порт
+ let workstationId: string
+ let comPort: string
+
+ if (req.body?.workstationId) {
+ // Явно указано рабочее место
+ const { rows: wsRows } = await db.query(
+ 'SELECT id, ttlock_com_port FROM workstations WHERE id = $1 AND hotel_id = $2',
+ [req.body.workstationId, hotelId],
+ )
+ const ws = wsRows[0]
+ if (!ws || !ws.ttlock_com_port) {
+ return reply.code(400).send({ error: 'Указанное рабочее место не найдено или не имеет COM-порта' })
+ }
+ workstationId = ws.id
+ comPort = ws.ttlock_com_port
+ } else {
+ // Авто-выбор: первое онлайн-рабочее место с настроенным энкодером
+ const { rows: wsRows } = await db.query(
+ `SELECT id, ttlock_com_port FROM workstations
+ WHERE hotel_id = $1 AND ttlock_com_port IS NOT NULL AND ttlock_com_port != ''`,
+ [hotelId],
+ )
+ const available = wsRows.filter(ws => {
+ const sock = getAgentSocket(ws.id)
+ return sock?.readyState === WebSocket.OPEN
+ })
+ if (available.length === 0) {
+ return reply.code(400).send({ error: 'Нет онлайн-рабочих мест с настроенным энкодером' })
+ }
+ workstationId = available[0].id
+ comPort = available[0].ttlock_com_port
+ }
const checkIn = new Date(booking.check_in).getTime()
const checkOut = new Date(booking.check_out).getTime()
@@ -250,10 +280,10 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
// Отправляем команду агенту — он делает всё остальное через DLL
let result: unknown
try {
- result = await sendCommandAndWait(cfg.workstation_id, {
+ result = await sendCommandAndWait(workstationId, {
type: 'ttlock:write_card',
lockMac: lock.lock_mac,
- comPort: cfg.com_port,
+ comPort: comPort,
cardSectors: cfg.card_sectors,
clientId: cfg.client_id,
clientSecret: cfg.client_secret,
diff --git a/backend/src/routes/workstations.ts b/backend/src/routes/workstations.ts
index 97f911f..9b373f7 100644
--- a/backend/src/routes/workstations.ts
+++ b/backend/src/routes/workstations.ts
@@ -77,7 +77,7 @@ const workstationRoutes: FastifyPluginAsync = async (fastify) => {
)
// ── PATCH /api/hotels/:slug/workstations/:id ────────────────────────────────
- fastify.patch(
+ fastify.patch(
'/api/hotels/:slug/workstations/:id',
{ onRequest: [fastify.authenticate] },
async (req, reply) => {
@@ -85,12 +85,18 @@ const workstationRoutes: FastifyPluginAsync = async (fastify) => {
if (!canManage(req.user.hotelSlug, req.user.role, slug))
return reply.code(403).send({ error: 'Forbidden' })
- const { name } = req.body
- if (!name?.trim()) return reply.code(400).send({ error: 'name required' })
+ const { name, ttlock_com_port } = req.body
+ const sets: string[] = []
+ const vals: unknown[] = []
+ let idx = 1
+ if (name !== undefined) { sets.push(`name = $${idx++}`); vals.push(name.trim()) }
+ if (ttlock_com_port !== undefined) { sets.push(`ttlock_com_port = $${idx++}`); vals.push(ttlock_com_port || null) }
+ if (!sets.length) return reply.code(400).send({ error: 'Nothing to update' })
+ vals.push(id)
const { rows } = await db.query(
- `UPDATE workstations SET name = $1 WHERE id = $2 RETURNING *`,
- [name.trim(), id],
+ `UPDATE workstations SET ${sets.join(', ')} WHERE id = $${idx} RETURNING *`,
+ vals,
)
if (!rows[0]) return reply.code(404).send({ error: 'Not found' })
return rows[0]
diff --git a/src/components/bookings/BookingDetailPanel.tsx b/src/components/bookings/BookingDetailPanel.tsx
index 5ec5405..37aca99 100644
--- a/src/components/bookings/BookingDetailPanel.tsx
+++ b/src/components/bookings/BookingDetailPanel.tsx
@@ -303,6 +303,9 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
const [earlyOutSaving, setEarlyOutSaving] = useState(false)
const [cardIssuing, setCardIssuing] = useState(false)
+ const [cardEncoders, setCardEncoders] = useState>([])
+ const [cardEncoderPickerOpen, setCardEncoderPickerOpen] = useState(false)
+ const [selectedEncoderId, setSelectedEncoderId] = useState('')
const freeRoomsForRelocate = (rooms ?? []).filter(r =>
r.id !== booking.roomId &&
@@ -396,9 +399,38 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
const handleIssueCard = async () => {
if (!slug || cardIssuing) return
- setCardIssuing(true)
+
+ // Load available encoders
+ let encoders: Array<{ id: string; name: string }> = []
try {
- const r = await api.ttlock.issueCard(slug, booking.id)
+ const wsList = await api.workstations.list(slug)
+ encoders = wsList
+ .filter(w => w.isOnline && w.ttlockComPort)
+ .map(w => ({ id: w.id, name: w.name }))
+ } catch { /* ignore */ }
+
+ if (encoders.length === 0) {
+ showToast('✗ Нет онлайн-рабочих мест с настроенным энкодером')
+ return
+ }
+
+ if (encoders.length === 1) {
+ // Auto-issue
+ await doIssueCard(encoders[0].id)
+ return
+ }
+
+ // Multiple encoders — show picker
+ setCardEncoders(encoders)
+ setSelectedEncoderId(encoders[0].id)
+ setCardEncoderPickerOpen(true)
+ }
+
+ const doIssueCard = async (workstationId: string) => {
+ setCardIssuing(true)
+ setCardEncoderPickerOpen(false)
+ try {
+ const r = await api.ttlock.issueCard(slug!, booking.id, workstationId)
showToast(`✓ Ключ выдан (${r.roomName})`)
} catch (e) {
const msg = e instanceof Error ? e.message : 'Ошибка выдачи ключа'
@@ -1481,6 +1513,27 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
+
+ {cardEncoderPickerOpen && (
+
+
+
Выбрать энкодер
+
setSelectedEncoderId(e.target.value)}
+ >
+ {cardEncoders.map(e => (
+ {e.name}
+ ))}
+
+
+ setCardEncoderPickerOpen(false)} className="btn-secondary">Отмена
+ doIssueCard(selectedEncoderId)} className="btn-primary">Выдать ключ
+
+
+
+ )}
>
)
}
diff --git a/src/lib/api.ts b/src/lib/api.ts
index 9bd83cf..8d5d3f5 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -345,8 +345,11 @@ export const api = {
req('GET', `/api/hotels/${slug}/workstations`),
create: (slug: string, name: string) =>
req('POST', `/api/hotels/${slug}/workstations`, { name }),
- update: (slug: string, id: string, name: string) =>
- req('PATCH', `/api/hotels/${slug}/workstations/${id}`, { name }),
+ update: (slug: string, id: string, data: { name?: string; ttlockComPort?: string | null }) =>
+ req('PATCH', `/api/hotels/${slug}/workstations/${id}`, {
+ name: data.name,
+ ttlock_com_port: data.ttlockComPort,
+ }),
remove: (slug: string, id: string) =>
req('DELETE', `/api/hotels/${slug}/workstations/${id}`),
pairCode: (slug: string, id: string) =>
@@ -610,8 +613,8 @@ export const api = {
updateConfig: (slug: string, data: TTLockConfigUpdate) =>
req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/ttlock/config`, data),
- testEncoder: (slug: string) =>
- req<{ ok: boolean }>('POST', `/api/hotels/${slug}/ttlock/test`),
+ testEncoder: (slug: string, workstationId: string) =>
+ req<{ ok: boolean }>('POST', `/api/hotels/${slug}/ttlock/test`, { workstationId }),
getRoomLocks: (slug: string) =>
req('GET', `/api/hotels/${slug}/ttlock/room-locks`),
@@ -622,8 +625,8 @@ export const api = {
unmapRoom: (slug: string, roomId: string) =>
req<{ ok: boolean }>('DELETE', `/api/hotels/${slug}/ttlock/room-locks/${roomId}`),
- issueCard: (slug: string, bookingId: string) =>
- req('POST', `/api/hotels/${slug}/bookings/${bookingId}/issue-card`),
+ issueCard: (slug: string, bookingId: string, workstationId?: string) =>
+ req('POST', `/api/hotels/${slug}/bookings/${bookingId}/issue-card`, workstationId ? { workstationId } : undefined),
getCards: (slug: string, bookingId: string) =>
req('GET', `/api/hotels/${slug}/bookings/${bookingId}/cards`),
@@ -1076,25 +1079,22 @@ export interface Workstation {
isOnline: boolean
lastSeen: string | null
agentVersion?: string
+ ttlockComPort?: string | null
createdAt: string
devices: WorkstationDevice[] | null
}
export interface TTLockConfig {
- isEnabled: boolean
- clientId: string
- cardSectors: string
- comPort: string
- workstationId: string | null
+ isEnabled: boolean
+ clientId: string
+ cardSectors: string
}
export interface TTLockConfigUpdate {
- isEnabled?: boolean
- clientId?: string
- clientSecret?: string
- cardSectors?: string
- comPort?: string
- workstationId?: string | null
+ isEnabled?: boolean
+ clientId?: string
+ clientSecret?: string
+ cardSectors?: string
}
export interface RoomLockMapping {
diff --git a/src/pages/EquipmentPage.tsx b/src/pages/EquipmentPage.tsx
index 52cecf0..e678448 100644
--- a/src/pages/EquipmentPage.tsx
+++ b/src/pages/EquipmentPage.tsx
@@ -647,7 +647,7 @@ function WorkstationCard({
const saveName = async () => {
if (!name.trim() || name === ws.name) { setEditing(false); return }
- await api.workstations.update(slug, ws.id, name.trim())
+ await api.workstations.update(slug, ws.id, { name: name.trim() })
setEditing(false)
onRefresh()
}
diff --git a/src/pages/TTLockPage.tsx b/src/pages/TTLockPage.tsx
index bc7df2b..1a90259 100644
--- a/src/pages/TTLockPage.tsx
+++ b/src/pages/TTLockPage.tsx
@@ -77,7 +77,6 @@ export function TTLockPage() {
const [workstations, setWorkstations] = useState([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
- const [testing, setTesting] = useState(false)
const [error, setError] = useState(null)
const [success, setSuccess] = useState(null)
@@ -86,8 +85,11 @@ export function TTLockPage() {
const [clientId, setClientId] = useState('')
const [clientSecret, setClientSecret] = useState('')
const [cardSectors, setCardSectors] = useState('1,2,3,4,5,6,7,8,9,10')
- const [comPort, setComPort] = useState('')
- const [workstationId, setWorkstationId] = useState('')
+
+ // Per-workstation COM port state
+ const [wsComPorts, setWsComPorts] = useState>({})
+ const [wsSaving, setWsSaving] = useState>({})
+ const [wsTesting, setWsTesting] = useState>({})
// Форма привязки замков
const [addRoomId, setAddRoomId] = useState('')
@@ -113,11 +115,10 @@ export function TTLockPage() {
setIsEnabled(cfg.isEnabled)
setClientId(cfg.clientId)
setCardSectors(cfg.cardSectors || '1,2,3,4,5,6,7,8,9,10')
- setComPort(cfg.comPort)
- setWorkstationId(cfg.workstationId ?? '')
setMappings(maps)
setRooms(roomList)
setWorkstations(wsList)
+ setWsComPorts(Object.fromEntries(wsList.map(w => [w.id, w.ttlockComPort ?? ''])))
} catch {
setError('Не удалось загрузить настройки')
} finally {
@@ -136,8 +137,6 @@ export function TTLockPage() {
clientId: clientId.trim(),
clientSecret: clientSecret.trim() || undefined,
cardSectors: cardSectors.trim(),
- comPort: comPort.trim(),
- workstationId: workstationId || null,
})
showSuccess('Настройки сохранены')
setClientSecret('')
@@ -149,19 +148,6 @@ export function TTLockPage() {
}
}
- const handleTest = async () => {
- setTesting(true)
- setError(null)
- try {
- await api.ttlock.testEncoder(slug)
- showSuccess('Энкодер найден и отвечает — всё готово к работе!')
- } catch (e) {
- setError(e instanceof Error ? e.message : 'Ошибка проверки энкодера')
- } finally {
- setTesting(false)
- }
- }
-
const handleAddMapping = async () => {
if (!addRoomId || !addLockMac.trim()) return
setAddLoading(true)
@@ -203,7 +189,6 @@ export function TTLockPage() {
}
const unmappedRooms = rooms.filter(r => !mappings.some(m => m.roomId === r.id))
- const selectedWs = workstations.find(w => w.id === workstationId)
return (
@@ -283,60 +268,6 @@ export function TTLockPage() {
-
- {saving ? : }
- Сохранить
-
-
-
-
- {/* ── Шаг 2: Рабочее место и энкодер ── */}
-
-
-
- Выберите компьютер на ресепшен, к которому подключён USB-энкодер карт.
- COM-порт найдите в Диспетчере устройств Windows: Порты (COM и LPT) .
-
-
- {/* Рабочее место */}
-
-
Рабочее место (ПК с энкодером)
-
setWorkstationId(e.target.value)}
- className="input w-full"
- >
- — Выберите рабочее место —
- {workstations.map(w => (
-
- {w.name} {w.isOnline ? '✓ онлайн' : '✗ офлайн'}
- {w.hostname ? ` (${w.hostname})` : ''}
-
- ))}
-
- {selectedWs && !selectedWs.isOnline && (
-
-
- Агент на этом компьютере сейчас офлайн. Для выдачи карт агент должен быть запущен.
-
- )}
-
-
- {/* COM-порт */}
-
-
COM-порт энкодера
-
setComPort(e.target.value)}
- placeholder="Например: COM3"
- className="input w-full font-mono"
- />
-
- Windows: Диспетчер устройств → Порты (COM и LPT) → найдите «USB Serial Port»
-
-
-
{/* Секторы карты */}
Секторы карты
@@ -353,20 +284,79 @@ export function TTLockPage() {
-
-
- {saving ? : }
- Сохранить
-
-
- {testing ? : }
- Проверить энкодер
-
+
+ {saving ? : }
+ Сохранить
+
+
+
+
+ {/* ── Шаг 2: Рабочие места и энкодеры ── */}
+
+
+
+ Для каждого рабочего места укажите COM-порт USB-энкодера карт (найти в Диспетчере устройств → Порты).
+ Например: COM3 . Агент должен быть онлайн.
+
+ {workstations.length === 0 && (
+
Нет рабочих мест. Добавьте их на странице «Оборудование».
+ )}
+
+ {workstations.map(ws => (
+
+ {/* Online dot */}
+
+ {/* Name */}
+
+
{ws.name}
+ {ws.hostname &&
{ws.hostname}
}
+
+ {/* COM port input */}
+
setWsComPorts(prev => ({ ...prev, [ws.id]: e.target.value }))}
+ />
+ {/* Save */}
+
{
+ setWsSaving(prev => ({ ...prev, [ws.id]: true }))
+ try {
+ await api.workstations.update(slug, ws.id, { ttlockComPort: wsComPorts[ws.id]?.trim() || null })
+ showSuccess(`COM-порт для «${ws.name}» сохранён`)
+ await load()
+ } catch { setError('Ошибка сохранения') }
+ finally { setWsSaving(prev => ({ ...prev, [ws.id]: false })) }
+ }}
+ className="btn-secondary text-xs px-3 py-1.5 shrink-0"
+ >
+ {wsSaving[ws.id] ? : }
+
+ {/* Test */}
+
{
+ setWsTesting(prev => ({ ...prev, [ws.id]: true }))
+ setError(null)
+ try {
+ await api.ttlock.testEncoder(slug, ws.id)
+ showSuccess(`Энкодер на «${ws.name}» отвечает — всё готово!`)
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'Ошибка проверки')
+ } finally { setWsTesting(prev => ({ ...prev, [ws.id]: false })) }
+ }}
+ className="btn-secondary text-xs px-3 py-1.5 shrink-0"
+ title={!ws.isOnline ? 'Агент офлайн' : !wsComPorts[ws.id]?.trim() ? 'Укажите COM-порт' : 'Проверить связь с энкодером'}
+ >
+ {wsTesting[ws.id] ? : }
+
+
+ ))}