feat: TTLock integration + fix agent offline status

- Add TTLock electronic lock module: settings page, room-lock mapping,
  card issuance via agent command (ttlock:write_card)
- Fix agent offline: EquipmentPage now polls every 30s + reacts to
  workstation:online/offline WebSocket events instantly
- Backend broadcasts workstation online/offline events to hotel clients
  via existing WebSocket (agent-ws.ts)
- New DB migration 048_ttlock.sql: hotel_ttlock_config, room_lock_mappings,
  card_issuances tables
- New backend routes: GET/PATCH ttlock/config, POST ttlock/token,
  GET ttlock/locks, POST/DELETE ttlock/room-locks, POST issue-card
- "Выдать ключ (TTLock)" button in BookingDetailPanel for confirmed/checked_in
- TTLock page added to sidebar under Настройки

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-31 15:46:17 +03:00
parent 7a3772d1cf
commit be92d026e8
11 changed files with 1095 additions and 4 deletions

View File

@@ -0,0 +1,52 @@
-- TTLock integration: credentials, room-to-lock mapping, card issuance log
-- TTLock API credentials per hotel (Open Platform)
CREATE TABLE IF NOT EXISTS hotel_ttlock_config (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE,
is_enabled BOOLEAN NOT NULL DEFAULT false,
client_id TEXT NOT NULL DEFAULT '',
client_secret TEXT NOT NULL DEFAULT '',
ttlock_username TEXT NOT NULL DEFAULT '',
ttlock_password TEXT NOT NULL DEFAULT '',
access_token TEXT,
refresh_token TEXT,
token_expires_at TIMESTAMPTZ,
-- Which workstation has the card encoder attached
workstation_id UUID REFERENCES workstations(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(hotel_id)
);
-- Mapping: room → TTLock lock_id
CREATE TABLE IF NOT EXISTS room_lock_mappings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE,
room_id UUID NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
lock_id BIGINT NOT NULL,
lock_name TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(hotel_id, room_id)
);
-- Log of every card issuance
CREATE TABLE IF NOT EXISTS card_issuances (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE,
booking_id UUID NOT NULL REFERENCES bookings(id) ON DELETE CASCADE,
room_id UUID NOT NULL REFERENCES rooms(id),
lock_id BIGINT NOT NULL,
ttlock_card_id BIGINT,
issued_by UUID REFERENCES users(id),
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
check_in TIMESTAMPTZ NOT NULL,
check_out TIMESTAMPTZ NOT NULL,
is_revoked BOOLEAN NOT NULL DEFAULT false,
revoked_at TIMESTAMPTZ,
revoked_by UUID REFERENCES users(id),
notes TEXT
);
CREATE INDEX IF NOT EXISTS idx_card_issuances_booking ON card_issuances(booking_id);
CREATE INDEX IF NOT EXISTS idx_card_issuances_hotel ON card_issuances(hotel_id);

View File

