import { useState, useEffect, useCallback } from 'react'
import {
KeyRound, Save, RefreshCw, Check, X, AlertCircle,
ChevronDown, ChevronRight, Trash2, Plus, Eye, EyeOff,
Lock, Unlock, Info, HardDriveDownload, Zap, Terminal,
} from 'lucide-react'
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'
// ── Helpers ───────────────────────────────────────────────────────────────────
function Section({ title, children, defaultOpen = true }: {
title: string; children: React.ReactNode; defaultOpen?: boolean
}) {
const [open, setOpen] = useState(defaultOpen)
return (
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"
>
{title}
{open
?
: }
{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"
/>
setShow(s => !s)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
{show ? : }
)
}
// ── Hint box ──────────────────────────────────────────────────────────────────
function Hint({ children }: { children: React.ReactNode }) {
return (
)
}
// ── Sector Picker ─────────────────────────────────────────────────────────────
function SectorPicker({ value, onChange }: {
value: string
onChange: (v: string) => void
}) {
// Parse comma-separated string into a Set of numbers
const selected = new Set(
value.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n) && n >= 1 && n <= 16)
)
const toggle = (n: number) => {
const next = new Set(selected)
if (next.has(n)) {
next.delete(n)
} else {
next.add(n)
}
// Sort numerically and join
const sorted = Array.from(next).sort((a, b) => a - b)
onChange(sorted.join(','))
}
return (
{Array.from({ length: 16 }, (_, i) => i + 1).map(n => (
toggle(n)}
title={`Сектор ${n}`}
className={cn(
'w-9 h-9 rounded-full text-sm font-semibold transition-colors border-2',
selected.has(n)
? 'bg-brand-600 border-brand-600 text-white hover:bg-brand-700 hover:border-brand-700'
: 'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-500 dark:text-slate-400 hover:border-brand-400 hover:text-brand-600',
)}
>
{n}
))}
Выбрано: {value || '(нет)'}
{' — '}секторы Mifare-карты, которые использует TTHotel. Обычно 1–10.
)
}
// ── DLL error note ────────────────────────────────────────────────────────────
function isDllEntryError(msg: string): boolean {
return msg.includes('точку входа') || msg.toLowerCase().includes('entrypoint') || msg.toLowerCase().includes('entry point')
}
// ── Main Page ─────────────────────────────────────────────────────────────────
export function TTLockPage() {
const { user } = useAuth()
const slug = user?.hotelSlug ?? ''
const [config, setConfig] = useState(null)
const [mappings, setMappings] = useState([])
const [rooms, setRooms] = useState([])
const [workstations, setWorkstations] = useState([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const [success, setSuccess] = useState(null)
// Форма настроек
const [isEnabled, setIsEnabled] = useState(false)
const [clientId, setClientId] = useState('')
const [clientSecret, setClientSecret] = useState('')
const [cardSectors, setCardSectors] = useState('1,2,3,4,5,6,7,8,9,10')
// Per-workstation COM port state
const [wsComPorts, setWsComPorts] = useState>({})
const [wsSaving, setWsSaving] = useState>({})
const [wsTesting, setWsTesting] = useState>({})
// Per-workstation port list state
const [wsPorts, setWsPorts] = useState>({})
const [wsPortsLoading, setWsPortsLoading] = useState>({})
// Форма привязки замков
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), 3500)
}
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)
setCardSectors(cfg.cardSectors || '1,2,3,4,5,6,7,8,9,10')
setMappings(maps)
setRooms(roomList)
setWorkstations(wsList)
setWsComPorts(Object.fromEntries(wsList.map(w => [w.id, w.ttlockComPort ?? ''])))
} 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,
cardSectors: cardSectors.trim(),
})
showSuccess('Настройки сохранены')
setClientSecret('')
await load()
} catch {
setError('Ошибка сохранения')
} finally {
setSaving(false)
}
}
const handleAddMapping = async () => {
if (!addRoomId || !addLockMac.trim()) return
setAddLoading(true)
setError(null)
try {
await api.ttlock.mapRoom(slug, {
roomId: addRoomId,
lockMac: addLockMac.trim(),
lockName: addLockName.trim() || undefined,
})
setAddRoomId('')
setAddLockMac('')
setAddLockName('')
await load()
showSuccess('Замок привязан к номеру')
} catch (e) {
setError(e instanceof Error ? e.message : 'Ошибка привязки замка')
} finally {
setAddLoading(false)
}
}
const handleUnmap = async (roomId: string) => {
try {
await api.ttlock.unmapRoom(slug, roomId)
await load()
showSuccess('Привязка удалена')
} catch {
setError('Ошибка удаления привязки')
}
}
const handleLoadPorts = async (wsId: string) => {
setWsPortsLoading(prev => ({ ...prev, [wsId]: true }))
try {
const result = await api.workstations.listPorts(slug, wsId)
setWsPorts(prev => ({ ...prev, [wsId]: result.ports }))
} catch {
// If agent offline or error, leave wsPorts[wsId] undefined — fallback to text input
} finally {
setWsPortsLoading(prev => ({ ...prev, [wsId]: false }))
}
}
if (loading) {
return (
)
}
const unmappedRooms = rooms.filter(r => !mappings.some(m => m.roomId === r.id))
return (
{/* Header */}
Электронные замки TTLock
Автоматическая выдача карт-ключей через программу TTHotel
{/* ── Шаг 1: Подключение к TTHotel ── */}
Откройте программу TTHotel на Windows-компьютере →{' '}
Настройки → Интеграции . Скопируйте поля Client ID и Client Secret.
{/* Включить/выключить */}
Активировать модуль
Включает кнопку «Выдать ключ» в карточке бронирования
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',
)}
>
{/* Секторы карты — визуальный пикер */}
Секторы карты
{saving ? : }
Сохранить
{/* ── Шаг 2: Рабочие места и энкодеры ── */}
Для каждого рабочего места укажите COM-порт USB-энкодера карт (найти в Диспетчере устройств → Порты).
Нажмите ↺ рядом с полем, чтобы загрузить список портов с агента.
Агент должен быть онлайн.
{/* Alerts */}
{error && (
{error}
{isDllEntryError(error) && (
Имена функций в DLL не совпадают. {' '}
Откройте файл{' '}
C:\Program Files (x86)\TTHotel\resources\libs\index.js{' '}
и сообщите содержимое разработчику.
)}
setError(null)}>
)}
{success && (
{success}
)}
{workstations.length === 0 && (
Нет рабочих мест. Добавьте их на странице «Оборудование».
)}
{workstations.map(ws => {
const hasPort = !!wsComPorts[ws.id]?.trim()
const portList = wsPorts[ws.id]
const portsLoading = wsPortsLoading[ws.id] ?? false
return (
{/* Online dot */}
{/* Name */}
{ws.name}
{ws.hostname &&
{ws.hostname}
}
{!hasPort && (
Энкодер не настроен
)}
{/* COM port — dropdown if ports loaded, text input otherwise */}
{portList && portList.length > 0 ? (
setWsComPorts(prev => ({ ...prev, [ws.id]: e.target.value }))}
>
— Выберите —
{portList.map(p => (
{p.port}{p.description ? ` — ${p.description}` : ''}
))}
) : portList && portList.length === 0 ? (
Порты не найдены
) : (
setWsComPorts(prev => ({ ...prev, [ws.id]: e.target.value }))}
/>
)}
{/* Load ports button */}
handleLoadPorts(ws.id)}
disabled={portsLoading || !ws.isOnline}
title={!ws.isOnline ? 'Агент офлайн' : 'Загрузить список COM-портов'}
className="btn-secondary p-1.5 shrink-0"
>
{portsLoading
?
: }
{/* Save COM port */}
{
setWsSaving(prev => ({ ...prev, [ws.id]: true }))
try {
await api.workstations.update(slug, ws.id, { ttlockComPort: wsComPorts[ws.id]?.trim() || null })
showSuccess(`COM-порт для «${ws.name}» сохранён`)
await load()
} catch { setError('Ошибка сохранения') }
finally { setWsSaving(prev => ({ ...prev, [ws.id]: false })) }
}}
className="btn-secondary p-1.5 shrink-0"
title="Сохранить COM-порт"
>
{wsSaving[ws.id]
?
: }
{/* Test encoder */}
{
setWsTesting(prev => ({ ...prev, [ws.id]: true }))
setError(null)
try {
await api.ttlock.testEncoder(slug, ws.id)
showSuccess(`Энкодер на «${ws.name}» отвечает — всё готово!`)
} catch (e) {
const msg = e instanceof Error ? e.message : 'Ошибка проверки'
setError(msg)
} finally { setWsTesting(prev => ({ ...prev, [ws.id]: false })) }
}}
className="btn-secondary p-1.5 shrink-0"
title={!ws.isOnline ? 'Агент офлайн' : !hasPort ? 'Укажите COM-порт' : 'Проверить энкодер'}
>
{wsTesting[ws.id]
?
: }
{/* Diagnose DLL */}
{
setError(null)
try {
await api.ttlock.testEncoder(slug, ws.id)
showSuccess(`Диагностика «${ws.name}»: DLL загружена успешно`)
} catch (e) {
const msg = e instanceof Error ? e.message : 'Ошибка диагностики DLL'
setError(msg)
}
}}
className="btn-secondary p-1.5 shrink-0"
title={!ws.isOnline ? 'Агент офлайн' : 'Диагностика DLL'}
>
)
})}
{/* ── Шаг 3: Привязка номеров к замкам ── */}
MAC-адрес замка: войдите на lock.ttlock.com →{' '}
нажмите Manage у нужного замка → иконка шестерёнки →{' '}
Basic → скопируйте поле MAC .
Введите без двоеточий: 42A6BBF5ECE5
{/* Форма добавления */}
{unmappedRooms.length > 0 && (
Добавить привязку
{addLoading ? : }
Привязать
)}
{/* Список привязок */}
{mappings.length > 0 ? (
{mappings.map(m => (
Номер {m.roomNumber}{m.roomName ? ` — ${m.roomName}` : ''}
{m.lockMac}
{m.lockName && {m.lockName} }
handleUnmap(m.roomId)}
className="btn-ghost p-2 text-red-500 hover:text-red-700"
title="Удалить привязку"
>
))}
) : (
Нет привязанных замков
Добавьте MAC-адреса замков для каждого номера
)}
{rooms.length > 0 && unmappedRooms.length === 0 && mappings.length > 0 && (
Все номера привязаны к замкам
)}
)
}
export default TTLockPage