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 <noreply@anthropic.com>
This commit is contained in:
9
backend/migrations/051_workstation_ttlock_com_port.sql
Normal file
9
backend/migrations/051_workstation_ttlock_com_port.sql
Normal file
@@ -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;
|
||||||
@@ -20,8 +20,9 @@
|
|||||||
* GET /api/hotels/:slug/bookings/:bookingId/cards
|
* GET /api/hotels/:slug/bookings/:bookingId/cards
|
||||||
*/
|
*/
|
||||||
import { FastifyPluginAsync } from 'fastify'
|
import { FastifyPluginAsync } from 'fastify'
|
||||||
|
import { WebSocket } from 'ws'
|
||||||
import { db } from '../db'
|
import { db } from '../db'
|
||||||
import { sendCommandAndWait } from '../agent-ws'
|
import { sendCommandAndWait, getAgentSocket } from '../agent-ws'
|
||||||
|
|
||||||
type SlugParam = { Params: { slug: string } }
|
type SlugParam = { Params: { slug: string } }
|
||||||
type RoomParam = { Params: { slug: string; roomId: 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' })
|
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||||
|
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
`SELECT is_enabled, client_id, card_sectors, com_port, workstation_id
|
`SELECT is_enabled, client_id, card_sectors FROM hotel_ttlock_config WHERE hotel_id = $1`,
|
||||||
FROM hotel_ttlock_config WHERE hotel_id = $1`,
|
|
||||||
[hotelId],
|
[hotelId],
|
||||||
)
|
)
|
||||||
if (!rows[0]) {
|
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]
|
const r = rows[0]
|
||||||
return {
|
return {
|
||||||
isEnabled: r.is_enabled,
|
isEnabled: r.is_enabled,
|
||||||
clientId: r.client_id,
|
clientId: r.client_id,
|
||||||
cardSectors: r.card_sectors,
|
cardSectors: r.card_sectors,
|
||||||
comPort: r.com_port,
|
|
||||||
workstationId: r.workstation_id,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -70,8 +68,6 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
clientId?: string
|
clientId?: string
|
||||||
clientSecret?: string
|
clientSecret?: string
|
||||||
cardSectors?: string
|
cardSectors?: string
|
||||||
comPort?: string
|
|
||||||
workstationId?: string | null
|
|
||||||
}
|
}
|
||||||
}>('/api/hotels/:slug/ttlock/config', { onRequest: [fastify.authenticate] }, async (req, reply) => {
|
}>('/api/hotels/:slug/ttlock/config', { onRequest: [fastify.authenticate] }, async (req, reply) => {
|
||||||
const { slug } = req.params
|
const { slug } = req.params
|
||||||
@@ -80,28 +76,23 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
const hotelId = await getHotelId(slug)
|
const hotelId = await getHotelId(slug)
|
||||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
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(
|
await db.query(
|
||||||
`INSERT INTO hotel_ttlock_config
|
`INSERT INTO hotel_ttlock_config (hotel_id, is_enabled, client_id, client_secret, card_sectors)
|
||||||
(hotel_id, is_enabled, client_id, client_secret, card_sectors, com_port, workstation_id)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
||||||
ON CONFLICT (hotel_id) DO UPDATE SET
|
ON CONFLICT (hotel_id) DO UPDATE SET
|
||||||
is_enabled = COALESCE($2, hotel_ttlock_config.is_enabled),
|
is_enabled = COALESCE($2, hotel_ttlock_config.is_enabled),
|
||||||
client_id = COALESCE($3, hotel_ttlock_config.client_id),
|
client_id = COALESCE($3, hotel_ttlock_config.client_id),
|
||||||
client_secret = COALESCE(NULLIF($4, ''), hotel_ttlock_config.client_secret),
|
client_secret = COALESCE(NULLIF($4, ''), hotel_ttlock_config.client_secret),
|
||||||
card_sectors = COALESCE($5, hotel_ttlock_config.card_sectors),
|
card_sectors = COALESCE($5, hotel_ttlock_config.card_sectors),
|
||||||
com_port = COALESCE($6, hotel_ttlock_config.com_port),
|
updated_at = NOW()`,
|
||||||
workstation_id = $7,
|
|
||||||
updated_at = NOW()`,
|
|
||||||
[
|
[
|
||||||
hotelId,
|
hotelId,
|
||||||
isEnabled ?? null,
|
isEnabled ?? null,
|
||||||
clientId ?? null,
|
clientId ?? null,
|
||||||
clientSecret ?? '',
|
clientSecret ?? '',
|
||||||
cardSectors ?? null,
|
cardSectors ?? null,
|
||||||
comPort ?? null,
|
|
||||||
workstationId ?? null,
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
@@ -109,34 +100,41 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
|
|
||||||
// ── POST /api/hotels/:slug/ttlock/test ──────────────────────────────────────
|
// ── POST /api/hotels/:slug/ttlock/test ──────────────────────────────────────
|
||||||
// Проверяем: агент онлайн, энкодер отвечает на COM-порту
|
// Проверяем: агент онлайн, энкодер отвечает на COM-порту
|
||||||
fastify.post<SlugParam>('/api/hotels/:slug/ttlock/test', { onRequest: [fastify.authenticate] }, async (req, reply) => {
|
fastify.post<SlugParam & { Body: { workstationId: string } }>(
|
||||||
const { slug } = req.params
|
'/api/hotels/:slug/ttlock/test',
|
||||||
if (!canManage(req.user.hotelSlug, req.user.role, slug))
|
{ onRequest: [fastify.authenticate] },
|
||||||
return reply.code(403).send({ error: 'Forbidden' })
|
async (req, reply) => {
|
||||||
const hotelId = await getHotelId(slug)
|
const { slug } = req.params
|
||||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
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(
|
const { workstationId } = req.body
|
||||||
'SELECT workstation_id, com_port FROM hotel_ttlock_config WHERE hotel_id = $1',
|
if (!workstationId) return reply.code(400).send({ error: 'workstationId required' })
|
||||||
[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-порт не указан' })
|
|
||||||
|
|
||||||
try {
|
const { rows: wsRows } = await db.query(
|
||||||
const result = await sendCommandAndWait(cfg.workstation_id, {
|
'SELECT ttlock_com_port FROM workstations WHERE id = $1 AND hotel_id = $2',
|
||||||
type: 'ttlock:ping_encoder',
|
[workstationId, hotelId],
|
||||||
comPort: cfg.com_port,
|
)
|
||||||
}, 10_000) as { ok?: boolean; error?: string }
|
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 ?? 'Энкодер не ответил' })
|
try {
|
||||||
return { ok: true }
|
const result = await sendCommandAndWait(workstationId, {
|
||||||
} catch (err) {
|
type: 'ttlock:ping_encoder',
|
||||||
const msg = err instanceof Error ? err.message : 'Агент не ответил'
|
comPort: ws.ttlock_com_port,
|
||||||
return reply.code(502).send({ error: msg })
|
}, 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 ──────────────────────────────────
|
// ── GET /api/hotels/:slug/ttlock/room-locks ──────────────────────────────────
|
||||||
fastify.get<SlugParam>('/api/hotels/:slug/ttlock/room-locks', { onRequest: [fastify.authenticate] }, async (req, reply) => {
|
fastify.get<SlugParam>('/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 ────────────────────
|
// ── POST /api/hotels/:slug/bookings/:bookingId/issue-card ────────────────────
|
||||||
fastify.post<BookingParam>(
|
fastify.post<BookingParam & { Body: { workstationId?: string } }>(
|
||||||
'/api/hotels/:slug/bookings/:bookingId/issue-card',
|
'/api/hotels/:slug/bookings/:bookingId/issue-card',
|
||||||
{ onRequest: [fastify.authenticate] },
|
{ onRequest: [fastify.authenticate] },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
@@ -233,16 +231,48 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
const lock = lockRows[0]
|
const lock = lockRows[0]
|
||||||
if (!lock) return reply.code(400).send({ error: `Номер "${booking.room_name}" не привязан к замку TTLock` })
|
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(
|
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],
|
[hotelId],
|
||||||
)
|
)
|
||||||
const cfg = cfgRows[0]
|
const cfg = cfgRows[0]
|
||||||
if (!cfg?.is_enabled) return reply.code(400).send({ error: 'TTLock модуль не активирован' })
|
if (!cfg?.is_enabled) return reply.code(400).send({ error: 'TTLock модуль не активирован' })
|
||||||
if (!cfg.workstation_id) return reply.code(400).send({ error: 'Рабочее место с энкодером не выбрано' })
|
if (!cfg.client_id) return reply.code(400).send({ error: 'client_id не заполнен' })
|
||||||
if (!cfg.com_port) return reply.code(400).send({ error: 'COM-порт энкодера не указан' })
|
|
||||||
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 checkIn = new Date(booking.check_in).getTime()
|
||||||
const checkOut = new Date(booking.check_out).getTime()
|
const checkOut = new Date(booking.check_out).getTime()
|
||||||
@@ -250,10 +280,10 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
// Отправляем команду агенту — он делает всё остальное через DLL
|
// Отправляем команду агенту — он делает всё остальное через DLL
|
||||||
let result: unknown
|
let result: unknown
|
||||||
try {
|
try {
|
||||||
result = await sendCommandAndWait(cfg.workstation_id, {
|
result = await sendCommandAndWait(workstationId, {
|
||||||
type: 'ttlock:write_card',
|
type: 'ttlock:write_card',
|
||||||
lockMac: lock.lock_mac,
|
lockMac: lock.lock_mac,
|
||||||
comPort: cfg.com_port,
|
comPort: comPort,
|
||||||
cardSectors: cfg.card_sectors,
|
cardSectors: cfg.card_sectors,
|
||||||
clientId: cfg.client_id,
|
clientId: cfg.client_id,
|
||||||
clientSecret: cfg.client_secret,
|
clientSecret: cfg.client_secret,
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ const workstationRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ── PATCH /api/hotels/:slug/workstations/:id ────────────────────────────────
|
// ── PATCH /api/hotels/:slug/workstations/:id ────────────────────────────────
|
||||||
fastify.patch<WsParam & { Body: { name?: string } }>(
|
fastify.patch<WsParam & { Body: { name?: string; ttlock_com_port?: string | null } }>(
|
||||||
'/api/hotels/:slug/workstations/:id',
|
'/api/hotels/:slug/workstations/:id',
|
||||||
{ onRequest: [fastify.authenticate] },
|
{ onRequest: [fastify.authenticate] },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
@@ -85,12 +85,18 @@ const workstationRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
if (!canManage(req.user.hotelSlug, req.user.role, slug))
|
if (!canManage(req.user.hotelSlug, req.user.role, slug))
|
||||||
return reply.code(403).send({ error: 'Forbidden' })
|
return reply.code(403).send({ error: 'Forbidden' })
|
||||||
|
|
||||||
const { name } = req.body
|
const { name, ttlock_com_port } = req.body
|
||||||
if (!name?.trim()) return reply.code(400).send({ error: 'name required' })
|
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(
|
const { rows } = await db.query(
|
||||||
`UPDATE workstations SET name = $1 WHERE id = $2 RETURNING *`,
|
`UPDATE workstations SET ${sets.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||||
[name.trim(), id],
|
vals,
|
||||||
)
|
)
|
||||||
if (!rows[0]) return reply.code(404).send({ error: 'Not found' })
|
if (!rows[0]) return reply.code(404).send({ error: 'Not found' })
|
||||||
return rows[0]
|
return rows[0]
|
||||||
|
|||||||
@@ -303,6 +303,9 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
|
|||||||
const [earlyOutSaving, setEarlyOutSaving] = useState(false)
|
const [earlyOutSaving, setEarlyOutSaving] = useState(false)
|
||||||
|
|
||||||
const [cardIssuing, setCardIssuing] = useState(false)
|
const [cardIssuing, setCardIssuing] = useState(false)
|
||||||
|
const [cardEncoders, setCardEncoders] = useState<Array<{ id: string; name: string }>>([])
|
||||||
|
const [cardEncoderPickerOpen, setCardEncoderPickerOpen] = useState(false)
|
||||||
|
const [selectedEncoderId, setSelectedEncoderId] = useState('')
|
||||||
|
|
||||||
const freeRoomsForRelocate = (rooms ?? []).filter(r =>
|
const freeRoomsForRelocate = (rooms ?? []).filter(r =>
|
||||||
r.id !== booking.roomId &&
|
r.id !== booking.roomId &&
|
||||||
@@ -396,9 +399,38 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
|
|||||||
|
|
||||||
const handleIssueCard = async () => {
|
const handleIssueCard = async () => {
|
||||||
if (!slug || cardIssuing) return
|
if (!slug || cardIssuing) return
|
||||||
setCardIssuing(true)
|
|
||||||
|
// Load available encoders
|
||||||
|
let encoders: Array<{ id: string; name: string }> = []
|
||||||
try {
|
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})`)
|
showToast(`✓ Ключ выдан (${r.roomName})`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : 'Ошибка выдачи ключа'
|
const msg = e instanceof Error ? e.message : 'Ошибка выдачи ключа'
|
||||||
@@ -1481,6 +1513,27 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{cardEncoderPickerOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-2xl p-6 w-80 shadow-2xl space-y-4">
|
||||||
|
<h3 className="font-semibold text-slate-900 dark:text-slate-100">Выбрать энкодер</h3>
|
||||||
|
<select
|
||||||
|
className="input w-full"
|
||||||
|
value={selectedEncoderId}
|
||||||
|
onChange={e => setSelectedEncoderId(e.target.value)}
|
||||||
|
>
|
||||||
|
{cardEncoders.map(e => (
|
||||||
|
<option key={e.id} value={e.id}>{e.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button onClick={() => setCardEncoderPickerOpen(false)} className="btn-secondary">Отмена</button>
|
||||||
|
<button onClick={() => doIssueCard(selectedEncoderId)} className="btn-primary">Выдать ключ</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -345,8 +345,11 @@ export const api = {
|
|||||||
req<Workstation[]>('GET', `/api/hotels/${slug}/workstations`),
|
req<Workstation[]>('GET', `/api/hotels/${slug}/workstations`),
|
||||||
create: (slug: string, name: string) =>
|
create: (slug: string, name: string) =>
|
||||||
req<Workstation>('POST', `/api/hotels/${slug}/workstations`, { name }),
|
req<Workstation>('POST', `/api/hotels/${slug}/workstations`, { name }),
|
||||||
update: (slug: string, id: string, name: string) =>
|
update: (slug: string, id: string, data: { name?: string; ttlockComPort?: string | null }) =>
|
||||||
req<Workstation>('PATCH', `/api/hotels/${slug}/workstations/${id}`, { name }),
|
req<Workstation>('PATCH', `/api/hotels/${slug}/workstations/${id}`, {
|
||||||
|
name: data.name,
|
||||||
|
ttlock_com_port: data.ttlockComPort,
|
||||||
|
}),
|
||||||
remove: (slug: string, id: string) =>
|
remove: (slug: string, id: string) =>
|
||||||
req<void>('DELETE', `/api/hotels/${slug}/workstations/${id}`),
|
req<void>('DELETE', `/api/hotels/${slug}/workstations/${id}`),
|
||||||
pairCode: (slug: string, id: string) =>
|
pairCode: (slug: string, id: string) =>
|
||||||
@@ -610,8 +613,8 @@ export const api = {
|
|||||||
updateConfig: (slug: string, data: TTLockConfigUpdate) =>
|
updateConfig: (slug: string, data: TTLockConfigUpdate) =>
|
||||||
req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/ttlock/config`, data),
|
req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/ttlock/config`, data),
|
||||||
|
|
||||||
testEncoder: (slug: string) =>
|
testEncoder: (slug: string, workstationId: string) =>
|
||||||
req<{ ok: boolean }>('POST', `/api/hotels/${slug}/ttlock/test`),
|
req<{ ok: boolean }>('POST', `/api/hotels/${slug}/ttlock/test`, { workstationId }),
|
||||||
|
|
||||||
getRoomLocks: (slug: string) =>
|
getRoomLocks: (slug: string) =>
|
||||||
req<RoomLockMapping[]>('GET', `/api/hotels/${slug}/ttlock/room-locks`),
|
req<RoomLockMapping[]>('GET', `/api/hotels/${slug}/ttlock/room-locks`),
|
||||||
@@ -622,8 +625,8 @@ export const api = {
|
|||||||
unmapRoom: (slug: string, roomId: string) =>
|
unmapRoom: (slug: string, roomId: string) =>
|
||||||
req<{ ok: boolean }>('DELETE', `/api/hotels/${slug}/ttlock/room-locks/${roomId}`),
|
req<{ ok: boolean }>('DELETE', `/api/hotels/${slug}/ttlock/room-locks/${roomId}`),
|
||||||
|
|
||||||
issueCard: (slug: string, bookingId: string) =>
|
issueCard: (slug: string, bookingId: string, workstationId?: string) =>
|
||||||
req<CardIssuanceResult>('POST', `/api/hotels/${slug}/bookings/${bookingId}/issue-card`),
|
req<CardIssuanceResult>('POST', `/api/hotels/${slug}/bookings/${bookingId}/issue-card`, workstationId ? { workstationId } : undefined),
|
||||||
|
|
||||||
getCards: (slug: string, bookingId: string) =>
|
getCards: (slug: string, bookingId: string) =>
|
||||||
req<CardIssuance[]>('GET', `/api/hotels/${slug}/bookings/${bookingId}/cards`),
|
req<CardIssuance[]>('GET', `/api/hotels/${slug}/bookings/${bookingId}/cards`),
|
||||||
@@ -1076,25 +1079,22 @@ export interface Workstation {
|
|||||||
isOnline: boolean
|
isOnline: boolean
|
||||||
lastSeen: string | null
|
lastSeen: string | null
|
||||||
agentVersion?: string
|
agentVersion?: string
|
||||||
|
ttlockComPort?: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
devices: WorkstationDevice[] | null
|
devices: WorkstationDevice[] | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TTLockConfig {
|
export interface TTLockConfig {
|
||||||
isEnabled: boolean
|
isEnabled: boolean
|
||||||
clientId: string
|
clientId: string
|
||||||
cardSectors: string
|
cardSectors: string
|
||||||
comPort: string
|
|
||||||
workstationId: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TTLockConfigUpdate {
|
export interface TTLockConfigUpdate {
|
||||||
isEnabled?: boolean
|
isEnabled?: boolean
|
||||||
clientId?: string
|
clientId?: string
|
||||||
clientSecret?: string
|
clientSecret?: string
|
||||||
cardSectors?: string
|
cardSectors?: string
|
||||||
comPort?: string
|
|
||||||
workstationId?: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoomLockMapping {
|
export interface RoomLockMapping {
|
||||||
|
|||||||
@@ -647,7 +647,7 @@ function WorkstationCard({
|
|||||||
|
|
||||||
const saveName = async () => {
|
const saveName = async () => {
|
||||||
if (!name.trim() || name === ws.name) { setEditing(false); return }
|
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)
|
setEditing(false)
|
||||||
onRefresh()
|
onRefresh()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ export function TTLockPage() {
|
|||||||
const [workstations, setWorkstations] = useState<Workstation[]>([])
|
const [workstations, setWorkstations] = useState<Workstation[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [testing, setTesting] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [success, setSuccess] = useState<string | null>(null)
|
const [success, setSuccess] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -86,8 +85,11 @@ export function TTLockPage() {
|
|||||||
const [clientId, setClientId] = useState('')
|
const [clientId, setClientId] = useState('')
|
||||||
const [clientSecret, setClientSecret] = useState('')
|
const [clientSecret, setClientSecret] = useState('')
|
||||||
const [cardSectors, setCardSectors] = useState('1,2,3,4,5,6,7,8,9,10')
|
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<Record<string, string>>({})
|
||||||
|
const [wsSaving, setWsSaving] = useState<Record<string, boolean>>({})
|
||||||
|
const [wsTesting, setWsTesting] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
// Форма привязки замков
|
// Форма привязки замков
|
||||||
const [addRoomId, setAddRoomId] = useState('')
|
const [addRoomId, setAddRoomId] = useState('')
|
||||||
@@ -113,11 +115,10 @@ export function TTLockPage() {
|
|||||||
setIsEnabled(cfg.isEnabled)
|
setIsEnabled(cfg.isEnabled)
|
||||||
setClientId(cfg.clientId)
|
setClientId(cfg.clientId)
|
||||||
setCardSectors(cfg.cardSectors || '1,2,3,4,5,6,7,8,9,10')
|
setCardSectors(cfg.cardSectors || '1,2,3,4,5,6,7,8,9,10')
|
||||||
setComPort(cfg.comPort)
|
|
||||||
setWorkstationId(cfg.workstationId ?? '')
|
|
||||||
setMappings(maps)
|
setMappings(maps)
|
||||||
setRooms(roomList)
|
setRooms(roomList)
|
||||||
setWorkstations(wsList)
|
setWorkstations(wsList)
|
||||||
|
setWsComPorts(Object.fromEntries(wsList.map(w => [w.id, w.ttlockComPort ?? ''])))
|
||||||
} catch {
|
} catch {
|
||||||
setError('Не удалось загрузить настройки')
|
setError('Не удалось загрузить настройки')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -136,8 +137,6 @@ export function TTLockPage() {
|
|||||||
clientId: clientId.trim(),
|
clientId: clientId.trim(),
|
||||||
clientSecret: clientSecret.trim() || undefined,
|
clientSecret: clientSecret.trim() || undefined,
|
||||||
cardSectors: cardSectors.trim(),
|
cardSectors: cardSectors.trim(),
|
||||||
comPort: comPort.trim(),
|
|
||||||
workstationId: workstationId || null,
|
|
||||||
})
|
})
|
||||||
showSuccess('Настройки сохранены')
|
showSuccess('Настройки сохранены')
|
||||||
setClientSecret('')
|
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 () => {
|
const handleAddMapping = async () => {
|
||||||
if (!addRoomId || !addLockMac.trim()) return
|
if (!addRoomId || !addLockMac.trim()) return
|
||||||
setAddLoading(true)
|
setAddLoading(true)
|
||||||
@@ -203,7 +189,6 @@ export function TTLockPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const unmappedRooms = rooms.filter(r => !mappings.some(m => m.roomId === r.id))
|
const unmappedRooms = rooms.filter(r => !mappings.some(m => m.roomId === r.id))
|
||||||
const selectedWs = workstations.find(w => w.id === workstationId)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl mx-auto p-6 space-y-6">
|
<div className="max-w-3xl mx-auto p-6 space-y-6">
|
||||||
@@ -283,60 +268,6 @@ export function TTLockPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button onClick={handleSave} disabled={saving} className="btn-primary flex items-center gap-2">
|
|
||||||
{saving ? <RefreshCw size={15} className="animate-spin" /> : <Save size={15} />}
|
|
||||||
Сохранить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* ── Шаг 2: Рабочее место и энкодер ── */}
|
|
||||||
<Section title="Шаг 2 — Рабочее место и карточный энкодер">
|
|
||||||
<div className="space-y-5">
|
|
||||||
<Hint>
|
|
||||||
Выберите компьютер на ресепшен, к которому подключён USB-энкодер карт.
|
|
||||||
COM-порт найдите в Диспетчере устройств Windows: <strong>Порты (COM и LPT)</strong>.
|
|
||||||
</Hint>
|
|
||||||
|
|
||||||
{/* Рабочее место */}
|
|
||||||
<div>
|
|
||||||
<label className="form-label">Рабочее место (ПК с энкодером)</label>
|
|
||||||
<select
|
|
||||||
value={workstationId}
|
|
||||||
onChange={e => setWorkstationId(e.target.value)}
|
|
||||||
className="input w-full"
|
|
||||||
>
|
|
||||||
<option value="">— Выберите рабочее место —</option>
|
|
||||||
{workstations.map(w => (
|
|
||||||
<option key={w.id} value={w.id}>
|
|
||||||
{w.name} {w.isOnline ? '✓ онлайн' : '✗ офлайн'}
|
|
||||||
{w.hostname ? ` (${w.hostname})` : ''}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{selectedWs && !selectedWs.isOnline && (
|
|
||||||
<p className="text-xs text-amber-600 dark:text-amber-400 mt-1 flex items-center gap-1">
|
|
||||||
<AlertCircle size={12} />
|
|
||||||
Агент на этом компьютере сейчас офлайн. Для выдачи карт агент должен быть запущен.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* COM-порт */}
|
|
||||||
<div>
|
|
||||||
<label className="form-label">COM-порт энкодера</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={comPort}
|
|
||||||
onChange={e => setComPort(e.target.value)}
|
|
||||||
placeholder="Например: COM3"
|
|
||||||
className="input w-full font-mono"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-slate-500 mt-1">
|
|
||||||
Windows: Диспетчер устройств → Порты (COM и LPT) → найдите «USB Serial Port»
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Секторы карты */}
|
{/* Секторы карты */}
|
||||||
<div>
|
<div>
|
||||||
<label className="form-label">Секторы карты</label>
|
<label className="form-label">Секторы карты</label>
|
||||||
@@ -353,20 +284,79 @@ export function TTLockPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<button onClick={handleSave} disabled={saving} className="btn-primary flex items-center gap-2">
|
||||||
<button onClick={handleSave} disabled={saving} className="btn-primary flex items-center gap-2">
|
{saving ? <RefreshCw size={15} className="animate-spin" /> : <Save size={15} />}
|
||||||
{saving ? <RefreshCw size={15} className="animate-spin" /> : <Save size={15} />}
|
Сохранить
|
||||||
Сохранить
|
</button>
|
||||||
</button>
|
</div>
|
||||||
<button
|
</Section>
|
||||||
onClick={handleTest}
|
|
||||||
disabled={testing || !workstationId || !comPort}
|
{/* ── Шаг 2: Рабочие места и энкодеры ── */}
|
||||||
className="btn-secondary flex items-center gap-2"
|
<Section title="Шаг 2 — Рабочие места и энкодеры">
|
||||||
title={!workstationId || !comPort ? 'Сначала заполните рабочее место и COM-порт' : ''}
|
<div className="space-y-4">
|
||||||
>
|
<Hint>
|
||||||
{testing ? <RefreshCw size={15} className="animate-spin" /> : <Monitor size={15} />}
|
Для каждого рабочего места укажите COM-порт USB-энкодера карт (найти в Диспетчере устройств → Порты).
|
||||||
Проверить энкодер
|
Например: <strong>COM3</strong>. Агент должен быть онлайн.
|
||||||
</button>
|
</Hint>
|
||||||
|
{workstations.length === 0 && (
|
||||||
|
<p className="text-sm text-slate-500">Нет рабочих мест. Добавьте их на странице «Оборудование».</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{workstations.map(ws => (
|
||||||
|
<div key={ws.id} className="flex items-center gap-3 p-3 rounded-xl border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/40">
|
||||||
|
{/* Online dot */}
|
||||||
|
<div className={cn('w-2.5 h-2.5 rounded-full shrink-0', ws.isOnline ? 'bg-emerald-500' : 'bg-slate-300 dark:bg-slate-600')} />
|
||||||
|
{/* Name */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{ws.name}</p>
|
||||||
|
{ws.hostname && <p className="text-xs text-slate-400 truncate">{ws.hostname}</p>}
|
||||||
|
</div>
|
||||||
|
{/* COM port input */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input w-28 text-sm font-mono"
|
||||||
|
placeholder="COM3"
|
||||||
|
value={wsComPorts[ws.id] ?? ''}
|
||||||
|
onChange={e => setWsComPorts(prev => ({ ...prev, [ws.id]: e.target.value }))}
|
||||||
|
/>
|
||||||
|
{/* Save */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={wsSaving[ws.id]}
|
||||||
|
onClick={async () => {
|
||||||
|
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] ? <RefreshCw size={12} className="animate-spin" /> : <Save size={13} />}
|
||||||
|
</button>
|
||||||
|
{/* Test */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!ws.isOnline || !wsComPorts[ws.id]?.trim() || wsTesting[ws.id]}
|
||||||
|
onClick={async () => {
|
||||||
|
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] ? <RefreshCw size={12} className="animate-spin" /> : <Check size={13} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
Reference in New Issue
Block a user