@@ -8,6 +8,7 @@ import type { SocketStream } from '@fastify/websocket'
import { WebSocket } from 'ws' import { WebSocket } from 'ws'
import { randomUUID } from 'crypto' import { randomUUID } from 'crypto'
import { db } from './db' import { db } from './db'
import { broadcast } from './routes/ws'
interface AgentSocket extends WebSocket { interface AgentSocket extends WebSocket {
agentId?: string agentId?: string
@@ -127,6 +128,7 @@ export const setupAgentWsRoute: FastifyPluginAsync = async (fastify) => {
agentSockets.set(wsId, socket) agentSockets.set(wsId, socket)
await markOnline(wsId, req.ip) await markOnline(wsId, req.ip)
broadcast(socket.hotelSlug!, { type: 'workstation:online', workstationId: wsId })
socket.on('pong', () => { socket.isAlive = true }) socket.on('pong', () => { socket.isAlive = true })
@@ -161,6 +163,7 @@ export const setupAgentWsRoute: FastifyPluginAsync = async (fastify) => {
console.log(`[agent-ws] Disconnected: ${wsId}`) console.log(`[agent-ws] Disconnected: ${wsId}`)
agentSockets.delete(wsId) agentSockets.delete(wsId)
markOffline(wsId) markOffline(wsId)
broadcast(socket.hotelSlug!, { type: 'workstation:offline', workstationId: wsId })
}) })
socket.on('error', (err) => { socket.on('error', (err) => {

View File

@@ -37,6 +37,7 @@ import chatRoutes from './routes/chat'
import workstationRoutes from './routes/workstations' import workstationRoutes from './routes/workstations'
import agentReleaseRoutes from './routes/agent-release' import agentReleaseRoutes from './routes/agent-release'
import wifiSettingsRoutes, { wifiAuthVerify } from './routes/wifi-settings' import wifiSettingsRoutes, { wifiAuthVerify } from './routes/wifi-settings'
import ttlockRoutes from './routes/ttlock'
import { setupAgentWsRoute } from './agent-ws' import { setupAgentWsRoute } from './agent-ws'
import { startJobs } from './jobs' import { startJobs } from './jobs'
@@ -126,6 +127,7 @@ export async function buildApp() {
await fastify.register(agentReleaseRoutes) await fastify.register(agentReleaseRoutes)
await fastify.register(wifiSettingsRoutes) await fastify.register(wifiSettingsRoutes)
await fastify.register(wifiAuthVerify) await fastify.register(wifiAuthVerify)
await fastify.register(ttlockRoutes)
await fastify.register(setupAgentWsRoute) await fastify.register(setupAgentWsRoute)
startJobs() startJobs()

View File

@@ -0,0 +1,399 @@
/**
* TTLock integration routes
*
* GET /api/hotels/:slug/ttlock/config — get config
* PATCH /api/hotels/:slug/ttlock/config — save config
* POST /api/hotels/:slug/ttlock/token — obtain/refresh TTLock access token
* GET /api/hotels/:slug/ttlock/locks — list locks from TTLock API
* GET /api/hotels/:slug/ttlock/room-locks — list room→lock mappings
* POST /api/hotels/:slug/ttlock/room-locks — map room to lock
* DELETE /api/hotels/:slug/ttlock/room-locks/:roomId — unmap room
* POST /api/hotels/:slug/bookings/:bookingId/issue-card — issue card via agent
* GET /api/hotels/:slug/bookings/:bookingId/cards — card issuance history
*/
import { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
import { sendCommandAndWait } from '../agent-ws'
import crypto from 'crypto'
type SlugParam = { Params: { slug: string } }
type RoomParam = { Params: { slug: string; roomId: 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> {
const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug])
return rows[0]?.id
}
/** MD5 hash for TTLock password field (their API requirement) */
function md5(text: string): string {
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 canManage = (userSlug: string | null, role: string, slug: string) =>
role === 'super_admin' || role === 'hotel_admin' || userSlug === slug
// ── GET /api/hotels/:slug/ttlock/config ─────────────────────────────────────
fastify.get<SlugParam>('/api/hotels/:slug/ttlock/config', { onRequest: [fastify.authenticate] }, async (req, reply) => {
const { slug } = req.params
if (!canManage(req.user.hotelSlug, req.user.role, slug))
return reply.code(403).send({ error: 'Forbidden' })
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { rows } = await db.query(
`SELECT id, is_enabled, client_id, ttlock_username, workstation_id,
access_token IS NOT NULL AS has_token, token_expires_at
FROM hotel_ttlock_config WHERE hotel_id = $1`,
[hotelId],
)
if (!rows[0]) {
// Return defaults
return { isEnabled: false, clientId: '', ttlockUsername: '', workstationId: null, hasToken: false }
}
const r = rows[0]
return {
isEnabled: r.is_enabled,
clientId: r.client_id,
ttlockUsername: r.ttlock_username,
workstationId: r.workstation_id,
hasToken: r.has_token,
tokenExpiresAt: r.token_expires_at,
}
})
// ── PATCH /api/hotels/:slug/ttlock/config ───────────────────────────────────
fastify.patch<SlugParam & {
Body: {
isEnabled?: boolean
clientId?: string
clientSecret?: string
ttlockUsername?: string
ttlockPassword?: string
workstationId?: string | null
}
}>('/api/hotels/:slug/ttlock/config', { onRequest: [fastify.authenticate] }, async (req, reply) => {
const { slug } = req.params
if (!canManage(req.user.hotelSlug, req.user.role, slug))
return reply.code(403).send({ error: 'Forbidden' })
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { isEnabled, clientId, clientSecret, ttlockUsername, ttlockPassword, workstationId } = req.body
await db.query(
`INSERT INTO hotel_ttlock_config (hotel_id, is_enabled, client_id, client_secret, ttlock_username, ttlock_password, workstation_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (hotel_id) DO UPDATE SET
is_enabled = COALESCE($2, hotel_ttlock_config.is_enabled),
client_id = COALESCE($3, hotel_ttlock_config.client_id),
client_secret = COALESCE(NULLIF($4, ''), hotel_ttlock_config.client_secret),
ttlock_username = COALESCE($5, hotel_ttlock_config.ttlock_username),
ttlock_password = COALESCE(NULLIF($6, ''), hotel_ttlock_config.ttlock_password),
workstation_id = $7,
access_token = NULL,
token_expires_at = NULL,
updated_at = NOW()`,
[hotelId, isEnabled ?? true, clientId ?? '', clientSecret ?? '', ttlockUsername ?? '', ttlockPassword ?? '', workstationId ?? null],
)
return { ok: true }
})
// ── POST /api/hotels/:slug/ttlock/token ─────────────────────────────────────
// Force re-auth with TTLock and store fresh token
fastify.post<SlugParam>('/api/hotels/:slug/ttlock/token', { 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 * FROM hotel_ttlock_config WHERE hotel_id = $1', [hotelId])
const cfg = rows[0]
if (!cfg?.client_id || !cfg.ttlock_username) return reply.code(400).send({ error: 'Заполните credentials' })
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 { ok: true, expiresAt }
} catch (err) {
const msg = err instanceof Error ? err.message : 'Ошибка'
return reply.code(400).send({ error: msg })
}
})
// ── GET /api/hotels/:slug/ttlock/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) => {
const { slug } = req.params
if (!canManage(req.user.hotelSlug, req.user.role, slug))
return reply.code(403).send({ error: 'Forbidden' })
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { rows } = await db.query(
`SELECT m.room_id, m.lock_id, m.lock_name, r.name AS room_name, r.number AS room_number
FROM room_lock_mappings m
JOIN rooms r ON r.id = m.room_id
WHERE m.hotel_id = $1
ORDER BY r.number`,
[hotelId],
)
return rows.map(r => ({
roomId: r.room_id,
roomName: r.room_name,
roomNumber: r.room_number,
lockId: Number(r.lock_id),
lockName: r.lock_name,
}))
})
// ── POST /api/hotels/:slug/ttlock/room-locks ─────────────────────────────────
fastify.post<SlugParam & { Body: { roomId: string; lockId: number; lockName?: string } }>(
'/api/hotels/:slug/ttlock/room-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' })
const { roomId, lockId, lockName } = req.body
await db.query(
`INSERT INTO room_lock_mappings (hotel_id, room_id, lock_id, lock_name)
VALUES ($1, $2, $3, $4)
ON CONFLICT (hotel_id, room_id) DO UPDATE SET lock_id = $3, lock_name = $4`,
[hotelId, roomId, lockId, lockName ?? null],
)
return { ok: true }
},
)
// ── DELETE /api/hotels/:slug/ttlock/room-locks/:roomId ──────────────────────
fastify.delete<RoomParam>('/api/hotels/:slug/ttlock/room-locks/:roomId', { onRequest: [fastify.authenticate] }, async (req, reply) => {
const { slug, roomId } = 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' })
await db.query('DELETE FROM room_lock_mappings WHERE hotel_id = $1 AND room_id = $2', [hotelId, roomId])
return { ok: true }
})
// ── POST /api/hotels/:slug/bookings/:bookingId/issue-card ───────────────────
fastify.post<BookingParam>(
'/api/hotels/:slug/bookings/:bookingId/issue-card',
{ onRequest: [fastify.authenticate] },
async (req, reply) => {
const { slug, bookingId } = 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' })
// Get booking + room info
const { rows: bookingRows } = await db.query(
`SELECT b.id, b.room_id, b.check_in, b.check_out, r.name AS room_name
FROM bookings b JOIN rooms r ON r.id = b.room_id
WHERE b.id = $1 AND b.hotel_id = $2`,
[bookingId, hotelId],
)
const booking = bookingRows[0]
if (!booking) return reply.code(404).send({ error: 'Бронирование не найдено' })
// Get lock mapping for this room
const { rows: lockRows } = await db.query(
'SELECT lock_id, lock_name FROM room_lock_mappings WHERE hotel_id = $1 AND room_id = $2',
[hotelId, booking.room_id],
)
const lockMapping = lockRows[0]
if (!lockMapping) return reply.code(400).send({ error: `Для номера "${booking.room_name}" не назначен замок` })
// Get TTLock config + workstation
const { rows: cfgRows } = await db.query(
'SELECT client_id, workstation_id, is_enabled FROM hotel_ttlock_config WHERE hotel_id = $1',
[hotelId],
)
const cfg = cfgRows[0]
if (!cfg?.is_enabled) return reply.code(400).send({ error: 'TTLock модуль не активирован' })
if (!cfg.workstation_id) return reply.code(400).send({ error: 'Не выбрано рабочее место с картридером' })
// 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 checkOut = new Date(booking.check_out).getTime()
let result: unknown
try {
result = await sendCommandAndWait(cfg.workstation_id, {
type: 'ttlock:write_card',
lockId: Number(lockMapping.lock_id),
clientId: cfg.client_id,
accessToken,
startDate: checkIn,
endDate: checkOut,
}, 30_000)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Агент не ответил'
return reply.code(502).send({ error: msg })
}
const agentResult = result as { ok?: boolean; error?: string; cardNumber?: string; ttlockCardId?: number }
if (!agentResult.ok) {
return reply.code(502).send({ error: agentResult.error ?? 'Ошибка записи карты' })
}
// Log issuance
const { rows: issuanceRows } = await db.query(
`INSERT INTO card_issuances
(hotel_id, booking_id, room_id, lock_id, ttlock_card_id, issued_by, check_in, check_out)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, issued_at`,
[
hotelId, bookingId, booking.room_id,
Number(lockMapping.lock_id),
agentResult.ttlockCardId ?? null,
req.user.sub,
booking.check_in, booking.check_out,
],
)
return {
ok: true,
issuanceId: issuanceRows[0].id,
issuedAt: issuanceRows[0].issued_at,
lockName: lockMapping.lock_name,
roomName: booking.room_name,
checkIn: booking.check_in,
checkOut: booking.check_out,
}
},
)
// ── GET /api/hotels/:slug/bookings/:bookingId/cards ──────────────────────────
fastify.get<BookingParam>('/api/hotels/:slug/bookings/:bookingId/cards', { onRequest: [fastify.authenticate] }, async (req, reply) => {
const { slug, bookingId } = req.params
if (!canManage(req.user.hotelSlug, req.user.role, slug))
return reply.code(403).send({ error: 'Forbidden' })
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { rows } = await db.query(
`SELECT c.*, u.name AS issued_by_name
FROM card_issuances c
LEFT JOIN users u ON u.id = c.issued_by
WHERE c.hotel_id = $1 AND c.booking_id = $2
ORDER BY c.issued_at DESC`,
[hotelId, bookingId],
)
return rows.map(r => ({
id: r.id,
lockId: Number(r.lock_id),
issuedAt: r.issued_at,
issuedBy: r.issued_by_name,
checkIn: r.check_in,
checkOut: r.check_out,
isRevoked: r.is_revoked,
revokedAt: r.revoked_at,
}))
})
}
export default ttlockRoutes

