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:
@@ -1,9 +1,10 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
KeyRound, Save, RefreshCw, Check, X, AlertCircle, ChevronDown, ChevronRight,
|
||||
Trash2, Plus, Eye, EyeOff, Monitor, Wifi, WifiOff, Lock, Unlock,
|
||||
KeyRound, Save, RefreshCw, Check, X, AlertCircle,
|
||||
ChevronDown, ChevronRight, Trash2, Plus, Eye, EyeOff,
|
||||
Monitor, Lock, Unlock, Info,
|
||||
} 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 { cn } from '../lib/utils'
|
||||
import type { Room } from '../types'
|
||||
@@ -11,9 +12,7 @@ import type { Room } from '../types'
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function Section({ title, children, defaultOpen = true }: {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
defaultOpen?: boolean
|
||||
title: string; children: React.ReactNode; defaultOpen?: boolean
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
{open && <div className="p-6 bg-white dark:bg-slate-900">{children}</div>}
|
||||
</div>
|
||||
@@ -31,9 +32,7 @@ function Section({ title, children, defaultOpen = true }: {
|
||||
}
|
||||
|
||||
function PasswordInput({ value, onChange, placeholder }: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
placeholder?: string
|
||||
value: string; onChange: (v: string) => void; placeholder?: string
|
||||
}) {
|
||||
const [show, setShow] = useState(false)
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
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 [config, setConfig] = useState<TTLockConfig | null>(null)
|
||||
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)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testing, setTesting] = 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 [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>('')
|
||||
const [cardSectors, setCardSectors] = useState('1,2,3,4,5,6,7,8,9,10')
|
||||
const [comPort, setComPort] = useState('')
|
||||
const [workstationId, setWorkstationId] = useState('')
|
||||
|
||||
// Room mapping
|
||||
const [selectedRoom, setSelectedRoom] = useState('')
|
||||
const [selectedLock, setSelectedLock] = useState<number | ''>('')
|
||||
const [mappingLoading, setMappingLoading] = useState(false)
|
||||
// Форма привязки замков
|
||||
const [addRoomId, setAddRoomId] = useState('')
|
||||
const [addLockMac, setAddLockMac] = useState('')
|
||||
const [addLockName, setAddLockName] = useState('')
|
||||
const [addLoading, setAddLoading] = useState(false)
|
||||
|
||||
const showSuccess = (msg: string) => {
|
||||
setSuccess(msg)
|
||||
setTimeout(() => setSuccess(null), 3000)
|
||||
setSuccess(msg); setTimeout(() => setSuccess(null), 3500)
|
||||
}
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -105,7 +112,8 @@ export function TTLockPage() {
|
||||
setConfig(cfg)
|
||||
setIsEnabled(cfg.isEnabled)
|
||||
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 ?? '')
|
||||
setMappings(maps)
|
||||
setRooms(roomList)
|
||||
@@ -125,13 +133,14 @@ export function TTLockPage() {
|
||||
try {
|
||||
await api.ttlock.updateConfig(slug, {
|
||||
isEnabled,
|
||||
clientId: clientId.trim(),
|
||||
clientSecret: clientSecret.trim() || undefined,
|
||||
ttlockUsername: username.trim(),
|
||||
ttlockPassword: password.trim() || undefined,
|
||||
workstationId: workstationId || null,
|
||||
clientId: clientId.trim(),
|
||||
clientSecret: clientSecret.trim() || undefined,
|
||||
cardSectors: cardSectors.trim(),
|
||||
comPort: comPort.trim(),
|
||||
workstationId: workstationId || null,
|
||||
})
|
||||
showSuccess('Настройки сохранены')
|
||||
setClientSecret('')
|
||||
await load()
|
||||
} catch {
|
||||
setError('Ошибка сохранения')
|
||||
@@ -140,51 +149,38 @@ export function TTLockPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestToken = async () => {
|
||||
setTokenLoading(true)
|
||||
const handleTest = async () => {
|
||||
setTesting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const r = await api.ttlock.testToken(slug)
|
||||
showSuccess(`Авторизация успешна, токен действителен до ${new Date(r.expiresAt).toLocaleString('ru')}`)
|
||||
await api.ttlock.testEncoder(slug)
|
||||
showSuccess('Энкодер найден и отвечает — всё готово к работе!')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Ошибка авторизации в TTLock')
|
||||
setError(e instanceof Error ? e.message : 'Ошибка проверки энкодера')
|
||||
} finally {
|
||||
setTokenLoading(false)
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLoadLocks = async () => {
|
||||
setLocksLoading(true)
|
||||
const handleAddMapping = async () => {
|
||||
if (!addRoomId || !addLockMac.trim()) return
|
||||
setAddLoading(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,
|
||||
roomId: addRoomId,
|
||||
lockMac: addLockMac.trim(),
|
||||
lockName: addLockName.trim() || undefined,
|
||||
})
|
||||
setSelectedRoom('')
|
||||
setSelectedLock('')
|
||||
setAddRoomId('')
|
||||
setAddLockMac('')
|
||||
setAddLockName('')
|
||||
await load()
|
||||
showSuccess('Замок привязан к номеру')
|
||||
} catch {
|
||||
setError('Ошибка привязки')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Ошибка привязки замка')
|
||||
} 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 onlineWorkstations = workstations.filter(w => w.isOnline)
|
||||
const selectedWs = workstations.find(w => w.id === workstationId)
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto p-6 space-y-6">
|
||||
|
||||
{/* Header */}
|
||||
<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>
|
||||
<p className="text-sm text-slate-500">Автоматическая выдача карт-ключей через программу TTHotel</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status messages */}
|
||||
{/* Alerts */}
|
||||
{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>
|
||||
<span className="flex-1">{error}</span>
|
||||
<button 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} />
|
||||
<Check size={16} className="shrink-0" />
|
||||
<span>{success}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── API Credentials ── */}
|
||||
<Section title="Подключение к TTLock Open Platform">
|
||||
{/* ── Шаг 1: Подключение к TTHotel ── */}
|
||||
<Section title="Шаг 1 — Подключение к TTHotel">
|
||||
<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>
|
||||
<p className="font-medium text-slate-800 dark:text-slate-200">Активировать модуль</p>
|
||||
@@ -259,16 +262,6 @@ export function TTLockPage() {
|
||||
</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>
|
||||
@@ -276,33 +269,38 @@ export function TTLockPage() {
|
||||
type="text"
|
||||
value={clientId}
|
||||
onChange={e => setClientId(e.target.value)}
|
||||
placeholder="Из TTLock Open Platform"
|
||||
className="input w-full"
|
||||
placeholder="Из TTHotel → Настройки → Интеграции"
|
||||
className="input w-full font-mono text-sm"
|
||||
/>
|
||||
</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"
|
||||
<PasswordInput
|
||||
value={clientSecret}
|
||||
onChange={setClientSecret}
|
||||
placeholder={config?.clientId ? '••••••• (не изменится)' : 'Из TTHotel → Интеграции → Показать'}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Пароль TTLock</label>
|
||||
<PasswordInput value={password} onChange={setPassword} placeholder="Оставьте пустым — не изменится" />
|
||||
</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>
|
||||
<label className="form-label">Рабочее место с картридером</label>
|
||||
<label className="form-label">Рабочее место (ПК с энкодером)</label>
|
||||
<select
|
||||
value={workstationId}
|
||||
onChange={e => setWorkstationId(e.target.value)}
|
||||
@@ -311,116 +309,146 @@ export function TTLockPage() {
|
||||
<option value="">— Выберите рабочее место —</option>
|
||||
{workstations.map(w => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.name}
|
||||
{w.isOnline ? ' ✓ онлайн' : ' ✗ офлайн'}
|
||||
{w.name} {w.isOnline ? '✓ онлайн' : '✗ офлайн'}
|
||||
{w.hostname ? ` (${w.hostname})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{workstationId && !workstations.find(w => w.id === workstationId)?.isOnline && (
|
||||
<p className="text-xs text-amber-600 mt-1 flex items-center gap-1">
|
||||
{selectedWs && !selectedWs.isOnline && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400 mt-1 flex items-center gap-1">
|
||||
<AlertCircle size={12} />
|
||||
Это рабочее место сейчас офлайн. Убедитесь, что агент запущен перед выдачей карт.
|
||||
</p>
|
||||
)}
|
||||
{onlineWorkstations.length === 0 && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
Нет онлайн-рабочих мест. Сначала подключите агент на Windows-компьютере с картридером.
|
||||
Агент на этом компьютере сейчас офлайн. Для выдачи карт агент должен быть запущен.
|
||||
</p>
|
||||
)}
|
||||
</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">
|
||||
{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
|
||||
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>
|
||||
{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="Привязка номеров к замкам">
|
||||
{/* ── Шаг 3: Привязка номеров к замкам ── */}
|
||||
<Section title="Шаг 3 — Привязка номеров к замкам">
|
||||
<div className="space-y-5">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Каждому номеру отеля назначается конкретный замок TTLock. При выдаче карты гостю
|
||||
система автоматически определит нужный замок по номеру бронирования.
|
||||
</p>
|
||||
<Hint>
|
||||
MAC-адрес замка: войдите на <strong>lock.ttlock.com</strong> →{' '}
|
||||
нажмите <strong>Manage</strong> у нужного замка → иконка шестерёнки →{' '}
|
||||
<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">
|
||||
<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>
|
||||
{/* Форма добавления */}
|
||||
{unmappedRooms.length > 0 && (
|
||||
<div className="p-4 bg-slate-50 dark:bg-slate-800/50 rounded-xl space-y-3">
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">Добавить привязку</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="form-label">Номер</label>
|
||||
<select value={addRoomId} onChange={e => setAddRoomId(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>
|
||||
<label className="form-label">MAC замка</label>
|
||||
<input
|
||||
type="text"
|
||||
value={addLockMac}
|
||||
onChange={e => setAddLockMac(e.target.value.replace(/[^0-9A-Fa-f:]/g, '').toUpperCase())}
|
||||
placeholder="42A6BBF5ECE5"
|
||||
maxLength={17}
|
||||
className="input w-full font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Название (необяз.)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={addLockName}
|
||||
onChange={e => setAddLockName(e.target.value)}
|
||||
placeholder="Замок номера 101"
|
||||
className="input w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleMapRoom}
|
||||
disabled={!selectedRoom || !selectedLock || mappingLoading}
|
||||
className="btn-primary flex items-center gap-2 shrink-0"
|
||||
onClick={handleAddMapping}
|
||||
disabled={!addRoomId || !addLockMac.trim() || addLoading}
|
||||
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>
|
||||
</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
|
||||
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">
|
||||
<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" />
|
||||
</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 className="text-xs text-slate-500 font-mono">
|
||||
{m.lockMac}
|
||||
{m.lockName && <span className="text-slate-400 font-sans ml-2">{m.lockName}</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -436,54 +464,20 @@ export function TTLockPage() {
|
||||
</div>
|
||||
) : (
|
||||
<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-xs mt-1">Загрузите замки из TTLock и привяжите их к номерам</p>
|
||||
<p className="text-xs mt-1">Добавьте MAC-адреса замков для каждого номера</p>
|
||||
</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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user