import { useState, useEffect, useCallback, useRef } from 'react'
import * as XLSX from 'xlsx'
import {
KeyRound, Save, RefreshCw, Check, X, AlertCircle,
ChevronDown, ChevronRight, Trash2, Plus, Eye, EyeOff,
Lock, Unlock, Info, Zap, Upload, Globe, Pencil,
} 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 — 2 rows of 8 ───────────────────────────────────────────────
function SectorPicker({ value, onChange }: {
value: string
onChange: (v: string) => void
}) {
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) }
onChange(Array.from(next).sort((a, b) => a - b).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.
)
}
// ── Toast ─────────────────────────────────────────────────────────────────────
interface ToastItem { id: number; type: 'success' | 'error'; message: string }
function ToastContainer({ toasts, onRemove }: { toasts: ToastItem[]; onRemove: (id: number) => void }) {
return (
{toasts.map(t => (
{t.type === 'success'
?
:
}
{t.message}
onRemove(t.id)} className="opacity-70 hover:opacity-100">
))}
)
}
// ── 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 [testingApi, setTestingApi] = useState(false)
// Toast notifications
const [toasts, setToasts] = useState([])
const toastCounter = useRef(0)
const showToast = useCallback((type: 'success' | 'error', message: string) => {
const id = ++toastCounter.current
setToasts(prev => [...prev, { id, type, message }])
setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), 4000)
}, [])
const removeToast = useCallback((id: number) => {
setToasts(prev => prev.filter(t => t.id !== id))
}, [])
// Форма настроек
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 [editingMac, setEditingMac] = useState(null)
const [editRoomId, setEditRoomId] = useState('')
const [editLockMac, setEditLockMac] = useState('')
const [editLockName, setEditLockName] = useState('')
const [editLoading, setEditLoading] = useState(false)
// Импорт из XLS
interface ImportRow {
doorName: string
lockMac: string
roomId: string
skip: boolean
}
const [importRows, setImportRows] = useState(null)
const [importing, setImporting] = useState(false)
const importFileRef = useRef(null)
const handleImportFile = async (file: File) => {
try {
const buf = await file.arrayBuffer()
const wb = XLSX.read(buf, { type: 'array' })
const ws = wb.Sheets[wb.SheetNames[0]]
const rows = XLSX.utils.sheet_to_json(ws, { defval: '' }) as Array>
const parsed: ImportRow[] = rows.map(row => {
const doorName = String(row['Door Name'] ?? '').trim()
const rawMac = String(row['Lock Mac'] ?? '').trim()
const mac = rawMac.replace(/:/g, '').toUpperCase()
const matched = rooms.find(r => String(r.number).trim() === doorName)
const alreadyMapped = matched ? mappings.some(m => m.roomId === matched.id) : false
return { doorName, lockMac: mac, roomId: matched?.id ?? '', skip: alreadyMapped }
}).filter(r => r.lockMac.length > 0)
setImportRows(parsed)
} catch {
showToast('error', 'Не удалось прочитать файл. Убедитесь, что это .xls/.xlsx из TTHotel.')
}
}
const handleImport = async () => {
if (!importRows) return
setImporting(true)
let ok = 0, fail = 0
await Promise.allSettled(
importRows
.filter(r => !r.skip && r.lockMac)
.map(async r => {
try {
await api.ttlock.mapRoom(slug, {
roomId: r.roomId || undefined as any,
lockMac: r.lockMac,
lockName: r.doorName || undefined,
})
ok++
} catch { fail++ }
})
)
setImporting(false)
setImportRows(null)
await load()
showToast('success', `Импортировано: ${ok} замков${fail ? `, ошибок: ${fail}` : ''}`)
}
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 {
showToast('error', 'Не удалось загрузить настройки')
} finally {
setLoading(false)
}
}, [slug])
useEffect(() => { load() }, [load])
const handleSave = async () => {
setSaving(true)
try {
await api.ttlock.updateConfig(slug, {
isEnabled,
clientId: clientId.trim(),
clientSecret: clientSecret.trim() || undefined,
cardSectors: cardSectors.trim(),
apiServer: 'https://euapi.ttlock.com',
})
showToast('success', 'Настройки сохранены')
setClientSecret('')
await load()
} catch {
showToast('error', 'Ошибка сохранения')
} finally {
setSaving(false)
}
}
const handleTestApi = async () => {
const ws = workstations[0]
if (!ws) { showToast('error', 'Нет рабочих мест — добавьте на странице «Оборудование»'); return }
setTestingApi(true)
try {
await api.ttlock.testApi(slug, ws.id)
showToast('success', 'Подключение к TTLock Cloud API успешно')
} catch (e) {
showToast('error', e instanceof Error ? e.message : 'Ошибка проверки API')
} finally {
setTestingApi(false)
}
}
const handleAddMapping = async () => {
if (!addRoomId || !addLockMac.trim()) return
setAddLoading(true)
try {
await api.ttlock.mapRoom(slug, {
roomId: addRoomId,
lockMac: addLockMac.trim(),
lockName: addLockName.trim() || undefined,
})
setAddRoomId('')
setAddLockMac('')
setAddLockName('')
await load()
showToast('success', 'Замок привязан к номеру')
} catch (e) {
showToast('error', e instanceof Error ? e.message : 'Ошибка привязки замка')
} finally {
setAddLoading(false)
}
}
const handleStartEdit = (m: RoomLockMapping) => {
setEditingMac(m.lockMac)
setEditRoomId(m.roomId ?? '')
setEditLockMac(m.lockMac)
setEditLockName(m.lockName ?? '')
}
const handleSaveEdit = async () => {
if (!editingMac) return
setEditLoading(true)
try {
const newMac = editLockMac.replace(/:/g, '').toUpperCase()
if (newMac !== editingMac) {
// MAC changed — delete old, create new
await api.ttlock.unmapRoom(slug, editingMac)
}
await api.ttlock.mapRoom(slug, {
roomId: editRoomId || undefined as any,
lockMac: newMac,
lockName: editLockName.trim() || undefined,
})
setEditingMac(null)
await load()
showToast('success', 'Привязка обновлена')
} catch (e) {
showToast('error', e instanceof Error ? e.message : 'Ошибка сохранения')
} finally {
setEditLoading(false)
}
}
const handleUnmap = async (lockMac: string) => {
try {
await api.ttlock.unmapRoom(slug, lockMac)
await load()
showToast('success', 'Привязка удалена')
} catch {
showToast('error', 'Ошибка удаления привязки')
}
}
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 {
// agent offline — 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 (
{/* Floating toasts */}
{/* 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',
)}
>
{/* Секторы карты */}
Секторы карты
{testingApi ? : }
Проверить подключение
{saving ? : }
Сохранить
{/* ── Шаг 2: Рабочие места и энкодеры ── */}
Для каждого рабочего места укажите COM-порт USB-энкодера карт (найти в Диспетчере устройств → Порты).
Нажмите ↺ рядом с полем, чтобы загрузить список портов с агента.
Агент должен быть онлайн.
{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 */}
{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 })
showToast('success', `COM-порт для «${ws.name}» сохранён`)
await load()
} catch { showToast('error', 'Ошибка сохранения') }
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 }))
try {
await api.ttlock.testEncoder(slug, ws.id)
showToast('success', `Энкодер на «${ws.name}» отвечает — всё готово!`)
} catch (e) {
const msg = e instanceof Error ? e.message : 'Ошибка проверки'
showToast('error', isDllEntryError(msg)
? `Имена функций в DLL не совпадают. Проверьте index.js в TTHotel.`
: msg)
} finally { setWsTesting(prev => ({ ...prev, [ws.id]: false })) }
}}
className="btn-secondary p-1.5 shrink-0"
title={!ws.isOnline ? 'Агент офлайн' : !hasPort ? 'Укажите COM-порт' : 'Проверить энкодер'}
>
{wsTesting[ws.id]
?
: }
)
})}
{/* ── Шаг 3: Привязка номеров к замкам ── */}
MAC-адрес замка: войдите на lock.ttlock.com →{' '}
нажмите Manage у нужного замка → иконка шестерёнки →{' '}
Basic → скопируйте поле MAC .
Введите без двоеточий: 42A6BBF5ECE5
{/* Импорт из TTHotel XLS */}
{/* Превью импорта */}
{importRows && (
Найдено замков: {importRows.length}
{' · '}
совпадений: {importRows.filter(r => r.roomId && !r.skip).length}
{importRows.some(r => !r.roomId) && (
· без номера: {importRows.filter(r => !r.roomId).length}
)}
{importRows.some(r => r.skip) && (
· уже привязаны: {importRows.filter(r => r.skip).length}
)}
setImportRows(null)} className="text-slate-400 hover:text-slate-600">
{importRows.map((row, i) => {
const matchedRoom = rooms.find(r => r.id === row.roomId)
return (
{row.skip
?
: row.roomId
?
:
}
{row.doorName}
{row.lockMac}
setImportRows(prev => prev!.map((r, j) =>
j === i ? { ...r, roomId: e.target.value } : r
))}
>
— номер не выбран —
{rooms.map(r => (
№{r.number}{r.name ? ` ${r.name}` : ''}
))}
{row.skip &&
уже привязан }
{!row.skip && matchedRoom &&
авто }
)
})}
setImportRows(null)} className="btn-secondary text-sm">
Отмена
r.skip)}
className="btn-primary flex items-center gap-2 text-sm"
>
{importing ? : }
Импортировать {importRows.filter(r => !r.skip).length} замков
)}
{/* Форма добавления */}
{unmappedRooms.length > 0 && (
Добавить привязку
{addLoading ? : }
Привязать
)}
{/* Список привязок */}
{mappings.length > 0 ? (
{mappings.map((m, i) => (
{editingMac === m.lockMac ? (
/* ── Режим редактирования ── */
{editLoading ? : }
Сохранить
setEditingMac(null)}
disabled={editLoading}
className="btn-secondary text-sm"
>
Отмена
) : (
/* ── Просмотр ── */
{m.roomId
? <>Номер {m.roomNumber}{m.roomName ? ` — ${m.roomName}` : ''}>
: Комната не привязана{m.lockName ? ` — ${m.lockName}` : ''}
}
{m.lockMac}
{m.roomId && m.lockName && {m.lockName} }
handleStartEdit(m)}
className="btn-ghost p-2 text-slate-400 hover:text-brand-600"
title="Редактировать"
>
handleUnmap(m.lockMac)}
className="btn-ghost p-2 text-red-400 hover:text-red-600"
title="Удалить привязку"
>
)}
))}
) : (
Нет привязанных замков
Добавьте MAC-адреса замков для каждого номера
)}
{rooms.length > 0 && unmappedRooms.length === 0 && mappings.length > 0 && (
Все номера привязаны к замкам
)}
)
}
export default TTLockPage