View File

@@ -43,6 +43,7 @@ import { SchedulePage } from './pages/SchedulePage'
import { BillingPage } from './pages/BillingPage' import { BillingPage } from './pages/BillingPage'
import { EquipmentPage } from './pages/EquipmentPage' import { EquipmentPage } from './pages/EquipmentPage'
import { WiFiPage } from './pages/WiFiPage' import { WiFiPage } from './pages/WiFiPage'
import { TTLockPage } from './pages/TTLockPage'
import { ModuleGuard } from './components/ModuleGuard' import { ModuleGuard } from './components/ModuleGuard'
export default function App() { export default function App() {
@@ -96,6 +97,7 @@ export default function App() {
<Route path="/billing" element={<BillingPage />} /> <Route path="/billing" element={<BillingPage />} />
<Route path="/equipment" element={<EquipmentPage />} /> <Route path="/equipment" element={<EquipmentPage />} />
<Route path="/wifi" element={<WiFiPage />} /> <Route path="/wifi" element={<WiFiPage />} />
<Route path="/ttlock" element={<TTLockPage />} />
</Route> </Route>
<Route path="/" element={<Navigate to="/login" replace />} /> <Route path="/" element={<Navigate to="/login" replace />} />

View File

@@ -4,7 +4,7 @@ import {
X, Mail, Calendar, Users, CreditCard, Tag, CheckCircle, XCircle, X, Mail, Calendar, Users, CreditCard, Tag, CheckCircle, XCircle,
Printer, ScanLine, Banknote, Building2, Plus, Pencil, Printer, ScanLine, Banknote, Building2, Plus, Pencil,
FileText, FileCheck, Receipt, IdCard, AlertTriangle, FileText, FileCheck, Receipt, IdCard, AlertTriangle,
Trash2, Loader2, UserCheck, Baby, Search, LogIn, Trash2, Loader2, UserCheck, Baby, Search, LogIn, KeyRound,
} from 'lucide-react' } from 'lucide-react'
import { import {
cn, BOOKING_STATUS_BADGE, BOOKING_STATUS_LABELS, cn, BOOKING_STATUS_BADGE, BOOKING_STATUS_LABELS,
@@ -302,6 +302,8 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
const [earlyOutMethod, setEarlyOutMethod] = useState<'cash' | 'card' | 'transfer'>('cash') const [earlyOutMethod, setEarlyOutMethod] = useState<'cash' | 'card' | 'transfer'>('cash')
const [earlyOutSaving, setEarlyOutSaving] = useState(false) const [earlyOutSaving, setEarlyOutSaving] = useState(false)
const [cardIssuing, setCardIssuing] = useState(false)
const freeRoomsForRelocate = (rooms ?? []).filter(r => const freeRoomsForRelocate = (rooms ?? []).filter(r =>
r.id !== booking.roomId && r.id !== booking.roomId &&
!(allBookings ?? []).some(b => !(allBookings ?? []).some(b =>
@@ -392,6 +394,20 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
]) ])
} }
const handleIssueCard = async () => {
if (!slug || cardIssuing) return
setCardIssuing(true)
try {
const r = await api.ttlock.issueCard(slug, booking.id)
showToast(`✓ Ключ выдан (${r.roomName})`)
} catch (e) {
const msg = e instanceof Error ? e.message : 'Ошибка выдачи ключа'
showToast(`${msg}`)
} finally {
setCardIssuing(false)
}
}
// Passport data // Passport data
const nameParts = booking.guestName.split(' ') const nameParts = booking.guestName.split(' ')
const [passport, setPassport] = useState<PassportData>({ const [passport, setPassport] = useState<PassportData>({
@@ -1323,6 +1339,17 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
> >
<CheckCircle size={15} /> {isEarlyArrival ? `Заселить (+ ${formatCurrency(room!.earlyCheckinFee!)})` : 'Заселить'} <CheckCircle size={15} /> {isEarlyArrival ? `Заселить (+ ${formatCurrency(room!.earlyCheckinFee!)})` : 'Заселить'}
</button> </button>
{slug && (
<button
onClick={handleIssueCard}
disabled={cardIssuing}
className="w-full btn-secondary justify-center flex items-center gap-2"
title="Записать карту-ключ для гостя"
>
{cardIssuing ? <Loader2 size={15} className="animate-spin" /> : <KeyRound size={15} />}
Выдать ключ (TTLock)
</button>
)}
</> </>
) )
})()} })()}
@@ -1357,6 +1384,17 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
> >
<CheckCircle size={15} /> {isLateCheckout ? `Выселить (+ ${formatCurrency(room!.lateCheckoutFee!)})` : 'Выселить'} <CheckCircle size={15} /> {isLateCheckout ? `Выселить (+ ${formatCurrency(room!.lateCheckoutFee!)})` : 'Выселить'}
</button> </button>
{slug && (
<button
onClick={handleIssueCard}
disabled={cardIssuing}
className="w-full btn-secondary justify-center flex items-center gap-2"
title="Выдать дубликат карты-ключа"
>
{cardIssuing ? <Loader2 size={15} className="animate-spin" /> : <KeyRound size={15} />}
Выдать ключ (TTLock)
</button>
)}
</> </>
) )
})()} })()}

