Files
hotelsync/src/pages/TTLockPage.tsx
2026-04-01 22:56:09 +03:00

629 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div className="border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden">
<button
onClick={() => 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"
>
<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" />}
</button>
{open && <div className="p-6 bg-white dark:bg-slate-900">{children}</div>}
</div>
)
}
function PasswordInput({ value, onChange, placeholder }: {
value: string; onChange: (v: string) => void; placeholder?: string
}) {
const [show, setShow] = useState(false)
return (
<div className="relative">
<input
type={show ? 'text' : 'password'}
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
className="input pr-10 w-full"
/>
<button
type="button"
onClick={() => setShow(s => !s)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
{show ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
</div>
)
}
// ── 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>
)
}
// ── 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 (
<div>
<div className="flex flex-wrap gap-2 mt-1">
{Array.from({ length: 16 }, (_, i) => i + 1).map(n => (
<button
key={n}
type="button"
onClick={() => 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}
</button>
))}
</div>
<p className="text-xs text-slate-500 mt-2">
Выбрано: <code className="bg-slate-100 dark:bg-slate-800 px-1 rounded font-mono">{value || '(нет)'}</code>
{' — '}секторы Mifare-карты, которые использует TTHotel. Обычно 110.
</p>
</div>
)
}
// ── 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<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 [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(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<Record<string, string>>({})
const [wsSaving, setWsSaving] = useState<Record<string, boolean>>({})
const [wsTesting, setWsTesting] = useState<Record<string, boolean>>({})
// Per-workstation port list state
const [wsPorts, setWsPorts] = useState<Record<string, { port: string; description?: string }[]>>({})
const [wsPortsLoading, setWsPortsLoading] = useState<Record<string, boolean>>({})
// Форма привязки замков
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 (
<div className="flex items-center justify-center h-64">
<RefreshCw size={24} className="animate-spin text-brand-600" />
</div>
)
}
const unmappedRooms = rooms.filter(r => !mappings.some(m => m.roomId === r.id))
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">Автоматическая выдача карт-ключей через программу TTHotel</p>
</div>
</div>
{/* ── Шаг 1: Подключение к TTHotel ── */}
<Section title="Шаг 1 — Подключение к TTHotel">
<div className="space-y-5">
<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>
<p className="text-xs text-slate-500 mt-0.5">Включает кнопку «Выдать ключ» в карточке бронирования</p>
</div>
<button
onClick={() => 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',
)}
>
<span className={cn(
'absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform',
isEnabled && 'translate-x-5',
)} />
</button>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="form-label">Client ID</label>
<input
type="text"
value={clientId}
onChange={e => setClientId(e.target.value)}
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={config?.clientId ? '••••••• (не изменится)' : 'Из TTHotel → Интеграции → Показать'}
/>
</div>
</div>
{/* Секторы карты — визуальный пикер */}
<div>
<label className="form-label">Секторы карты</label>
<SectorPicker value={cardSectors} onChange={setCardSectors} />
</div>
<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-4">
<Hint>
Для каждого рабочего места укажите COM-порт USB-энкодера карт (найти в Диспетчере устройств Порты).
Нажмите <strong></strong> рядом с полем, чтобы загрузить список портов с агента.
Агент должен быть онлайн.
</Hint>
{/* 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" />
<div className="flex-1 space-y-1">
<span>{error}</span>
{isDllEntryError(error) && (
<div className="mt-2 p-2 rounded bg-red-100 dark:bg-red-900/40 text-xs">
<strong>Имена функций в DLL не совпадают.</strong>{' '}
Откройте файл{' '}
<code className="font-mono">C:\Program Files (x86)\TTHotel\resources\libs\index.js</code>{' '}
и сообщите содержимое разработчику.
</div>
)}
</div>
<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} className="shrink-0" />
<span>{success}</span>
</div>
)}
{workstations.length === 0 && (
<p className="text-sm text-slate-500">Нет рабочих мест. Добавьте их на странице «Оборудование».</p>
)}
<div className="space-y-3">
{workstations.map(ws => {
const hasPort = !!wsComPorts[ws.id]?.trim()
const portList = wsPorts[ws.id]
const portsLoading = wsPortsLoading[ws.id] ?? false
return (
<div
key={ws.id}
className={cn(
'flex items-center gap-3 p-3 rounded-xl border bg-slate-50 dark:bg-slate-800/40',
hasPort
? 'border-slate-200 dark:border-slate-700'
: 'border-slate-200 dark:border-slate-700 opacity-60',
)}
>
{/* Online dot */}
<div className={cn('w-2.5 h-2.5 rounded-full shrink-0', ws.isOnline ? 'bg-emerald-500' : 'bg-slate-300 dark:bg-slate-600')} />
{/* Name */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{ws.name}</p>
{ws.hostname && <p className="text-xs text-slate-400 truncate">{ws.hostname}</p>}
{!hasPort && (
<p className="text-xs text-amber-500 dark:text-amber-400 mt-0.5">Энкодер не настроен</p>
)}
</div>
{/* COM port — dropdown if ports loaded, text input otherwise */}
<div className="flex items-center gap-1 shrink-0">
{portList && portList.length > 0 ? (
<select
className="input w-36 text-sm font-mono py-1.5"
value={wsComPorts[ws.id] ?? ''}
onChange={e => setWsComPorts(prev => ({ ...prev, [ws.id]: e.target.value }))}
>
<option value=""> Выберите </option>
{portList.map(p => (
<option key={p.port} value={p.port}>
{p.port}{p.description ? `${p.description}` : ''}
</option>
))}
</select>
) : portList && portList.length === 0 ? (
<span className="text-xs text-slate-400 w-36 text-center">Порты не найдены</span>
) : (
<input
type="text"
className="input w-28 text-sm font-mono"
placeholder="COM3"
value={wsComPorts[ws.id] ?? ''}
onChange={e => setWsComPorts(prev => ({ ...prev, [ws.id]: e.target.value }))}
/>
)}
{/* Load ports button */}
<button
type="button"
onClick={() => handleLoadPorts(ws.id)}
disabled={portsLoading || !ws.isOnline}
title={!ws.isOnline ? 'Агент офлайн' : 'Загрузить список COM-портов'}
className="btn-secondary p-1.5 shrink-0"
>
{portsLoading
? <RefreshCw size={13} className="animate-spin" />
: <RefreshCw size={13} />}
</button>
</div>
{/* Save COM port */}
<button
type="button"
disabled={wsSaving[ws.id]}
onClick={async () => {
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]
? <RefreshCw size={13} className="animate-spin" />
: <HardDriveDownload size={13} />}
</button>
{/* Test encoder */}
<button
type="button"
disabled={!ws.isOnline || !hasPort || wsTesting[ws.id]}
onClick={async () => {
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]
? <RefreshCw size={13} className="animate-spin" />
: <Zap size={13} />}
</button>
{/* Diagnose DLL */}
<button
type="button"
disabled={!ws.isOnline}
onClick={async () => {
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'}
>
<Terminal size={13} />
</button>
</div>
)
})}
</div>
</div>
</Section>
{/* ── Шаг 3: Привязка номеров к замкам ── */}
<Section title="Шаг 3 — Привязка номеров к замкам">
<div className="space-y-5">
<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>
{/* Форма добавления */}
{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={handleAddMapping}
disabled={!addRoomId || !addLockMac.trim() || addLoading}
className="btn-primary flex items-center gap-2"
>
{addLoading ? <RefreshCw size={15} className="animate-spin" /> : <Plus size={15} />}
Привязать
</button>
</div>
)}
{/* Список привязок */}
{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 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 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 font-mono">
{m.lockMac}
{m.lockName && <span className="text-slate-400 font-sans ml-2">{m.lockName}</span>}
</p>
</div>
</div>
<button
onClick={() => handleUnmap(m.roomId)}
className="btn-ghost p-2 text-red-500 hover:text-red-700"
title="Удалить привязку"
>
<Trash2 size={15} />
</button>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-slate-400 dark:text-slate-600">
<Unlock size={32} className="mx-auto mb-2 opacity-40" />
<p className="text-sm">Нет привязанных замков</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>
</div>
)
}
export default TTLockPage