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 (
{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" />
) } // ── Hint box ────────────────────────────────────────────────────────────────── function Hint({ children }: { children: React.ReactNode }) { return (
{children}
) } // ── 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 => ( ))}

Выбрано: {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}
))}
) } // ── 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. {/* Включить/выключить */}

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

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

setClientId(e.target.value)} placeholder="0004e6c0cdb64324..." className="input w-full font-mono text-sm" />
{/* Секторы карты */}
{/* ── Шаг 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 ? ( ) : portList && portList.length === 0 ? ( Порты не найдены ) : ( setWsComPorts(prev => ({ ...prev, [ws.id]: e.target.value }))} /> )} {/* Load ports button */}
{/* Save COM port */} {/* Test encoder */}
) })}
{/* ── Шаг 3: Привязка номеров к замкам ── */}
MAC-адрес замка: войдите на lock.ttlock.com →{' '} нажмите Manage у нужного замка → иконка шестерёнки →{' '} Basic → скопируйте поле MAC. Введите без двоеточий: 42A6BBF5ECE5 {/* Импорт из TTHotel XLS */}

Или импортируйте привязки из файла TTHotel

{ const f = e.target.files?.[0] if (f) { handleImportFile(f); e.target.value = '' } }} />
{/* Превью импорта */} {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} )}
{importRows.map((row, i) => { const matchedRoom = rooms.find(r => r.id === row.roomId) return (
{row.skip ? : row.roomId ? : }
{row.doorName} {row.lockMac} {row.skip && уже привязан} {!row.skip && matchedRoom && авто}
) })}
)} {/* Форма добавления */} {unmappedRooms.length > 0 && (

Добавить привязку

setAddLockMac(e.target.value.replace(/[^0-9A-Fa-f:]/g, '').toUpperCase())} placeholder="42A6BBF5ECE5" maxLength={17} className="input w-full font-mono text-sm" />
setAddLockName(e.target.value)} placeholder="Замок номера 101" className="input w-full" />
)} {/* Список привязок */} {mappings.length > 0 ? (
{mappings.map((m, i) => (
{editingMac === m.lockMac ? ( /* ── Режим редактирования ── */
setEditLockMac(e.target.value.replace(/[^0-9A-Fa-f:]/g, '').toUpperCase())} maxLength={17} className="input w-full font-mono text-sm" />
setEditLockName(e.target.value)} placeholder="Замок номера 101" className="input w-full" />
) : ( /* ── Просмотр ── */

{m.roomId ? <>Номер {m.roomNumber}{m.roomName ? ` — ${m.roomName}` : ''} : Комната не привязана{m.lockName ? ` — ${m.lockName}` : ''} }

{m.lockMac} {m.roomId && m.lockName && {m.lockName}}

)}
))}
) : (

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

Добавьте MAC-адреса замков для каждого номера

)} {rooms.length > 0 && unmappedRooms.length === 0 && mappings.length > 0 && (

Все номера привязаны к замкам

)}
) } export default TTLockPage