View File

@@ -3,7 +3,7 @@ import { useState, useEffect } from 'react'
import { import {
CalendarDays, BookOpen, BedDouble, Globe, Settings, CalendarDays, BookOpen, BedDouble, Globe, Settings,
FileText, LayoutDashboard, Building2, Users, X, Hotel, Puzzle, CalendarRange, Map, LayoutGrid, UserCog, UsersRound, FileText, LayoutDashboard, Building2, Users, X, Hotel, Puzzle, CalendarRange, Map, LayoutGrid, UserCog, UsersRound,
TrendingUp, Tag, Award, Wrench, Utensils, CalendarClock, Zap, ChevronRight, Star, CreditCard, Monitor, Wifi, TrendingUp, Tag, Award, Wrench, Utensils, CalendarClock, Zap, ChevronRight, Star, CreditCard, Monitor, Wifi, KeyRound,
} from 'lucide-react' } from 'lucide-react'
import { useAuth } from '../../contexts/AuthContext' import { useAuth } from '../../contexts/AuthContext'
import { useModules } from '../../contexts/ModulesContext' import { useModules } from '../../contexts/ModulesContext'
@@ -185,7 +185,7 @@ export function Sidebar({ open, onClose }: SidebarProps) {
prices: ['/tariffs', '/dynamic-pricing', '/discounts', '/rental'], prices: ['/tariffs', '/dynamic-pricing', '/discounts', '/rental'],
service: ['/housekeeping', '/technical', ...activeModuleItems.map(m => m.sidebarItem!.path)], service: ['/housekeeping', '/technical', ...activeModuleItems.map(m => m.sidebarItem!.path)],
management: ['/users', '/schedule', '/loyalty', '/maintenance', '/floor-map', '/channels'], management: ['/users', '/schedule', '/loyalty', '/maintenance', '/floor-map', '/channels'],
settingsGroup:['/modules', '/settings', '/billing', '/equipment', '/wifi'], settingsGroup:['/modules', '/settings', '/billing', '/equipment', '/wifi', '/ttlock'],
devGroup: ['/api-docs'], devGroup: ['/api-docs'],
} }
@@ -234,6 +234,7 @@ export function Sidebar({ open, onClose }: SidebarProps) {
{ to: '/settings', icon: Settings, label: 'Настройки' }, { to: '/settings', icon: Settings, label: 'Настройки' },
{ to: '/equipment', icon: Monitor, label: 'Оборудование' }, { to: '/equipment', icon: Monitor, label: 'Оборудование' },
{ to: '/wifi', icon: Wifi, label: 'WiFi авторизация' }, { to: '/wifi', icon: Wifi, label: 'WiFi авторизация' },
{ to: '/ttlock', icon: KeyRound, label: 'Электронные замки' },
// dev // dev
{ to: '/api-docs', icon: FileText, label: 'API & Документация' }, { to: '/api-docs', icon: FileText, label: 'API & Документация' },
] ]
@@ -417,6 +418,7 @@ export function Sidebar({ open, onClose }: SidebarProps) {
<NavItem to="/billing" icon={CreditCard} label="Тарифы и оплата" {...navItemProps} /> <NavItem to="/billing" icon={CreditCard} label="Тарифы и оплата" {...navItemProps} />
<NavItem to="/equipment" icon={Monitor} label="Оборудование" {...navItemProps} /> <NavItem to="/equipment" icon={Monitor} label="Оборудование" {...navItemProps} />
<NavItem to="/wifi" icon={Wifi} label="WiFi авторизация" {...navItemProps} /> <NavItem to="/wifi" icon={Wifi} label="WiFi авторизация" {...navItemProps} />
<NavItem to="/ttlock" icon={KeyRound} label="Эл. замки TTLock" {...navItemProps} />
</div> </div>
)} )}
</> </>

