From be92d026e84858b2d35fb97cf5363907a5f3a077 Mon Sep 17 00:00:00 2001 From: HotelSync Date: Tue, 31 Mar 2026 15:46:17 +0300 Subject: [PATCH] feat: TTLock integration + fix agent offline status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/migrations/048_ttlock.sql | 52 ++ backend/src/agent-ws.ts | 3 + backend/src/app.ts | 2 + backend/src/routes/ttlock.ts | 399 ++++++++++++++ src/App.tsx | 2 + .../bookings/BookingDetailPanel.tsx | 40 +- src/components/layout/Sidebar.tsx | 6 +- src/hooks/useHotelSocket.ts | 2 + src/lib/api.ts | 82 +++ src/pages/EquipmentPage.tsx | 20 +- src/pages/TTLockPage.tsx | 491 ++++++++++++++++++ 11 files changed, 1095 insertions(+), 4 deletions(-) create mode 100644 backend/migrations/048_ttlock.sql create mode 100644 backend/src/routes/ttlock.ts create mode 100644 src/pages/TTLockPage.tsx diff --git a/backend/migrations/048_ttlock.sql b/backend/migrations/048_ttlock.sql new file mode 100644 index 0000000..0e3b384 --- /dev/null +++ b/backend/migrations/048_ttlock.sql @@ -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); diff --git a/backend/src/agent-ws.ts b/backend/src/agent-ws.ts index 8e6a7aa..3845c26 100644 --- a/backend/src/agent-ws.ts +++ b/backend/src/agent-ws.ts @@ -8,6 +8,7 @@ import type { SocketStream } from '@fastify/websocket' import { WebSocket } from 'ws' import { randomUUID } from 'crypto' import { db } from './db' +import { broadcast } from './routes/ws' interface AgentSocket extends WebSocket { agentId?: string @@ -127,6 +128,7 @@ export const setupAgentWsRoute: FastifyPluginAsync = async (fastify) => { agentSockets.set(wsId, socket) await markOnline(wsId, req.ip) + broadcast(socket.hotelSlug!, { type: 'workstation:online', workstationId: wsId }) socket.on('pong', () => { socket.isAlive = true }) @@ -161,6 +163,7 @@ export const setupAgentWsRoute: FastifyPluginAsync = async (fastify) => { console.log(`[agent-ws] Disconnected: ${wsId}`) agentSockets.delete(wsId) markOffline(wsId) + broadcast(socket.hotelSlug!, { type: 'workstation:offline', workstationId: wsId }) }) socket.on('error', (err) => { diff --git a/backend/src/app.ts b/backend/src/app.ts index d807b96..e44b36b 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -37,6 +37,7 @@ import chatRoutes from './routes/chat' import workstationRoutes from './routes/workstations' import agentReleaseRoutes from './routes/agent-release' import wifiSettingsRoutes, { wifiAuthVerify } from './routes/wifi-settings' +import ttlockRoutes from './routes/ttlock' import { setupAgentWsRoute } from './agent-ws' import { startJobs } from './jobs' @@ -126,6 +127,7 @@ export async function buildApp() { await fastify.register(agentReleaseRoutes) await fastify.register(wifiSettingsRoutes) await fastify.register(wifiAuthVerify) + await fastify.register(ttlockRoutes) await fastify.register(setupAgentWsRoute) startJobs() diff --git a/backend/src/routes/ttlock.ts b/backend/src/routes/ttlock.ts new file mode 100644 index 0000000..c5c3e72 --- /dev/null +++ b/backend/src/routes/ttlock.ts @@ -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 { + 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 { + 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('/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('/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('/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('/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('/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( + '/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('/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( + '/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('/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 diff --git a/src/App.tsx b/src/App.tsx index 24a7099..ad5712e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -43,6 +43,7 @@ import { SchedulePage } from './pages/SchedulePage' import { BillingPage } from './pages/BillingPage' import { EquipmentPage } from './pages/EquipmentPage' import { WiFiPage } from './pages/WiFiPage' +import { TTLockPage } from './pages/TTLockPage' import { ModuleGuard } from './components/ModuleGuard' export default function App() { @@ -96,6 +97,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/src/components/bookings/BookingDetailPanel.tsx b/src/components/bookings/BookingDetailPanel.tsx index 12131d4..5ec5405 100644 --- a/src/components/bookings/BookingDetailPanel.tsx +++ b/src/components/bookings/BookingDetailPanel.tsx @@ -4,7 +4,7 @@ import { X, Mail, Calendar, Users, CreditCard, Tag, CheckCircle, XCircle, Printer, ScanLine, Banknote, Building2, Plus, Pencil, FileText, FileCheck, Receipt, IdCard, AlertTriangle, - Trash2, Loader2, UserCheck, Baby, Search, LogIn, + Trash2, Loader2, UserCheck, Baby, Search, LogIn, KeyRound, } from 'lucide-react' import { 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 [earlyOutSaving, setEarlyOutSaving] = useState(false) + const [cardIssuing, setCardIssuing] = useState(false) + const freeRoomsForRelocate = (rooms ?? []).filter(r => r.id !== booking.roomId && !(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 const nameParts = booking.guestName.split(' ') const [passport, setPassport] = useState({ @@ -1323,6 +1339,17 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on > {isEarlyArrival ? `Заселить (+ ${formatCurrency(room!.earlyCheckinFee!)})` : 'Заселить'} + {slug && ( + + )} ) })()} @@ -1357,6 +1384,17 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on > {isLateCheckout ? `Выселить (+ ${formatCurrency(room!.lateCheckoutFee!)})` : 'Выселить'} + {slug && ( + + )} ) })()} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 220146b..3446227 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from 'react' import { CalendarDays, BookOpen, BedDouble, Globe, Settings, 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' import { useAuth } from '../../contexts/AuthContext' import { useModules } from '../../contexts/ModulesContext' @@ -185,7 +185,7 @@ export function Sidebar({ open, onClose }: SidebarProps) { prices: ['/tariffs', '/dynamic-pricing', '/discounts', '/rental'], service: ['/housekeeping', '/technical', ...activeModuleItems.map(m => m.sidebarItem!.path)], management: ['/users', '/schedule', '/loyalty', '/maintenance', '/floor-map', '/channels'], - settingsGroup:['/modules', '/settings', '/billing', '/equipment', '/wifi'], + settingsGroup:['/modules', '/settings', '/billing', '/equipment', '/wifi', '/ttlock'], devGroup: ['/api-docs'], } @@ -234,6 +234,7 @@ export function Sidebar({ open, onClose }: SidebarProps) { { to: '/settings', icon: Settings, label: 'Настройки' }, { to: '/equipment', icon: Monitor, label: 'Оборудование' }, { to: '/wifi', icon: Wifi, label: 'WiFi авторизация' }, + { to: '/ttlock', icon: KeyRound, label: 'Электронные замки' }, // dev { to: '/api-docs', icon: FileText, label: 'API & Документация' }, ] @@ -417,6 +418,7 @@ export function Sidebar({ open, onClose }: SidebarProps) { + )} diff --git a/src/hooks/useHotelSocket.ts b/src/hooks/useHotelSocket.ts index cdb7118..a7cc450 100644 --- a/src/hooks/useHotelSocket.ts +++ b/src/hooks/useHotelSocket.ts @@ -11,6 +11,8 @@ export type WsMessage = | { type: 'housekeeping_updated'; task: Record } | { type: 'housekeeping_done'; taskId: string; roomId: string; roomStatus: string } | { type: 'notification:new'; notification: Record } + | { type: 'workstation:online'; workstationId: string } + | { type: 'workstation:offline'; workstationId: string } interface Options { slug: string diff --git a/src/lib/api.ts b/src/lib/api.ts index 4bd91f0..073797c 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -601,6 +601,36 @@ export const api = { regenerateToken: (slug: string) => req<{ apiToken: string }>('POST', `/api/hotels/${slug}/wifi-settings/regenerate-token`), }, + + // ── TTLock ───────────────────────────────────────────────────────────────── + ttlock: { + getConfig: (slug: string) => + req('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('GET', `/api/hotels/${slug}/ttlock/locks`), + + getRoomLocks: (slug: string) => + req('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('POST', `/api/hotels/${slug}/bookings/${bookingId}/issue-card`), + + getCards: (slug: string, bookingId: string) => + req('GET', `/api/hotels/${slug}/bookings/${bookingId}/cards`), + }, } // ── Schedule ───────────────────────────────────────────────────────────────── @@ -1051,6 +1081,58 @@ export interface Workstation { 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 { id: string hotelId: string diff --git a/src/pages/EquipmentPage.tsx b/src/pages/EquipmentPage.tsx index 1da3f33..52cecf0 100644 --- a/src/pages/EquipmentPage.tsx +++ b/src/pages/EquipmentPage.tsx @@ -8,6 +8,7 @@ import { import { cn } from '../lib/utils' import { api, type Workstation, type WorkstationDevice } from '../lib/api' import { useAuth } from '../contexts/AuthContext' +import { useHotelSocket } from '../hooks/useHotelSocket' // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -1024,7 +1025,7 @@ function WorkstationCard({ // ─── Main Page ──────────────────────────────────────────────────────────────── export function EquipmentPage() { - const { user } = useAuth() + const { user, session } = useAuth() const slug = user?.hotelSlug ?? '' const [workstations, setWorkstations] = useState([]) @@ -1051,6 +1052,23 @@ export function EquipmentPage() { 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(() => { api.agents.getLatestRelease().then(setLatestRelease).catch(() => {}) }, []) diff --git a/src/pages/TTLockPage.tsx b/src/pages/TTLockPage.tsx new file mode 100644 index 0000000..3a17a4d --- /dev/null +++ b/src/pages/TTLockPage.tsx @@ -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 ( +
+ + {open &&
{children}
} +
+ ) +} + +function PasswordInput({ value, onChange, placeholder }: { + value: string + onChange: (v: string) => void + placeholder?: string +}) { + const [show, setShow] = useState(false) + return ( +
+ onChange(e.target.value)} + placeholder={placeholder} + className="input pr-10 w-full" + /> + +
+ ) +} + +// ── Main Page ───────────────────────────────────────────────────────────────── + +export function TTLockPage() { + const { user } = useAuth() + const slug = user?.hotelSlug ?? '' + + const [config, setConfig] = useState(null) + const [locks, setLocks] = useState([]) + const [mappings, setMappings] = useState([]) + const [rooms, setRooms] = useState([]) + const [workstations, setWorkstations] = useState([]) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [tokenLoading, setTokenLoading] = useState(false) + const [locksLoading, setLocksLoading] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(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('') + + // Room mapping + const [selectedRoom, setSelectedRoom] = useState('') + const [selectedLock, setSelectedLock] = useState('') + 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 ( +
+ +
+ ) + } + + const unmappedRooms = rooms.filter(r => !mappings.some(m => m.roomId === r.id)) + const onlineWorkstations = workstations.filter(w => w.isOnline) + + return ( +
+
+
+ +
+
+

Электронные замки TTLock

+

Автоматическая выдача карт-ключей гостям при заезде

+
+
+ + {/* Status messages */} + {error && ( +
+ + {error} + +
+ )} + {success && ( +
+ + {success} +
+ )} + + {/* ── API Credentials ── */} +
+
+ {/* Enable toggle */} +
+
+

Активировать модуль

+

Включает кнопку «Выдать ключ» в карточке бронирования

+
+ +
+ +
+

Как получить Client ID и Secret

+
    +
  1. Зарегистрируйтесь на euopen.ttlock.com (EU) или open.ttlock.com
  2. +
  3. Создайте приложение в разделе «My App»
  4. +
  5. Скопируйте Client ID и Client Secret из настроек приложения
  6. +
  7. Логин/пароль — это ваш аккаунт TTLock (мобильное приложение)
  8. +
+
+ +
+
+ + setClientId(e.target.value)} + placeholder="Из TTLock Open Platform" + className="input w-full" + /> +
+
+ + +
+
+ + setUsername(e.target.value)} + placeholder="admin@hotel.ru" + className="input w-full" + /> +
+
+ + +
+
+ + {/* Workstation selection */} +
+ + + {workstationId && !workstations.find(w => w.id === workstationId)?.isOnline && ( +

+ + Это рабочее место сейчас офлайн. Убедитесь, что агент запущен перед выдачей карт. +

+ )} + {onlineWorkstations.length === 0 && ( +

+ Нет онлайн-рабочих мест. Сначала подключите агент на Windows-компьютере с картридером. +

+ )} +
+ +
+ + + {config?.hasToken && ( + + Токен активен + + )} +
+
+
+ + {/* ── Room → Lock mapping ── */} +
+
+

+ Каждому номеру отеля назначается конкретный замок TTLock. При выдаче карты гостю + система автоматически определит нужный замок по номеру бронирования. +

+ + {/* Load locks button */} +
+ + {locks.length > 0 && ( + Найдено: {locks.length} замков + )} + {!config?.hasToken && ( + Сначала проверьте подключение к TTLock + )} +
+ + {/* Add mapping */} + {locks.length > 0 && unmappedRooms.length > 0 && ( +
+
+ + +
+
+ + +
+ +
+ )} + + {/* Current mappings */} + {mappings.length > 0 ? ( +
+ {mappings.map(m => ( +
+
+
+ +
+
+

+ Номер {m.roomNumber}{m.roomName ? ` — ${m.roomName}` : ''} +

+

+ {m.lockName ?? `Замок #${m.lockId}`} + (ID: {m.lockId}) +

+
+
+ +
+ ))} +
+ ) : ( +
+ +

Нет привязанных замков

+

Загрузите замки из TTLock и привяжите их к номерам

+
+ )} +
+
+ + {/* ── Agent instructions ── */} +
+
+
+

Архитектура для замков без шлюза

+

+ USB-программатор E3/E4 от TTLock требует подключения к интернету через шлюз G2/G3. + Для автономных замков без шлюза карты программируются через Bluetooth: + замок запоминает UID карты при непосредственном контакте. +

+
    +
  1. Менеджер прикладывает карту к считывателю на ресепшен
  2. +
  3. Нажимает «Выдать ключ» в PMS → команда идёт агенту
  4. +
  5. Агент через Bluetooth (адаптер в ПК) подключается к замку
  6. +
  7. Замок считывает UID карты и записывает в свою память с датами доступа
  8. +
  9. Карта работает у двери номера автономно, без интернета
  10. +
+
+ +
+

Требования к компьютеру ресепшена:

+
    +
  • Windows 10 или 11
  • +
  • Bluetooth-адаптер (для BLE-соединения с замками, радиус ~10м)
  • +
  • USB RFID/IC считыватель карт (для чтения UID карты гостя)
  • +
  • Агент HotelSync версии 1.1.0 и выше с поддержкой BLE
  • +
  • Замки синхронизированы с TTLock-аккаунтом через мобильное приложение
  • +
+
+ +
+

Альтернатива: шлюз G2/G3

+

+ Если установить шлюз TTLock, карты можно программировать через USB-программатор E3/E4 + удалённо через облако — без Bluetooth и без физического доступа к замку. + Это более надёжный вариант для большого отеля. +

+
+
+
+
+ ) +} + +export default TTLockPage