feat: TTLock settings redesign — correct TTHotel DLL architecture
- Settings page rebuilt with 3-step flow: Step 1: client_id / client_secret (from TTHotel → Settings → Integrations) Step 2: workstation + COM port + card sectors Step 3: room → lock MAC address mapping (from lock.ttlock.com) - Agent command ttlock:write_card now uses lockMac, comPort, cardSectors instead of cloud-only lockId approach - New agent command ttlock:ping_encoder for encoder connectivity check - Migration 049_ttlock_v2.sql: lock_mac TEXT replaces lock_id BIGINT, card_sectors + com_port added, ttlock_username/password removed - API types updated: TTLockConfig, RoomLockMapping, CardIssuance Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
21
backend/migrations/049_ttlock_v2.sql
Normal file
21
backend/migrations/049_ttlock_v2.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
-- TTLock v2: исправляем схему под реальную архитектуру TTHotel DLL
|
||||||
|
-- lock_id BIGINT → lock_mac TEXT (MAC замка из lock.ttlock.com)
|
||||||
|
-- добавляем card_sectors и com_port
|
||||||
|
|
||||||
|
-- Убираем старый lock_id, добавляем MAC-адрес замка
|
||||||
|
ALTER TABLE room_lock_mappings
|
||||||
|
DROP COLUMN IF EXISTS lock_id,
|
||||||
|
ADD COLUMN IF NOT EXISTS lock_mac TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- В логе выдачи карт: lock_id → lock_mac
|
||||||
|
ALTER TABLE card_issuances
|
||||||
|
DROP COLUMN IF EXISTS lock_id,
|
||||||
|
ADD COLUMN IF NOT EXISTS lock_mac TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- Убираем ttlock_username/password — не нужны (auth через client_id/secret напрямую).
|
||||||
|
-- Добавляем card_sectors (из TTHotel) и com_port (COM-порт энкодера)
|
||||||
|
ALTER TABLE hotel_ttlock_config
|
||||||
|
DROP COLUMN IF EXISTS ttlock_username,
|
||||||
|
DROP COLUMN IF EXISTS ttlock_password,
|
||||||
|
ADD COLUMN IF NOT EXISTS card_sectors TEXT NOT NULL DEFAULT '1,2,3,4,5,6,7,8,9,10',
|
||||||
|
ADD COLUMN IF NOT EXISTS com_port TEXT NOT NULL DEFAULT '';
|
||||||
@@ -1,87 +1,41 @@
|
|||||||
/**
|
/**
|
||||||
* TTLock integration routes
|
* TTLock / TTHotel integration
|
||||||
*
|
*
|
||||||
* GET /api/hotels/:slug/ttlock/config — get config
|
* Архитектура:
|
||||||
* PATCH /api/hotels/:slug/ttlock/config — save config
|
* PMS backend → AgentWS → Windows Agent → Card Encoder DLL → USB Encoder (COM-порт) → Mifare-карта → Замок
|
||||||
* POST /api/hotels/:slug/ttlock/token — obtain/refresh TTLock access token
|
*
|
||||||
* GET /api/hotels/:slug/ttlock/locks — list locks from TTLock API
|
* Credentials: client_id + client_secret из TTHotel → Настройки → Интеграции
|
||||||
* GET /api/hotels/:slug/ttlock/room-locks — list room→lock mappings
|
* Lock id: MAC-адрес замка из lock.ttlock.com (без двоеточий, напр. "42A6BBF5ECE5")
|
||||||
* POST /api/hotels/:slug/ttlock/room-locks — map room to lock
|
* COM-порт: из Диспетчера устройств → Порты
|
||||||
* DELETE /api/hotels/:slug/ttlock/room-locks/:roomId — unmap room
|
* Секторы: из TTHotel (напр. "1,2,3,4,5,6,7,8,9,10")
|
||||||
* POST /api/hotels/:slug/bookings/:bookingId/issue-card — issue card via agent
|
*
|
||||||
* GET /api/hotels/:slug/bookings/:bookingId/cards — card issuance history
|
* Routes:
|
||||||
|
* GET /api/hotels/:slug/ttlock/config
|
||||||
|
* PATCH /api/hotels/:slug/ttlock/config
|
||||||
|
* POST /api/hotels/:slug/ttlock/test — проверка подключения агента и COM-порта
|
||||||
|
* GET /api/hotels/:slug/ttlock/room-locks — список привязок номер → замок
|
||||||
|
* POST /api/hotels/:slug/ttlock/room-locks — привязать номер к замку
|
||||||
|
* DELETE /api/hotels/:slug/ttlock/room-locks/:roomId
|
||||||
|
* POST /api/hotels/:slug/bookings/:bookingId/issue-card
|
||||||
|
* GET /api/hotels/:slug/bookings/:bookingId/cards
|
||||||
*/
|
*/
|
||||||
import { FastifyPluginAsync } from 'fastify'
|
import { FastifyPluginAsync } from 'fastify'
|
||||||
import { db } from '../db'
|
import { db } from '../db'
|
||||||
import { sendCommandAndWait } from '../agent-ws'
|
import { sendCommandAndWait } from '../agent-ws'
|
||||||
import crypto from 'crypto'
|
|
||||||
|
|
||||||
type SlugParam = { Params: { slug: string } }
|
type SlugParam = { Params: { slug: string } }
|
||||||
type RoomParam = { Params: { slug: string; roomId: string } }
|
type RoomParam = { Params: { slug: string; roomId: string } }
|
||||||
type BookingParam = { Params: { slug: string; bookingId: string } }
|
type BookingParam = { Params: { slug: string; bookingId: string } }
|
||||||
|
|
||||||
// TTLock Open Platform base URL — eu region
|
|
||||||
const TTLOCK_API = 'https://euopen.ttlock.com'
|
|
||||||
|
|
||||||
async function getHotelId(slug: string): Promise<string | undefined> {
|
async function getHotelId(slug: string): Promise<string | undefined> {
|
||||||
const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug])
|
const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug])
|
||||||
return rows[0]?.id
|
return rows[0]?.id
|
||||||
}
|
}
|
||||||
|
|
||||||
/** MD5 hash for TTLock password field (their API requirement) */
|
const canManage = (userSlug: string | null, role: string, slug: string) =>
|
||||||
function md5(text: string): string {
|
role === 'super_admin' || role === 'hotel_admin' || userSlug === slug
|
||||||
return crypto.createHash('md5').update(text).digest('hex')
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fetch TTLock access token using Resource Owner Password Grant */
|
|
||||||
async function fetchTTLockToken(clientId: string, clientSecret: string, username: string, password: string) {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
client_id: clientId,
|
|
||||||
client_secret: clientSecret,
|
|
||||||
grant_type: 'password',
|
|
||||||
username,
|
|
||||||
password: md5(password),
|
|
||||||
})
|
|
||||||
const res = await fetch(`${TTLOCK_API}/oauth2/token`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
body: params.toString(),
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error(`TTLock auth failed: ${res.status}`)
|
|
||||||
return res.json() as Promise<{ access_token: string; refresh_token: string; expires_in: number }>
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ensure we have a valid access token, refreshing if needed */
|
|
||||||
async function ensureToken(hotelId: string): Promise<string> {
|
|
||||||
const { rows } = await db.query(
|
|
||||||
'SELECT * FROM hotel_ttlock_config WHERE hotel_id = $1',
|
|
||||||
[hotelId],
|
|
||||||
)
|
|
||||||
const cfg = rows[0]
|
|
||||||
if (!cfg) throw new Error('TTLock не настроен')
|
|
||||||
if (!cfg.client_id || !cfg.ttlock_username) throw new Error('TTLock credentials не заполнены')
|
|
||||||
|
|
||||||
// Check if current token still valid (>60s buffer)
|
|
||||||
if (cfg.access_token && cfg.token_expires_at) {
|
|
||||||
const expiresAt = new Date(cfg.token_expires_at).getTime()
|
|
||||||
if (Date.now() < expiresAt - 60_000) return cfg.access_token
|
|
||||||
}
|
|
||||||
|
|
||||||
// Obtain fresh token
|
|
||||||
const token = await fetchTTLockToken(cfg.client_id, cfg.client_secret, cfg.ttlock_username, cfg.ttlock_password)
|
|
||||||
const expiresAt = new Date(Date.now() + token.expires_in * 1000)
|
|
||||||
await db.query(
|
|
||||||
`UPDATE hotel_ttlock_config
|
|
||||||
SET access_token = $1, refresh_token = $2, token_expires_at = $3, updated_at = NOW()
|
|
||||||
WHERE hotel_id = $4`,
|
|
||||||
[token.access_token, token.refresh_token, expiresAt, hotelId],
|
|
||||||
)
|
|
||||||
return token.access_token
|
|
||||||
}
|
|
||||||
|
|
||||||
const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
|
const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
|
||||||
const canManage = (userSlug: string | null, role: string, slug: string) =>
|
|
||||||
role === 'super_admin' || role === 'hotel_admin' || userSlug === slug
|
|
||||||
|
|
||||||
// ── GET /api/hotels/:slug/ttlock/config ─────────────────────────────────────
|
// ── GET /api/hotels/:slug/ttlock/config ─────────────────────────────────────
|
||||||
fastify.get<SlugParam>('/api/hotels/:slug/ttlock/config', { onRequest: [fastify.authenticate] }, async (req, reply) => {
|
fastify.get<SlugParam>('/api/hotels/:slug/ttlock/config', { onRequest: [fastify.authenticate] }, async (req, reply) => {
|
||||||
@@ -92,34 +46,31 @@ 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 id, is_enabled, client_id, ttlock_username, workstation_id,
|
`SELECT is_enabled, client_id, card_sectors, com_port, workstation_id
|
||||||
access_token IS NOT NULL AS has_token, token_expires_at
|
|
||||||
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 defaults
|
return { isEnabled: false, clientId: '', cardSectors: '1,2,3,4,5,6,7,8,9,10', comPort: '', workstationId: null }
|
||||||
return { isEnabled: false, clientId: '', ttlockUsername: '', workstationId: null, hasToken: false }
|
|
||||||
}
|
}
|
||||||
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,
|
||||||
ttlockUsername: r.ttlock_username,
|
cardSectors: r.card_sectors,
|
||||||
|
comPort: r.com_port,
|
||||||
workstationId: r.workstation_id,
|
workstationId: r.workstation_id,
|
||||||
hasToken: r.has_token,
|
|
||||||
tokenExpiresAt: r.token_expires_at,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── PATCH /api/hotels/:slug/ttlock/config ───────────────────────────────────
|
// ── PATCH /api/hotels/:slug/ttlock/config ───────────────────────────────────
|
||||||
fastify.patch<SlugParam & {
|
fastify.patch<SlugParam & {
|
||||||
Body: {
|
Body: {
|
||||||
isEnabled?: boolean
|
isEnabled?: boolean
|
||||||
clientId?: string
|
clientId?: string
|
||||||
clientSecret?: string
|
clientSecret?: string
|
||||||
ttlockUsername?: string
|
cardSectors?: string
|
||||||
ttlockPassword?: string
|
comPort?: string
|
||||||
workstationId?: string | null
|
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) => {
|
||||||
@@ -129,88 +80,65 @@ 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, ttlockUsername, ttlockPassword, workstationId } = req.body
|
const { isEnabled, clientId, clientSecret, cardSectors, comPort, workstationId } = req.body
|
||||||
|
|
||||||
await db.query(
|
await db.query(
|
||||||
`INSERT INTO hotel_ttlock_config (hotel_id, is_enabled, client_id, client_secret, ttlock_username, ttlock_password, workstation_id)
|
`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)
|
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),
|
||||||
ttlock_username = COALESCE($5, hotel_ttlock_config.ttlock_username),
|
card_sectors = COALESCE($5, hotel_ttlock_config.card_sectors),
|
||||||
ttlock_password = COALESCE(NULLIF($6, ''), hotel_ttlock_config.ttlock_password),
|
com_port = COALESCE($6, hotel_ttlock_config.com_port),
|
||||||
workstation_id = $7,
|
workstation_id = $7,
|
||||||
access_token = NULL,
|
updated_at = NOW()`,
|
||||||
token_expires_at = NULL,
|
[
|
||||||
updated_at = NOW()`,
|
hotelId,
|
||||||
[hotelId, isEnabled ?? true, clientId ?? '', clientSecret ?? '', ttlockUsername ?? '', ttlockPassword ?? '', workstationId ?? null],
|
isEnabled ?? null,
|
||||||
|
clientId ?? null,
|
||||||
|
clientSecret ?? '',
|
||||||
|
cardSectors ?? null,
|
||||||
|
comPort ?? null,
|
||||||
|
workstationId ?? null,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── POST /api/hotels/:slug/ttlock/token ─────────────────────────────────────
|
// ── POST /api/hotels/:slug/ttlock/test ──────────────────────────────────────
|
||||||
// Force re-auth with TTLock and store fresh token
|
// Проверяем: агент онлайн, энкодер отвечает на COM-порту
|
||||||
fastify.post<SlugParam>('/api/hotels/:slug/ttlock/token', { onRequest: [fastify.authenticate] }, async (req, reply) => {
|
fastify.post<SlugParam>('/api/hotels/:slug/ttlock/test', { onRequest: [fastify.authenticate] }, async (req, reply) => {
|
||||||
const { slug } = req.params
|
const { slug } = req.params
|
||||||
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 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' })
|
||||||
|
|
||||||
try {
|
const { rows } = await db.query(
|
||||||
const { rows } = await db.query('SELECT * FROM hotel_ttlock_config WHERE hotel_id = $1', [hotelId])
|
'SELECT workstation_id, com_port FROM hotel_ttlock_config WHERE hotel_id = $1',
|
||||||
const cfg = rows[0]
|
[hotelId],
|
||||||
if (!cfg?.client_id || !cfg.ttlock_username) return reply.code(400).send({ error: 'Заполните credentials' })
|
)
|
||||||
|
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 token = await fetchTTLockToken(cfg.client_id, cfg.client_secret, cfg.ttlock_username, cfg.ttlock_password)
|
try {
|
||||||
const expiresAt = new Date(Date.now() + token.expires_in * 1000)
|
const result = await sendCommandAndWait(cfg.workstation_id, {
|
||||||
await db.query(
|
type: 'ttlock:ping_encoder',
|
||||||
'UPDATE hotel_ttlock_config SET access_token = $1, refresh_token = $2, token_expires_at = $3, updated_at = NOW() WHERE hotel_id = $4',
|
comPort: cfg.com_port,
|
||||||
[token.access_token, token.refresh_token, expiresAt, hotelId],
|
}, 10_000) as { ok?: boolean; error?: string }
|
||||||
)
|
|
||||||
return { ok: true, expiresAt }
|
if (!result.ok) return reply.code(502).send({ error: result.error ?? 'Энкодер не ответил' })
|
||||||
|
return { ok: true }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : 'Ошибка'
|
const msg = err instanceof Error ? err.message : 'Агент не ответил'
|
||||||
return reply.code(400).send({ error: msg })
|
return reply.code(502).send({ error: msg })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── GET /api/hotels/:slug/ttlock/locks ──────────────────────────────────────
|
// ── GET /api/hotels/:slug/ttlock/room-locks ──────────────────────────────────
|
||||||
// List locks from TTLock API (requires valid token)
|
|
||||||
fastify.get<SlugParam>('/api/hotels/:slug/ttlock/locks', { 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' })
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await db.query('SELECT client_id FROM hotel_ttlock_config WHERE hotel_id = $1', [hotelId])
|
|
||||||
const clientId = rows[0]?.client_id
|
|
||||||
const accessToken = await ensureToken(hotelId)
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
clientId: clientId,
|
|
||||||
accessToken,
|
|
||||||
pageNo: '1',
|
|
||||||
pageSize: '100',
|
|
||||||
date: Date.now().toString(),
|
|
||||||
})
|
|
||||||
const res = await fetch(`${TTLOCK_API}/v3/lock/list?${params}`)
|
|
||||||
if (!res.ok) return reply.code(502).send({ error: 'TTLock API error' })
|
|
||||||
const data = await res.json() as { list?: { lockId: number; lockName: string; lockAlias: string }[] }
|
|
||||||
return (data.list ?? []).map(l => ({
|
|
||||||
lockId: l.lockId,
|
|
||||||
name: l.lockAlias || l.lockName,
|
|
||||||
}))
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : 'Ошибка'
|
|
||||||
return reply.code(400).send({ error: msg })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── 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) => {
|
||||||
const { slug } = req.params
|
const { slug } = req.params
|
||||||
if (!canManage(req.user.hotelSlug, req.user.role, slug))
|
if (!canManage(req.user.hotelSlug, req.user.role, slug))
|
||||||
@@ -219,7 +147,8 @@ 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 m.room_id, m.lock_id, m.lock_name, r.name AS room_name, r.number AS room_number
|
`SELECT m.room_id, m.lock_mac, m.lock_name,
|
||||||
|
r.name AS room_name, r.number AS room_number
|
||||||
FROM room_lock_mappings m
|
FROM room_lock_mappings m
|
||||||
JOIN rooms r ON r.id = m.room_id
|
JOIN rooms r ON r.id = m.room_id
|
||||||
WHERE m.hotel_id = $1
|
WHERE m.hotel_id = $1
|
||||||
@@ -230,13 +159,13 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
roomId: r.room_id,
|
roomId: r.room_id,
|
||||||
roomName: r.room_name,
|
roomName: r.room_name,
|
||||||
roomNumber: r.room_number,
|
roomNumber: r.room_number,
|
||||||
lockId: Number(r.lock_id),
|
lockMac: r.lock_mac,
|
||||||
lockName: r.lock_name,
|
lockName: r.lock_name,
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── POST /api/hotels/:slug/ttlock/room-locks ─────────────────────────────────
|
// ── POST /api/hotels/:slug/ttlock/room-locks ─────────────────────────────────
|
||||||
fastify.post<SlugParam & { Body: { roomId: string; lockId: number; lockName?: string } }>(
|
fastify.post<SlugParam & { Body: { roomId: string; lockMac: string; lockName?: string } }>(
|
||||||
'/api/hotels/:slug/ttlock/room-locks',
|
'/api/hotels/:slug/ttlock/room-locks',
|
||||||
{ onRequest: [fastify.authenticate] },
|
{ onRequest: [fastify.authenticate] },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
@@ -246,18 +175,24 @@ 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 { roomId, lockId, lockName } = req.body
|
const { roomId, lockMac, lockName } = req.body
|
||||||
|
// Нормализуем MAC: убираем двоеточия, приводим к верхнему регистру
|
||||||
|
const mac = lockMac.replace(/:/g, '').toUpperCase()
|
||||||
|
if (!/^[0-9A-F]{12}$/.test(mac)) {
|
||||||
|
return reply.code(400).send({ error: 'MAC-адрес должен быть 12 HEX-символов (напр. 42A6BBF5ECE5)' })
|
||||||
|
}
|
||||||
|
|
||||||
await db.query(
|
await db.query(
|
||||||
`INSERT INTO room_lock_mappings (hotel_id, room_id, lock_id, lock_name)
|
`INSERT INTO room_lock_mappings (hotel_id, room_id, lock_mac, lock_name)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
ON CONFLICT (hotel_id, room_id) DO UPDATE SET lock_id = $3, lock_name = $4`,
|
ON CONFLICT (hotel_id, room_id) DO UPDATE SET lock_mac = $3, lock_name = $4`,
|
||||||
[hotelId, roomId, lockId, lockName ?? null],
|
[hotelId, roomId, mac, lockName ?? null],
|
||||||
)
|
)
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── DELETE /api/hotels/:slug/ttlock/room-locks/:roomId ──────────────────────
|
// ── DELETE /api/hotels/:slug/ttlock/room-locks/:roomId ───────────────────────
|
||||||
fastify.delete<RoomParam>('/api/hotels/:slug/ttlock/room-locks/:roomId', { onRequest: [fastify.authenticate] }, async (req, reply) => {
|
fastify.delete<RoomParam>('/api/hotels/:slug/ttlock/room-locks/:roomId', { onRequest: [fastify.authenticate] }, async (req, reply) => {
|
||||||
const { slug, roomId } = req.params
|
const { slug, roomId } = req.params
|
||||||
if (!canManage(req.user.hotelSlug, req.user.role, slug))
|
if (!canManage(req.user.hotelSlug, req.user.role, slug))
|
||||||
@@ -269,7 +204,7 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── POST /api/hotels/:slug/bookings/:bookingId/issue-card ───────────────────
|
// ── POST /api/hotels/:slug/bookings/:bookingId/issue-card ────────────────────
|
||||||
fastify.post<BookingParam>(
|
fastify.post<BookingParam>(
|
||||||
'/api/hotels/:slug/bookings/:bookingId/issue-card',
|
'/api/hotels/:slug/bookings/:bookingId/issue-card',
|
||||||
{ onRequest: [fastify.authenticate] },
|
{ onRequest: [fastify.authenticate] },
|
||||||
@@ -280,9 +215,9 @@ 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' })
|
||||||
|
|
||||||
// Get booking + room info
|
// Бронирование + номер комнаты
|
||||||
const { rows: bookingRows } = await db.query(
|
const { rows: bookingRows } = await db.query(
|
||||||
`SELECT b.id, b.room_id, b.check_in, b.check_out, r.name AS room_name
|
`SELECT b.id, b.room_id, b.check_in, b.check_out, r.name AS room_name, r.number AS room_number
|
||||||
FROM bookings b JOIN rooms r ON r.id = b.room_id
|
FROM bookings b JOIN rooms r ON r.id = b.room_id
|
||||||
WHERE b.id = $1 AND b.hotel_id = $2`,
|
WHERE b.id = $1 AND b.hotel_id = $2`,
|
||||||
[bookingId, hotelId],
|
[bookingId, hotelId],
|
||||||
@@ -290,43 +225,38 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
const booking = bookingRows[0]
|
const booking = bookingRows[0]
|
||||||
if (!booking) return reply.code(404).send({ error: 'Бронирование не найдено' })
|
if (!booking) return reply.code(404).send({ error: 'Бронирование не найдено' })
|
||||||
|
|
||||||
// Get lock mapping for this room
|
// MAC замка для этого номера
|
||||||
const { rows: lockRows } = await db.query(
|
const { rows: lockRows } = await db.query(
|
||||||
'SELECT lock_id, lock_name FROM room_lock_mappings WHERE hotel_id = $1 AND room_id = $2',
|
'SELECT lock_mac, lock_name FROM room_lock_mappings WHERE hotel_id = $1 AND room_id = $2',
|
||||||
[hotelId, booking.room_id],
|
[hotelId, booking.room_id],
|
||||||
)
|
)
|
||||||
const lockMapping = lockRows[0]
|
const lock = lockRows[0]
|
||||||
if (!lockMapping) return reply.code(400).send({ error: `Для номера "${booking.room_name}" не назначен замок` })
|
if (!lock) return reply.code(400).send({ error: `Номер "${booking.room_name}" не привязан к замку TTLock` })
|
||||||
|
|
||||||
// Get TTLock config + workstation
|
// Конфигурация TTHotel (client_id, com_port, sectors, workstation)
|
||||||
const { rows: cfgRows } = await db.query(
|
const { rows: cfgRows } = await db.query(
|
||||||
'SELECT client_id, workstation_id, is_enabled FROM hotel_ttlock_config WHERE hotel_id = $1',
|
'SELECT client_id, client_secret, card_sectors, com_port, workstation_id, 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.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 не заполнен' })
|
||||||
|
|
||||||
// Get fresh access token
|
|
||||||
let accessToken: string
|
|
||||||
try {
|
|
||||||
accessToken = await ensureToken(hotelId)
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : 'Ошибка авторизации'
|
|
||||||
return reply.code(400).send({ error: msg })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send command to agent
|
|
||||||
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()
|
||||||
|
|
||||||
|
// Отправляем команду агенту — он делает всё остальное через DLL
|
||||||
let result: unknown
|
let result: unknown
|
||||||
try {
|
try {
|
||||||
result = await sendCommandAndWait(cfg.workstation_id, {
|
result = await sendCommandAndWait(cfg.workstation_id, {
|
||||||
type: 'ttlock:write_card',
|
type: 'ttlock:write_card',
|
||||||
lockId: Number(lockMapping.lock_id),
|
lockMac: lock.lock_mac,
|
||||||
|
comPort: cfg.com_port,
|
||||||
|
cardSectors: cfg.card_sectors,
|
||||||
clientId: cfg.client_id,
|
clientId: cfg.client_id,
|
||||||
accessToken,
|
clientSecret: cfg.client_secret,
|
||||||
startDate: checkIn,
|
startDate: checkIn,
|
||||||
endDate: checkOut,
|
endDate: checkOut,
|
||||||
}, 30_000)
|
}, 30_000)
|
||||||
@@ -335,34 +265,29 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
return reply.code(502).send({ error: msg })
|
return reply.code(502).send({ error: msg })
|
||||||
}
|
}
|
||||||
|
|
||||||
const agentResult = result as { ok?: boolean; error?: string; cardNumber?: string; ttlockCardId?: number }
|
const r = result as { ok?: boolean; error?: string; cardNumber?: string }
|
||||||
if (!agentResult.ok) {
|
if (!r.ok) return reply.code(502).send({ error: r.error ?? 'Ошибка записи карты' })
|
||||||
return reply.code(502).send({ error: agentResult.error ?? 'Ошибка записи карты' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log issuance
|
// Записываем в лог
|
||||||
const { rows: issuanceRows } = await db.query(
|
const { rows: issuanceRows } = await db.query(
|
||||||
`INSERT INTO card_issuances
|
`INSERT INTO card_issuances
|
||||||
(hotel_id, booking_id, room_id, lock_id, ttlock_card_id, issued_by, check_in, check_out)
|
(hotel_id, booking_id, room_id, lock_mac, issued_by, check_in, check_out)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
RETURNING id, issued_at`,
|
RETURNING id, issued_at`,
|
||||||
[
|
[hotelId, bookingId, booking.room_id, lock.lock_mac, req.user.sub, booking.check_in, booking.check_out],
|
||||||
hotelId, bookingId, booking.room_id,
|
|
||||||
Number(lockMapping.lock_id),
|
|
||||||
agentResult.ttlockCardId ?? null,
|
|
||||||
req.user.sub,
|
|
||||||
booking.check_in, booking.check_out,
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
issuanceId: issuanceRows[0].id,
|
issuanceId: issuanceRows[0].id,
|
||||||
issuedAt: issuanceRows[0].issued_at,
|
issuedAt: issuanceRows[0].issued_at,
|
||||||
lockName: lockMapping.lock_name,
|
|
||||||
roomName: booking.room_name,
|
roomName: booking.room_name,
|
||||||
|
roomNumber: booking.room_number,
|
||||||
|
lockMac: lock.lock_mac,
|
||||||
|
lockName: lock.lock_name,
|
||||||
checkIn: booking.check_in,
|
checkIn: booking.check_in,
|
||||||
checkOut: booking.check_out,
|
checkOut: booking.check_out,
|
||||||
|
cardNumber: r.cardNumber,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -376,7 +301,8 @@ 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 c.*, u.name AS issued_by_name
|
`SELECT c.id, c.lock_mac, c.issued_at, c.check_in, c.check_out,
|
||||||
|
c.is_revoked, c.revoked_at, u.name AS issued_by_name
|
||||||
FROM card_issuances c
|
FROM card_issuances c
|
||||||
LEFT JOIN users u ON u.id = c.issued_by
|
LEFT JOIN users u ON u.id = c.issued_by
|
||||||
WHERE c.hotel_id = $1 AND c.booking_id = $2
|
WHERE c.hotel_id = $1 AND c.booking_id = $2
|
||||||
@@ -384,14 +310,14 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
[hotelId, bookingId],
|
[hotelId, bookingId],
|
||||||
)
|
)
|
||||||
return rows.map(r => ({
|
return rows.map(r => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
lockId: Number(r.lock_id),
|
lockMac: r.lock_mac,
|
||||||
issuedAt: r.issued_at,
|
issuedAt: r.issued_at,
|
||||||
issuedBy: r.issued_by_name,
|
issuedBy: r.issued_by_name,
|
||||||
checkIn: r.check_in,
|
checkIn: r.check_in,
|
||||||
checkOut: r.check_out,
|
checkOut: r.check_out,
|
||||||
isRevoked: r.is_revoked,
|
isRevoked: r.is_revoked,
|
||||||
revokedAt: r.revoked_at,
|
revokedAt: r.revoked_at,
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -610,16 +610,13 @@ 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),
|
||||||
|
|
||||||
testToken: (slug: string) =>
|
testEncoder: (slug: string) =>
|
||||||
req<{ ok: boolean; expiresAt: string }>('POST', `/api/hotels/${slug}/ttlock/token`),
|
req<{ ok: boolean }>('POST', `/api/hotels/${slug}/ttlock/test`),
|
||||||
|
|
||||||
getLocks: (slug: string) =>
|
|
||||||
req<TTLockDevice[]>('GET', `/api/hotels/${slug}/ttlock/locks`),
|
|
||||||
|
|
||||||
getRoomLocks: (slug: string) =>
|
getRoomLocks: (slug: string) =>
|
||||||
req<RoomLockMapping[]>('GET', `/api/hotels/${slug}/ttlock/room-locks`),
|
req<RoomLockMapping[]>('GET', `/api/hotels/${slug}/ttlock/room-locks`),
|
||||||
|
|
||||||
mapRoom: (slug: string, data: { roomId: string; lockId: number; lockName?: string }) =>
|
mapRoom: (slug: string, data: { roomId: string; lockMac: string; lockName?: string }) =>
|
||||||
req<{ ok: boolean }>('POST', `/api/hotels/${slug}/ttlock/room-locks`, data),
|
req<{ ok: boolean }>('POST', `/api/hotels/${slug}/ttlock/room-locks`, data),
|
||||||
|
|
||||||
unmapRoom: (slug: string, roomId: string) =>
|
unmapRoom: (slug: string, roomId: string) =>
|
||||||
@@ -1082,53 +1079,50 @@ export interface Workstation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TTLockConfig {
|
export interface TTLockConfig {
|
||||||
isEnabled: boolean
|
isEnabled: boolean
|
||||||
clientId: string
|
clientId: string
|
||||||
ttlockUsername: string
|
cardSectors: string
|
||||||
|
comPort: string
|
||||||
workstationId: string | null
|
workstationId: string | null
|
||||||
hasToken: boolean
|
|
||||||
tokenExpiresAt: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TTLockConfigUpdate {
|
export interface TTLockConfigUpdate {
|
||||||
isEnabled?: boolean
|
isEnabled?: boolean
|
||||||
clientId?: string
|
clientId?: string
|
||||||
clientSecret?: string
|
clientSecret?: string
|
||||||
ttlockUsername?: string
|
cardSectors?: string
|
||||||
ttlockPassword?: string
|
comPort?: string
|
||||||
workstationId?: string | null
|
workstationId?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TTLockDevice {
|
|
||||||
lockId: number
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RoomLockMapping {
|
export interface RoomLockMapping {
|
||||||
roomId: string
|
roomId: string
|
||||||
roomName: string
|
roomName: string
|
||||||
roomNumber: string
|
roomNumber: string
|
||||||
lockId: number
|
lockMac: string
|
||||||
lockName: string | null
|
lockName: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CardIssuanceResult {
|
export interface CardIssuanceResult {
|
||||||
ok: boolean
|
ok: boolean
|
||||||
issuanceId: string
|
issuanceId: string
|
||||||
issuedAt: string
|
issuedAt: string
|
||||||
lockName: string | null
|
roomName: string
|
||||||
roomName: string
|
roomNumber: string
|
||||||
checkIn: string
|
lockMac: string
|
||||||
checkOut: string
|
lockName: string | null
|
||||||
|
checkIn: string
|
||||||
|
checkOut: string
|
||||||
|
cardNumber: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CardIssuance {
|
export interface CardIssuance {
|
||||||
id: string
|
id: string
|
||||||
lockId: number
|
lockMac: string
|
||||||
issuedAt: string
|
issuedAt: string
|
||||||
issuedBy: string | null
|
issuedBy: string | null
|
||||||
checkIn: string
|
checkIn: string
|
||||||
checkOut: string
|
checkOut: string
|
||||||
isRevoked: boolean
|
isRevoked: boolean
|
||||||
revokedAt: string | null
|
revokedAt: string | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import {
|
import {
|
||||||
KeyRound, Save, RefreshCw, Check, X, AlertCircle, ChevronDown, ChevronRight,
|
KeyRound, Save, RefreshCw, Check, X, AlertCircle,
|
||||||
Trash2, Plus, Eye, EyeOff, Monitor, Wifi, WifiOff, Lock, Unlock,
|
ChevronDown, ChevronRight, Trash2, Plus, Eye, EyeOff,
|
||||||
|
Monitor, Lock, Unlock, Info,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { api, type TTLockConfig, type TTLockDevice, type RoomLockMapping, type Workstation } from '../lib/api'
|
import { api, type TTLockConfig, type RoomLockMapping, type Workstation } from '../lib/api'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { cn } from '../lib/utils'
|
import { cn } from '../lib/utils'
|
||||||
import type { Room } from '../types'
|
import type { Room } from '../types'
|
||||||
@@ -11,9 +12,7 @@ import type { Room } from '../types'
|
|||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Section({ title, children, defaultOpen = true }: {
|
function Section({ title, children, defaultOpen = true }: {
|
||||||
title: string
|
title: string; children: React.ReactNode; defaultOpen?: boolean
|
||||||
children: React.ReactNode
|
|
||||||
defaultOpen?: boolean
|
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(defaultOpen)
|
const [open, setOpen] = useState(defaultOpen)
|
||||||
return (
|
return (
|
||||||
@@ -23,7 +22,9 @@ function Section({ title, children, defaultOpen = true }: {
|
|||||||
className="w-full flex items-center justify-between px-6 py-4 bg-slate-50 dark:bg-slate-800/50 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors text-left"
|
className="w-full flex items-center justify-between px-6 py-4 bg-slate-50 dark:bg-slate-800/50 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors text-left"
|
||||||
>
|
>
|
||||||
<span className="font-semibold text-slate-800 dark:text-slate-200">{title}</span>
|
<span className="font-semibold text-slate-800 dark:text-slate-200">{title}</span>
|
||||||
{open ? <ChevronDown size={16} className="text-slate-500" /> : <ChevronRight size={16} className="text-slate-500" />}
|
{open
|
||||||
|
? <ChevronDown size={16} className="text-slate-500" />
|
||||||
|
: <ChevronRight size={16} className="text-slate-500" />}
|
||||||
</button>
|
</button>
|
||||||
{open && <div className="p-6 bg-white dark:bg-slate-900">{children}</div>}
|
{open && <div className="p-6 bg-white dark:bg-slate-900">{children}</div>}
|
||||||
</div>
|
</div>
|
||||||
@@ -31,9 +32,7 @@ function Section({ title, children, defaultOpen = true }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PasswordInput({ value, onChange, placeholder }: {
|
function PasswordInput({ value, onChange, placeholder }: {
|
||||||
value: string
|
value: string; onChange: (v: string) => void; placeholder?: string
|
||||||
onChange: (v: string) => void
|
|
||||||
placeholder?: string
|
|
||||||
}) {
|
}) {
|
||||||
const [show, setShow] = useState(false)
|
const [show, setShow] = useState(false)
|
||||||
return (
|
return (
|
||||||
@@ -56,40 +55,48 @@ function PasswordInput({ value, onChange, placeholder }: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Hint box ──────────────────────────────────────────────────────────────────
|
||||||
|
function Hint({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 text-xs">
|
||||||
|
<Info size={14} className="shrink-0 mt-0.5" />
|
||||||
|
<div>{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Main Page ─────────────────────────────────────────────────────────────────
|
// ── Main Page ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function TTLockPage() {
|
export function TTLockPage() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const slug = user?.hotelSlug ?? ''
|
const slug = user?.hotelSlug ?? ''
|
||||||
|
|
||||||
const [config, setConfig] = useState<TTLockConfig | null>(null)
|
const [config, setConfig] = useState<TTLockConfig | null>(null)
|
||||||
const [locks, setLocks] = useState<TTLockDevice[]>([])
|
const [mappings, setMappings] = useState<RoomLockMapping[]>([])
|
||||||
const [mappings, setMappings] = useState<RoomLockMapping[]>([])
|
const [rooms, setRooms] = useState<Room[]>([])
|
||||||
const [rooms, setRooms] = useState<Room[]>([])
|
|
||||||
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 [tokenLoading, setTokenLoading] = useState(false)
|
const [testing, setTesting] = useState(false)
|
||||||
const [locksLoading, setLocksLoading] = 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)
|
|
||||||
|
|
||||||
// Form state
|
// Форма настроек
|
||||||
const [isEnabled, setIsEnabled] = useState(false)
|
const [isEnabled, setIsEnabled] = useState(false)
|
||||||
const [clientId, setClientId] = useState('')
|
const [clientId, setClientId] = useState('')
|
||||||
const [clientSecret, setClientSecret] = useState('')
|
const [clientSecret, setClientSecret] = useState('')
|
||||||
const [username, setUsername] = useState('')
|
const [cardSectors, setCardSectors] = useState('1,2,3,4,5,6,7,8,9,10')
|
||||||
const [password, setPassword] = useState('')
|
const [comPort, setComPort] = useState('')
|
||||||
const [workstationId, setWorkstationId] = useState<string>('')
|
const [workstationId, setWorkstationId] = useState('')
|
||||||
|
|
||||||
// Room mapping
|
// Форма привязки замков
|
||||||
const [selectedRoom, setSelectedRoom] = useState('')
|
const [addRoomId, setAddRoomId] = useState('')
|
||||||
const [selectedLock, setSelectedLock] = useState<number | ''>('')
|
const [addLockMac, setAddLockMac] = useState('')
|
||||||
const [mappingLoading, setMappingLoading] = useState(false)
|
const [addLockName, setAddLockName] = useState('')
|
||||||
|
const [addLoading, setAddLoading] = useState(false)
|
||||||
|
|
||||||
const showSuccess = (msg: string) => {
|
const showSuccess = (msg: string) => {
|
||||||
setSuccess(msg)
|
setSuccess(msg); setTimeout(() => setSuccess(null), 3500)
|
||||||
setTimeout(() => setSuccess(null), 3000)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -105,7 +112,8 @@ export function TTLockPage() {
|
|||||||
setConfig(cfg)
|
setConfig(cfg)
|
||||||
setIsEnabled(cfg.isEnabled)
|
setIsEnabled(cfg.isEnabled)
|
||||||
setClientId(cfg.clientId)
|
setClientId(cfg.clientId)
|
||||||
setUsername(cfg.ttlockUsername)
|
setCardSectors(cfg.cardSectors || '1,2,3,4,5,6,7,8,9,10')
|
||||||
|
setComPort(cfg.comPort)
|
||||||
setWorkstationId(cfg.workstationId ?? '')
|
setWorkstationId(cfg.workstationId ?? '')
|
||||||
setMappings(maps)
|
setMappings(maps)
|
||||||
setRooms(roomList)
|
setRooms(roomList)
|
||||||
@@ -125,13 +133,14 @@ export function TTLockPage() {
|
|||||||
try {
|
try {
|
||||||
await api.ttlock.updateConfig(slug, {
|
await api.ttlock.updateConfig(slug, {
|
||||||
isEnabled,
|
isEnabled,
|
||||||
clientId: clientId.trim(),
|
clientId: clientId.trim(),
|
||||||
clientSecret: clientSecret.trim() || undefined,
|
clientSecret: clientSecret.trim() || undefined,
|
||||||
ttlockUsername: username.trim(),
|
cardSectors: cardSectors.trim(),
|
||||||
ttlockPassword: password.trim() || undefined,
|
comPort: comPort.trim(),
|
||||||
workstationId: workstationId || null,
|
workstationId: workstationId || null,
|
||||||
})
|
})
|
||||||
showSuccess('Настройки сохранены')
|
showSuccess('Настройки сохранены')
|
||||||
|
setClientSecret('')
|
||||||
await load()
|
await load()
|
||||||
} catch {
|
} catch {
|
||||||
setError('Ошибка сохранения')
|
setError('Ошибка сохранения')
|
||||||
@@ -140,51 +149,38 @@ export function TTLockPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTestToken = async () => {
|
const handleTest = async () => {
|
||||||
setTokenLoading(true)
|
setTesting(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const r = await api.ttlock.testToken(slug)
|
await api.ttlock.testEncoder(slug)
|
||||||
showSuccess(`Авторизация успешна, токен действителен до ${new Date(r.expiresAt).toLocaleString('ru')}`)
|
showSuccess('Энкодер найден и отвечает — всё готово к работе!')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Ошибка авторизации в TTLock')
|
setError(e instanceof Error ? e.message : 'Ошибка проверки энкодера')
|
||||||
} finally {
|
} finally {
|
||||||
setTokenLoading(false)
|
setTesting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLoadLocks = async () => {
|
const handleAddMapping = async () => {
|
||||||
setLocksLoading(true)
|
if (!addRoomId || !addLockMac.trim()) return
|
||||||
|
setAddLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const list = await api.ttlock.getLocks(slug)
|
|
||||||
setLocks(list)
|
|
||||||
if (list.length === 0) setError('Замки не найдены. Убедитесь, что замки добавлены в аккаунт TTLock.')
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Ошибка загрузки замков')
|
|
||||||
} finally {
|
|
||||||
setLocksLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMapRoom = async () => {
|
|
||||||
if (!selectedRoom || !selectedLock) return
|
|
||||||
setMappingLoading(true)
|
|
||||||
try {
|
|
||||||
const lock = locks.find(l => l.lockId === selectedLock)
|
|
||||||
await api.ttlock.mapRoom(slug, {
|
await api.ttlock.mapRoom(slug, {
|
||||||
roomId: selectedRoom,
|
roomId: addRoomId,
|
||||||
lockId: selectedLock as number,
|
lockMac: addLockMac.trim(),
|
||||||
lockName: lock?.name,
|
lockName: addLockName.trim() || undefined,
|
||||||
})
|
})
|
||||||
setSelectedRoom('')
|
setAddRoomId('')
|
||||||
setSelectedLock('')
|
setAddLockMac('')
|
||||||
|
setAddLockName('')
|
||||||
await load()
|
await load()
|
||||||
showSuccess('Замок привязан к номеру')
|
showSuccess('Замок привязан к номеру')
|
||||||
} catch {
|
} catch (e) {
|
||||||
setError('Ошибка привязки')
|
setError(e instanceof Error ? e.message : 'Ошибка привязки замка')
|
||||||
} finally {
|
} finally {
|
||||||
setMappingLoading(false)
|
setAddLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,39 +203,46 @@ 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 onlineWorkstations = workstations.filter(w => w.isOnline)
|
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">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-xl bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-xl bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
|
||||||
<KeyRound size={20} className="text-brand-600 dark:text-brand-400" />
|
<KeyRound size={20} className="text-brand-600 dark:text-brand-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">Электронные замки TTLock</h1>
|
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">Электронные замки TTLock</h1>
|
||||||
<p className="text-sm text-slate-500">Автоматическая выдача карт-ключей гостям при заезде</p>
|
<p className="text-sm text-slate-500">Автоматическая выдача карт-ключей через программу TTHotel</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status messages */}
|
{/* Alerts */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-start gap-2 p-4 rounded-xl bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm">
|
<div className="flex items-start gap-2 p-4 rounded-xl bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm">
|
||||||
<AlertCircle size={16} className="mt-0.5 shrink-0" />
|
<AlertCircle size={16} className="mt-0.5 shrink-0" />
|
||||||
<span>{error}</span>
|
<span className="flex-1">{error}</span>
|
||||||
<button className="ml-auto" onClick={() => setError(null)}><X size={14} /></button>
|
<button onClick={() => setError(null)}><X size={14} /></button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{success && (
|
{success && (
|
||||||
<div className="flex items-center gap-2 p-4 rounded-xl bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-400 text-sm">
|
<div className="flex items-center gap-2 p-4 rounded-xl bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-400 text-sm">
|
||||||
<Check size={16} />
|
<Check size={16} className="shrink-0" />
|
||||||
<span>{success}</span>
|
<span>{success}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── API Credentials ── */}
|
{/* ── Шаг 1: Подключение к TTHotel ── */}
|
||||||
<Section title="Подключение к TTLock Open Platform">
|
<Section title="Шаг 1 — Подключение к TTHotel">
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{/* Enable toggle */}
|
<Hint>
|
||||||
|
Откройте программу <strong>TTHotel</strong> на Windows-компьютере →{' '}
|
||||||
|
<strong>Настройки → Интеграции</strong>. Скопируйте поля Client ID и Client Secret.
|
||||||
|
</Hint>
|
||||||
|
|
||||||
|
{/* Включить/выключить */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-slate-800 dark:text-slate-200">Активировать модуль</p>
|
<p className="font-medium text-slate-800 dark:text-slate-200">Активировать модуль</p>
|
||||||
@@ -259,16 +262,6 @@ export function TTLockPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 text-sm text-blue-700 dark:text-blue-300">
|
|
||||||
<p className="font-medium mb-1">Как получить Client ID и Secret</p>
|
|
||||||
<ol className="list-decimal ml-4 space-y-1 text-xs">
|
|
||||||
<li>Зарегистрируйтесь на <strong>euopen.ttlock.com</strong> (EU) или <strong>open.ttlock.com</strong></li>
|
|
||||||
<li>Создайте приложение в разделе «My App»</li>
|
|
||||||
<li>Скопируйте Client ID и Client Secret из настроек приложения</li>
|
|
||||||
<li>Логин/пароль — это ваш аккаунт TTLock (мобильное приложение)</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="form-label">Client ID</label>
|
<label className="form-label">Client ID</label>
|
||||||
@@ -276,33 +269,38 @@ export function TTLockPage() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={clientId}
|
value={clientId}
|
||||||
onChange={e => setClientId(e.target.value)}
|
onChange={e => setClientId(e.target.value)}
|
||||||
placeholder="Из TTLock Open Platform"
|
placeholder="Из TTHotel → Настройки → Интеграции"
|
||||||
className="input w-full"
|
className="input w-full font-mono text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="form-label">Client Secret</label>
|
<label className="form-label">Client Secret</label>
|
||||||
<PasswordInput value={clientSecret} onChange={setClientSecret} placeholder="Оставьте пустым — не изменится" />
|
<PasswordInput
|
||||||
</div>
|
value={clientSecret}
|
||||||
<div>
|
onChange={setClientSecret}
|
||||||
<label className="form-label">Логин TTLock (email)</label>
|
placeholder={config?.clientId ? '••••••• (не изменится)' : 'Из TTHotel → Интеграции → Показать'}
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={username}
|
|
||||||
onChange={e => setUsername(e.target.value)}
|
|
||||||
placeholder="admin@hotel.ru"
|
|
||||||
className="input w-full"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label className="form-label">Пароль TTLock</label>
|
|
||||||
<PasswordInput value={password} onChange={setPassword} placeholder="Оставьте пустым — не изменится" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Workstation selection */}
|
<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>
|
<div>
|
||||||
<label className="form-label">Рабочее место с картридером</label>
|
<label className="form-label">Рабочее место (ПК с энкодером)</label>
|
||||||
<select
|
<select
|
||||||
value={workstationId}
|
value={workstationId}
|
||||||
onChange={e => setWorkstationId(e.target.value)}
|
onChange={e => setWorkstationId(e.target.value)}
|
||||||
@@ -311,116 +309,146 @@ export function TTLockPage() {
|
|||||||
<option value="">— Выберите рабочее место —</option>
|
<option value="">— Выберите рабочее место —</option>
|
||||||
{workstations.map(w => (
|
{workstations.map(w => (
|
||||||
<option key={w.id} value={w.id}>
|
<option key={w.id} value={w.id}>
|
||||||
{w.name}
|
{w.name} {w.isOnline ? '✓ онлайн' : '✗ офлайн'}
|
||||||
{w.isOnline ? ' ✓ онлайн' : ' ✗ офлайн'}
|
{w.hostname ? ` (${w.hostname})` : ''}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{workstationId && !workstations.find(w => w.id === workstationId)?.isOnline && (
|
{selectedWs && !selectedWs.isOnline && (
|
||||||
<p className="text-xs text-amber-600 mt-1 flex items-center gap-1">
|
<p className="text-xs text-amber-600 dark:text-amber-400 mt-1 flex items-center gap-1">
|
||||||
<AlertCircle size={12} />
|
<AlertCircle size={12} />
|
||||||
Это рабочее место сейчас офлайн. Убедитесь, что агент запущен перед выдачей карт.
|
Агент на этом компьютере сейчас офлайн. Для выдачи карт агент должен быть запущен.
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{onlineWorkstations.length === 0 && (
|
|
||||||
<p className="text-xs text-red-500 mt-1">
|
|
||||||
Нет онлайн-рабочих мест. Сначала подключите агент на Windows-компьютере с картридером.
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-2">
|
{/* 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>
|
||||||
|
<label className="form-label">Секторы карты</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={cardSectors}
|
||||||
|
onChange={e => setCardSectors(e.target.value)}
|
||||||
|
placeholder="1,2,3,4,5,6,7,8,9,10"
|
||||||
|
className="input w-full font-mono"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
Секторы Mifare-карты, которые использует TTHotel. Посмотрите в настройках TTHotel.
|
||||||
|
Обычно: <code className="bg-slate-100 dark:bg-slate-800 px-1 rounded">1,2,3,4,5,6,7,8,9,10</code>
|
||||||
|
</p>
|
||||||
|
</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>
|
||||||
<button onClick={handleTestToken} disabled={tokenLoading || !clientId} className="btn-secondary flex items-center gap-2">
|
<button
|
||||||
{tokenLoading ? <RefreshCw size={15} className="animate-spin" /> : <Wifi size={15} />}
|
onClick={handleTest}
|
||||||
Проверить подключение
|
disabled={testing || !workstationId || !comPort}
|
||||||
|
className="btn-secondary flex items-center gap-2"
|
||||||
|
title={!workstationId || !comPort ? 'Сначала заполните рабочее место и COM-порт' : ''}
|
||||||
|
>
|
||||||
|
{testing ? <RefreshCw size={15} className="animate-spin" /> : <Monitor size={15} />}
|
||||||
|
Проверить энкодер
|
||||||
</button>
|
</button>
|
||||||
{config?.hasToken && (
|
|
||||||
<span className="text-xs text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
|
|
||||||
<Check size={12} /> Токен активен
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* ── Room → Lock mapping ── */}
|
{/* ── Шаг 3: Привязка номеров к замкам ── */}
|
||||||
<Section title="Привязка номеров к замкам">
|
<Section title="Шаг 3 — Привязка номеров к замкам">
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
<Hint>
|
||||||
Каждому номеру отеля назначается конкретный замок TTLock. При выдаче карты гостю
|
MAC-адрес замка: войдите на <strong>lock.ttlock.com</strong> →{' '}
|
||||||
система автоматически определит нужный замок по номеру бронирования.
|
нажмите <strong>Manage</strong> у нужного замка → иконка шестерёнки →{' '}
|
||||||
</p>
|
<strong>Basic</strong> → скопируйте поле <strong>MAC</strong>.
|
||||||
|
Введите без двоеточий: <code className="bg-blue-100 dark:bg-blue-900/40 px-1 rounded font-mono">42A6BBF5ECE5</code>
|
||||||
|
</Hint>
|
||||||
|
|
||||||
{/* Load locks button */}
|
{/* Форма добавления */}
|
||||||
<div className="flex items-center gap-3">
|
{unmappedRooms.length > 0 && (
|
||||||
<button onClick={handleLoadLocks} disabled={locksLoading || !config?.hasToken} className="btn-secondary flex items-center gap-2">
|
<div className="p-4 bg-slate-50 dark:bg-slate-800/50 rounded-xl space-y-3">
|
||||||
{locksLoading ? <RefreshCw size={15} className="animate-spin" /> : <RefreshCw size={15} />}
|
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">Добавить привязку</p>
|
||||||
Загрузить замки из TTLock
|
<div className="grid grid-cols-3 gap-3">
|
||||||
</button>
|
<div>
|
||||||
{locks.length > 0 && (
|
<label className="form-label">Номер</label>
|
||||||
<span className="text-xs text-slate-500">Найдено: {locks.length} замков</span>
|
<select value={addRoomId} onChange={e => setAddRoomId(e.target.value)} className="input w-full">
|
||||||
)}
|
<option value="">— Выберите —</option>
|
||||||
{!config?.hasToken && (
|
{unmappedRooms.map(r => (
|
||||||
<span className="text-xs text-amber-600">Сначала проверьте подключение к TTLock</span>
|
<option key={r.id} value={r.id}>
|
||||||
)}
|
№{r.number}{r.name ? ` ${r.name}` : ''}
|
||||||
</div>
|
</option>
|
||||||
|
))}
|
||||||
{/* Add mapping */}
|
</select>
|
||||||
{locks.length > 0 && unmappedRooms.length > 0 && (
|
</div>
|
||||||
<div className="flex items-end gap-3 p-4 bg-slate-50 dark:bg-slate-800/50 rounded-xl">
|
<div>
|
||||||
<div className="flex-1">
|
<label className="form-label">MAC замка</label>
|
||||||
<label className="form-label">Номер</label>
|
<input
|
||||||
<select value={selectedRoom} onChange={e => setSelectedRoom(e.target.value)} className="input w-full">
|
type="text"
|
||||||
<option value="">— Выберите номер —</option>
|
value={addLockMac}
|
||||||
{unmappedRooms.map(r => (
|
onChange={e => setAddLockMac(e.target.value.replace(/[^0-9A-Fa-f:]/g, '').toUpperCase())}
|
||||||
<option key={r.id} value={r.id}>{r.number} {r.name && `— ${r.name}`}</option>
|
placeholder="42A6BBF5ECE5"
|
||||||
))}
|
maxLength={17}
|
||||||
</select>
|
className="input w-full font-mono text-sm"
|
||||||
</div>
|
/>
|
||||||
<div className="flex-1">
|
</div>
|
||||||
<label className="form-label">Замок TTLock</label>
|
<div>
|
||||||
<select
|
<label className="form-label">Название (необяз.)</label>
|
||||||
value={selectedLock}
|
<input
|
||||||
onChange={e => setSelectedLock(e.target.value ? Number(e.target.value) : '')}
|
type="text"
|
||||||
className="input w-full"
|
value={addLockName}
|
||||||
>
|
onChange={e => setAddLockName(e.target.value)}
|
||||||
<option value="">— Выберите замок —</option>
|
placeholder="Замок номера 101"
|
||||||
{locks.map(l => (
|
className="input w-full"
|
||||||
<option key={l.lockId} value={l.lockId}>{l.name} (#{l.lockId})</option>
|
/>
|
||||||
))}
|
</div>
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleMapRoom}
|
onClick={handleAddMapping}
|
||||||
disabled={!selectedRoom || !selectedLock || mappingLoading}
|
disabled={!addRoomId || !addLockMac.trim() || addLoading}
|
||||||
className="btn-primary flex items-center gap-2 shrink-0"
|
className="btn-primary flex items-center gap-2"
|
||||||
>
|
>
|
||||||
{mappingLoading ? <RefreshCw size={15} className="animate-spin" /> : <Plus size={15} />}
|
{addLoading ? <RefreshCw size={15} className="animate-spin" /> : <Plus size={15} />}
|
||||||
Привязать
|
Привязать
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Current mappings */}
|
{/* Список привязок */}
|
||||||
{mappings.length > 0 ? (
|
{mappings.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{mappings.map(m => (
|
{mappings.map(m => (
|
||||||
<div key={m.roomId} className="flex items-center justify-between p-3 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900">
|
<div
|
||||||
|
key={m.roomId}
|
||||||
|
className="flex items-center justify-between p-3 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900"
|
||||||
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 rounded-lg bg-brand-50 dark:bg-brand-900/20 flex items-center justify-center">
|
<div className="w-8 h-8 rounded-lg bg-brand-50 dark:bg-brand-900/20 flex items-center justify-center shrink-0">
|
||||||
<Lock size={14} className="text-brand-600 dark:text-brand-400" />
|
<Lock size={14} className="text-brand-600 dark:text-brand-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">
|
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">
|
||||||
Номер {m.roomNumber}{m.roomName ? ` — ${m.roomName}` : ''}
|
Номер {m.roomNumber}{m.roomName ? ` — ${m.roomName}` : ''}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-slate-500">
|
<p className="text-xs text-slate-500 font-mono">
|
||||||
{m.lockName ?? `Замок #${m.lockId}`}
|
{m.lockMac}
|
||||||
<span className="text-slate-400 ml-1">(ID: {m.lockId})</span>
|
{m.lockName && <span className="text-slate-400 font-sans ml-2">{m.lockName}</span>}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -436,54 +464,20 @@ export function TTLockPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8 text-slate-400 dark:text-slate-600">
|
<div className="text-center py-8 text-slate-400 dark:text-slate-600">
|
||||||
<Unlock size={32} className="mx-auto mb-2 opacity-50" />
|
<Unlock size={32} className="mx-auto mb-2 opacity-40" />
|
||||||
<p className="text-sm">Нет привязанных замков</p>
|
<p className="text-sm">Нет привязанных замков</p>
|
||||||
<p className="text-xs mt-1">Загрузите замки из TTLock и привяжите их к номерам</p>
|
<p className="text-xs mt-1">Добавьте MAC-адреса замков для каждого номера</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{rooms.length > 0 && unmappedRooms.length === 0 && mappings.length > 0 && (
|
||||||
|
<p className="text-xs text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
|
||||||
|
<Check size={12} /> Все номера привязаны к замкам
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* ── Agent instructions ── */}
|
|
||||||
<Section title="Как работает интеграция (без шлюза)" defaultOpen={false}>
|
|
||||||
<div className="space-y-4 text-sm text-slate-600 dark:text-slate-400">
|
|
||||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 text-blue-800 dark:text-blue-300">
|
|
||||||
<p className="font-medium mb-2">Архитектура для замков без шлюза</p>
|
|
||||||
<p className="text-xs mb-2">
|
|
||||||
USB-программатор E3/E4 от TTLock требует подключения к интернету через шлюз G2/G3.
|
|
||||||
Для автономных замков без шлюза карты программируются через <strong>Bluetooth</strong>:
|
|
||||||
замок запоминает UID карты при непосредственном контакте.
|
|
||||||
</p>
|
|
||||||
<ol className="list-decimal ml-4 space-y-1 text-xs">
|
|
||||||
<li>Менеджер прикладывает карту к считывателю на ресепшен</li>
|
|
||||||
<li>Нажимает «Выдать ключ» в PMS → команда идёт агенту</li>
|
|
||||||
<li>Агент через Bluetooth (адаптер в ПК) подключается к замку</li>
|
|
||||||
<li>Замок считывает UID карты и записывает в свою память с датами доступа</li>
|
|
||||||
<li>Карта работает у двери номера автономно, без интернета</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-slate-50 dark:bg-slate-800 rounded-lg p-4">
|
|
||||||
<p className="font-medium text-slate-700 dark:text-slate-300 mb-2">Требования к компьютеру ресепшена:</p>
|
|
||||||
<ul className="space-y-1 text-xs list-disc ml-4">
|
|
||||||
<li>Windows 10 или 11</li>
|
|
||||||
<li>Bluetooth-адаптер (для BLE-соединения с замками, радиус ~10м)</li>
|
|
||||||
<li>USB RFID/IC считыватель карт (для чтения UID карты гостя)</li>
|
|
||||||
<li>Агент HotelSync версии 1.1.0 и выше с поддержкой BLE</li>
|
|
||||||
<li>Замки синхронизированы с TTLock-аккаунтом через мобильное приложение</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-4 text-amber-700 dark:text-amber-400">
|
|
||||||
<p className="font-medium mb-1">Альтернатива: шлюз G2/G3</p>
|
|
||||||
<p className="text-xs">
|
|
||||||
Если установить шлюз TTLock, карты можно программировать через USB-программатор E3/E4
|
|
||||||
удалённо через облако — без Bluetooth и без физического доступа к замку.
|
|
||||||
Это более надёжный вариант для большого отеля.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user