View File

@@ -11,6 +11,8 @@ export type WsMessage =
| { type: 'housekeeping_updated'; task: Record<string, unknown> } | { type: 'housekeeping_updated'; task: Record<string, unknown> }
| { type: 'housekeeping_done'; taskId: string; roomId: string; roomStatus: string } | { type: 'housekeeping_done'; taskId: string; roomId: string; roomStatus: string }
| { type: 'notification:new'; notification: Record<string, unknown> } | { type: 'notification:new'; notification: Record<string, unknown> }
| { type: 'workstation:online'; workstationId: string }
| { type: 'workstation:offline'; workstationId: string }
interface Options { interface Options {
slug: string slug: string

View File

@@ -601,6 +601,36 @@ export const api = {
regenerateToken: (slug: string) => regenerateToken: (slug: string) =>
req<{ apiToken: string }>('POST', `/api/hotels/${slug}/wifi-settings/regenerate-token`), req<{ apiToken: string }>('POST', `/api/hotels/${slug}/wifi-settings/regenerate-token`),
}, },
// ── TTLock ─────────────────────────────────────────────────────────────────
ttlock: {
getConfig: (slug: string) =>
req<TTLockConfig>('GET', `/api/hotels/${slug}/ttlock/config`),
updateConfig: (slug: string, data: TTLockConfigUpdate) =>
req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/ttlock/config`, data),
testToken: (slug: string) =>
req<{ ok: boolean; expiresAt: string }>('POST', `/api/hotels/${slug}/ttlock/token`),
getLocks: (slug: string) =>
req<TTLockDevice[]>('GET', `/api/hotels/${slug}/ttlock/locks`),
getRoomLocks: (slug: string) =>
req<RoomLockMapping[]>('GET', `/api/hotels/${slug}/ttlock/room-locks`),
mapRoom: (slug: string, data: { roomId: string; lockId: number; lockName?: string }) =>
req<{ ok: boolean }>('POST', `/api/hotels/${slug}/ttlock/room-locks`, data),
unmapRoom: (slug: string, roomId: string) =>
req<{ ok: boolean }>('DELETE', `/api/hotels/${slug}/ttlock/room-locks/${roomId}`),
issueCard: (slug: string, bookingId: string) =>
req<CardIssuanceResult>('POST', `/api/hotels/${slug}/bookings/${bookingId}/issue-card`),
getCards: (slug: string, bookingId: string) =>
req<CardIssuance[]>('GET', `/api/hotels/${slug}/bookings/${bookingId}/cards`),
},
} }
// ── Schedule ───────────────────────────────────────────────────────────────── // ── Schedule ─────────────────────────────────────────────────────────────────
@@ -1051,6 +1081,58 @@ export interface Workstation {
devices: WorkstationDevice[] | null devices: WorkstationDevice[] | null
} }
export interface TTLockConfig {
isEnabled: boolean
clientId: string
ttlockUsername: string
workstationId: string | null
hasToken: boolean
tokenExpiresAt: string | null
}
export interface TTLockConfigUpdate {
isEnabled?: boolean
clientId?: string
clientSecret?: string
ttlockUsername?: string
ttlockPassword?: string
workstationId?: string | null
}
export interface TTLockDevice {
lockId: number
name: string
}
export interface RoomLockMapping {
roomId: string
roomName: string
roomNumber: string
lockId: number
lockName: string | null
}
export interface CardIssuanceResult {
ok: boolean
issuanceId: string
issuedAt: string
lockName: string | null
roomName: string
checkIn: string
checkOut: string
}
export interface CardIssuance {
id: string
lockId: number
issuedAt: string
issuedBy: string | null
checkIn: string
checkOut: string
isRevoked: boolean
revokedAt: string | null
}
export interface WifiSettings { export interface WifiSettings {
id: string id: string
hotelId: string hotelId: string

View File

@@ -8,6 +8,7 @@ import {
import { cn } from '../lib/utils' import { cn } from '../lib/utils'
import { api, type Workstation, type WorkstationDevice } from '../lib/api' import { api, type Workstation, type WorkstationDevice } from '../lib/api'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { useHotelSocket } from '../hooks/useHotelSocket'
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -1024,7 +1025,7 @@ function WorkstationCard({
// ─── Main Page ──────────────────────────────────────────────────────────────── // ─── Main Page ────────────────────────────────────────────────────────────────
export function EquipmentPage() { export function EquipmentPage() {
const { user } = useAuth() const { user, session } = useAuth()
const slug = user?.hotelSlug ?? '' const slug = user?.hotelSlug ?? ''
const [workstations, setWorkstations] = useState<Workstation[]>([]) const [workstations, setWorkstations] = useState<Workstation[]>([])
@@ -1051,6 +1052,23 @@ export function EquipmentPage() {
useEffect(() => { load() }, [load]) useEffect(() => { load() }, [load])
// Auto-refresh every 30 s so online/offline status stays current
useEffect(() => {
const timer = setInterval(load, 30_000)
return () => clearInterval(timer)
}, [load])
// Instant update when agent connects or disconnects
useHotelSocket({
slug,
token: session?.token ?? null,
onMessage: (msg) => {
if (msg.type === 'workstation:online' || msg.type === 'workstation:offline') {
load()
}
},
})
useEffect(() => { useEffect(() => {
api.agents.getLatestRelease().then(setLatestRelease).catch(() => {}) api.agents.getLatestRelease().then(setLatestRelease).catch(() => {})
}, []) }, [])

491
src/pages/TTLockPage.tsx Normal file
View File

@@ -0,0 +1,491 @@
import { useState, useEffect, useCallback } from 'react'
import {
KeyRound, Save, RefreshCw, Check, X, AlertCircle, ChevronDown, ChevronRight,
Trash2, Plus, Eye, EyeOff, Monitor, Wifi, WifiOff, Lock, Unlock,
} from 'lucide-react'
import { api, type TTLockConfig, type TTLockDevice, type RoomLockMapping, type Workstation } from '../lib/api'
import { useAuth } from '../contexts/AuthContext'
import { cn } from '../lib/utils'
import type { Room } from '../types'
// ── Helpers ───────────────────────────────────────────────────────────────────
function Section({ title, children, defaultOpen = true }: {
title: string
children: React.ReactNode
defaultOpen?: boolean
}) {
const [open, setOpen] = useState(defaultOpen)
return (
<div className="border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden">
<button
onClick={() => setOpen(o => !o)}
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>
{open ? <ChevronDown size={16} className="text-slate-500" /> : <ChevronRight size={16} className="text-slate-500" />}
</button>
{open && <div className="p-6 bg-white dark:bg-slate-900">{children}</div>}
</div>
)
}
function PasswordInput({ value, onChange, placeholder }: {
value: string
onChange: (v: string) => void
placeholder?: string
}) {
const [show, setShow] = useState(false)
return (
<div className="relative">
<input
type={show ? 'text' : 'password'}
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
className="input pr-10 w-full"
/>
<button
type="button"
onClick={() => setShow(s => !s)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
{show ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
</div>
)
}
// ── Main Page ─────────────────────────────────────────────────────────────────
export function TTLockPage() {
const { user } = useAuth()
const slug = user?.hotelSlug ?? ''
const [config, setConfig] = useState<TTLockConfig | null>(null)
const [locks, setLocks] = useState<TTLockDevice[]>([])
const [mappings, setMappings] = useState<RoomLockMapping[]>([])
const [rooms, setRooms] = useState<Room[]>([])
const [workstations, setWorkstations] = useState<Workstation[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [tokenLoading, setTokenLoading] = useState(false)
const [locksLoading, setLocksLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
// Form state
const [isEnabled, setIsEnabled] = useState(false)
const [clientId, setClientId] = useState('')
const [clientSecret, setClientSecret] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [workstationId, setWorkstationId] = useState<string>('')
// Room mapping
const [selectedRoom, setSelectedRoom] = useState('')
const [selectedLock, setSelectedLock] = useState<number | ''>('')
const [mappingLoading, setMappingLoading] = useState(false)
const showSuccess = (msg: string) => {
setSuccess(msg)
setTimeout(() => setSuccess(null), 3000)
}
const load = useCallback(async () => {
if (!slug) return
setLoading(true)
try {
const [cfg, maps, roomList, wsList] = await Promise.all([
api.ttlock.getConfig(slug),
api.ttlock.getRoomLocks(slug),
api.rooms.list(slug),
api.workstations.list(slug),
])
setConfig(cfg)
setIsEnabled(cfg.isEnabled)
setClientId(cfg.clientId)
setUsername(cfg.ttlockUsername)
setWorkstationId(cfg.workstationId ?? '')
setMappings(maps)
setRooms(roomList)
setWorkstations(wsList)
} catch {
setError('Не удалось загрузить настройки')
} finally {
setLoading(false)
}
}, [slug])
useEffect(() => { load() }, [load])
const handleSave = async () => {
setSaving(true)
setError(null)
try {
await api.ttlock.updateConfig(slug, {
isEnabled,
clientId: clientId.trim(),
clientSecret: clientSecret.trim() || undefined,
ttlockUsername: username.trim(),
ttlockPassword: password.trim() || undefined,
workstationId: workstationId || null,
})
showSuccess('Настройки сохранены')
await load()
} catch {
setError('Ошибка сохранения')
} finally {
setSaving(false)
}
}
const handleTestToken = async () => {
setTokenLoading(true)
setError(null)
try {
const r = await api.ttlock.testToken(slug)
showSuccess(`Авторизация успешна, токен действителен до ${new Date(r.expiresAt).toLocaleString('ru')}`)
} catch (e) {
setError(e instanceof Error ? e.message : 'Ошибка авторизации в TTLock')
} finally {
setTokenLoading(false)
}
}
const handleLoadLocks = async () => {
setLocksLoading(true)
setError(null)
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, {
roomId: selectedRoom,
lockId: selectedLock as number,
lockName: lock?.name,
})
setSelectedRoom('')
setSelectedLock('')
await load()
showSuccess('Замок привязан к номеру')
} catch {
setError('Ошибка привязки')
} finally {
setMappingLoading(false)
}
}
const handleUnmap = async (roomId: string) => {
try {
await api.ttlock.unmapRoom(slug, roomId)
await load()
showSuccess('Привязка удалена')
} catch {
setError('Ошибка удаления привязки')
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<RefreshCw size={24} className="animate-spin text-brand-600" />
</div>
)
}
const unmappedRooms = rooms.filter(r => !mappings.some(m => m.roomId === r.id))
const onlineWorkstations = workstations.filter(w => w.isOnline)
return (
<div className="max-w-3xl mx-auto p-6 space-y-6">
<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">
<KeyRound size={20} className="text-brand-600 dark:text-brand-400" />
</div>
<div>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">Электронные замки TTLock</h1>
<p className="text-sm text-slate-500">Автоматическая выдача карт-ключей гостям при заезде</p>
</div>
</div>
{/* Status messages */}
{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">
<AlertCircle size={16} className="mt-0.5 shrink-0" />
<span>{error}</span>
<button className="ml-auto" onClick={() => setError(null)}><X size={14} /></button>
</div>
)}
{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">
<Check size={16} />
<span>{success}</span>
</div>
)}
{/* ── API Credentials ── */}
<Section title="Подключение к TTLock Open Platform">
<div className="space-y-5">
{/* Enable toggle */}
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-slate-800 dark:text-slate-200">Активировать модуль</p>
<p className="text-xs text-slate-500 mt-0.5">Включает кнопку «Выдать ключ» в карточке бронирования</p>
</div>
<button
onClick={() => setIsEnabled(e => !e)}
className={cn(
'relative w-11 h-6 rounded-full transition-colors',
isEnabled ? 'bg-brand-500' : 'bg-slate-300 dark:bg-slate-600',
)}
>
<span className={cn(
'absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform',
isEnabled && 'translate-x-5',
)} />
</button>
</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>
<label className="form-label">Client ID</label>
<input
type="text"
value={clientId}
onChange={e => setClientId(e.target.value)}
placeholder="Из TTLock Open Platform"
className="input w-full"
/>
</div>
<div>
<label className="form-label">Client Secret</label>
<PasswordInput value={clientSecret} onChange={setClientSecret} placeholder="Оставьте пустым — не изменится" />
</div>
<div>
<label className="form-label">Логин TTLock (email)</label>
<input
type="email"
value={username}
onChange={e => setUsername(e.target.value)}
placeholder="admin@hotel.ru"
className="input w-full"
/>
</div>
<div>
<label className="form-label">Пароль TTLock</label>
<PasswordInput value={password} onChange={setPassword} placeholder="Оставьте пустым — не изменится" />
</div>
</div>
{/* Workstation selection */}
<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 ? ' ✓ онлайн' : ' ✗ офлайн'}
</option>
))}
</select>
{workstationId && !workstations.find(w => w.id === workstationId)?.isOnline && (
<p className="text-xs text-amber-600 mt-1 flex items-center gap-1">
<AlertCircle size={12} />
Это рабочее место сейчас офлайн. Убедитесь, что агент запущен перед выдачей карт.
</p>
)}
{onlineWorkstations.length === 0 && (
<p className="text-xs text-red-500 mt-1">
Нет онлайн-рабочих мест. Сначала подключите агент на Windows-компьютере с картридером.
</p>
)}
</div>
<div className="flex items-center gap-3 pt-2">
<button onClick={handleSave} disabled={saving} className="btn-primary flex items-center gap-2">
{saving ? <RefreshCw size={15} className="animate-spin" /> : <Save size={15} />}
Сохранить
</button>
<button onClick={handleTestToken} disabled={tokenLoading || !clientId} className="btn-secondary flex items-center gap-2">
{tokenLoading ? <RefreshCw size={15} className="animate-spin" /> : <Wifi size={15} />}
Проверить подключение
</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>
</Section>
{/* ── Room → Lock mapping ── */}
<Section title="Привязка номеров к замкам">
<div className="space-y-5">
<p className="text-sm text-slate-600 dark:text-slate-400">
Каждому номеру отеля назначается конкретный замок TTLock. При выдаче карты гостю
система автоматически определит нужный замок по номеру бронирования.
</p>
{/* Load locks button */}
<div className="flex items-center gap-3">
<button onClick={handleLoadLocks} disabled={locksLoading || !config?.hasToken} className="btn-secondary flex items-center gap-2">
{locksLoading ? <RefreshCw size={15} className="animate-spin" /> : <RefreshCw size={15} />}
Загрузить замки из TTLock
</button>
{locks.length > 0 && (
<span className="text-xs text-slate-500">Найдено: {locks.length} замков</span>
)}
{!config?.hasToken && (
<span className="text-xs text-amber-600">Сначала проверьте подключение к TTLock</span>
)}
</div>
{/* Add mapping */}
{locks.length > 0 && unmappedRooms.length > 0 && (
<div className="flex items-end gap-3 p-4 bg-slate-50 dark:bg-slate-800/50 rounded-xl">
<div className="flex-1">
<label className="form-label">Номер</label>
<select value={selectedRoom} onChange={e => setSelectedRoom(e.target.value)} className="input w-full">
<option value=""> Выберите номер </option>
{unmappedRooms.map(r => (
<option key={r.id} value={r.id}>{r.number} {r.name && `${r.name}`}</option>
))}
</select>
</div>
<div className="flex-1">
<label className="form-label">Замок TTLock</label>
<select
value={selectedLock}
onChange={e => setSelectedLock(e.target.value ? Number(e.target.value) : '')}
className="input w-full"
>
<option value=""> Выберите замок </option>
{locks.map(l => (
<option key={l.lockId} value={l.lockId}>{l.name} (#{l.lockId})</option>
))}
</select>
</div>
<button
onClick={handleMapRoom}
disabled={!selectedRoom || !selectedLock || mappingLoading}
className="btn-primary flex items-center gap-2 shrink-0"
>
{mappingLoading ? <RefreshCw size={15} className="animate-spin" /> : <Plus size={15} />}
Привязать
</button>
</div>
)}
{/* Current mappings */}
{mappings.length > 0 ? (
<div className="space-y-2">
{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 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">
<Lock size={14} className="text-brand-600 dark:text-brand-400" />
</div>
<div>
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">
Номер {m.roomNumber}{m.roomName ? `${m.roomName}` : ''}
</p>
<p className="text-xs text-slate-500">
{m.lockName ?? `Замок #${m.lockId}`}
<span className="text-slate-400 ml-1">(ID: {m.lockId})</span>
</p>
</div>
</div>
<button
onClick={() => handleUnmap(m.roomId)}
className="btn-ghost p-2 text-red-500 hover:text-red-700"
title="Удалить привязку"
>
<Trash2 size={15} />
</button>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-slate-400 dark:text-slate-600">
<Unlock size={32} className="mx-auto mb-2 opacity-50" />
<p className="text-sm">Нет привязанных замков</p>
<p className="text-xs mt-1">Загрузите замки из TTLock и привяжите их к номерам</p>
</div>
)}
</div>
</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>
)
}
export default TTLockPage