- Floating toast notifications (top-center, auto-dismiss 4s) instead of inline alerts in section 2 — visible regardless of scroll position - Edit button on each lock mapping (inline form, supports MAC/room/name change) - Sector picker: 2 rows of 8 (grid-cols-8), removed "Обычно 1–10." hint - Removed API server selector (always EU, hardcoded in save) - Removed buildNo/floorNo fields from UI (backend defaults to 1) - Renamed "Проверить API" → "Проверить подключение" - Save COM-port button: HardDriveDownload → Save (floppy disk) icon - Removed debug-only Terminal and Search buttons from workstations row - Unmap now deletes by MAC (consistent with edit flow) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
923 lines
40 KiB
TypeScript
923 lines
40 KiB
TypeScript
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 (
|
||
<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 — 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 (
|
||
<div>
|
||
<div className="grid grid-cols-8 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.
|
||
</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Toast ─────────────────────────────────────────────────────────────────────
|
||
|
||
interface ToastItem { id: number; type: 'success' | 'error'; message: string }
|
||
|
||
function ToastContainer({ toasts, onRemove }: { toasts: ToastItem[]; onRemove: (id: number) => void }) {
|
||
return (
|
||
<div className="fixed top-5 left-1/2 -translate-x-1/2 z-50 flex flex-col gap-2 pointer-events-none" style={{ minWidth: 320 }}>
|
||
{toasts.map(t => (
|
||
<div
|
||
key={t.id}
|
||
className={cn(
|
||
'flex items-center gap-3 px-4 py-3 rounded-xl shadow-lg text-sm font-medium pointer-events-auto',
|
||
'animate-in fade-in slide-in-from-top-2 duration-200',
|
||
t.type === 'success'
|
||
? 'bg-emerald-600 text-white'
|
||
: 'bg-red-600 text-white',
|
||
)}
|
||
>
|
||
{t.type === 'success'
|
||
? <Check size={16} className="shrink-0" />
|
||
: <AlertCircle size={16} className="shrink-0" />}
|
||
<span className="flex-1">{t.message}</span>
|
||
<button onClick={() => onRemove(t.id)} className="opacity-70 hover:opacity-100">
|
||
<X size={14} />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</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 [testingApi, setTestingApi] = useState(false)
|
||
|
||
// Toast notifications
|
||
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||
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<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 [editingMac, setEditingMac] = useState<string | null>(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<ImportRow[] | null>(null)
|
||
const [importing, setImporting] = useState(false)
|
||
const importFileRef = useRef<HTMLInputElement>(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<Record<string, unknown>>
|
||
|
||
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 (
|
||
<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">
|
||
|
||
{/* Floating toasts */}
|
||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||
|
||
{/* 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 <span className="font-normal text-slate-400">(из TTHotel → Интеграция с PMS)</span></label>
|
||
<input
|
||
type="text"
|
||
value={clientId}
|
||
onChange={e => setClientId(e.target.value)}
|
||
placeholder="0004e6c0cdb64324..."
|
||
className="input w-full font-mono text-sm"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="form-label">Client Secret <span className="font-normal text-slate-400">(нажмите «Просмотр» в TTHotel)</span></label>
|
||
<PasswordInput
|
||
value={clientSecret}
|
||
onChange={setClientSecret}
|
||
placeholder={config?.clientId ? '••••••• (не изменится)' : 'Нажмите «Просмотр» в TTHotel → Интеграция с PMS'}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Секторы карты */}
|
||
<div>
|
||
<label className="form-label">Секторы карты</label>
|
||
<SectorPicker value={cardSectors} onChange={setCardSectors} />
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<button onClick={handleTestApi} disabled={testingApi || saving} className="btn-secondary flex items-center gap-2">
|
||
{testingApi ? <RefreshCw size={15} className="animate-spin" /> : <Globe size={15} />}
|
||
Проверить подключение
|
||
</button>
|
||
<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>
|
||
</div>
|
||
</Section>
|
||
|
||
{/* ── Шаг 2: Рабочие места и энкодеры ── */}
|
||
<Section title="Шаг 2 — Рабочие места и энкодеры">
|
||
<div className="space-y-4">
|
||
<Hint>
|
||
Для каждого рабочего места укажите COM-порт USB-энкодера карт (найти в Диспетчере устройств → Порты).
|
||
Нажмите <strong>↺</strong> рядом с полем, чтобы загрузить список портов с агента.
|
||
Агент должен быть онлайн.
|
||
</Hint>
|
||
|
||
{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 */}
|
||
<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 })
|
||
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]
|
||
? <RefreshCw size={13} className="animate-spin" />
|
||
: <Save size={13} />}
|
||
</button>
|
||
|
||
{/* Test encoder */}
|
||
<button
|
||
type="button"
|
||
disabled={!ws.isOnline || !hasPort || wsTesting[ws.id]}
|
||
onClick={async () => {
|
||
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]
|
||
? <RefreshCw size={13} className="animate-spin" />
|
||
: <Zap 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>
|
||
|
||
{/* Импорт из TTHotel XLS */}
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-sm text-slate-500">Или импортируйте привязки из файла TTHotel</p>
|
||
<button
|
||
type="button"
|
||
onClick={() => importFileRef.current?.click()}
|
||
className="btn-secondary flex items-center gap-1.5 text-sm"
|
||
>
|
||
<Upload size={14} />
|
||
Импорт из TTHotel (.xls)
|
||
</button>
|
||
<input
|
||
ref={importFileRef}
|
||
type="file"
|
||
accept=".xls,.xlsx"
|
||
className="hidden"
|
||
onChange={e => {
|
||
const f = e.target.files?.[0]
|
||
if (f) { handleImportFile(f); e.target.value = '' }
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* Превью импорта */}
|
||
{importRows && (
|
||
<div className="border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden">
|
||
<div className="px-4 py-3 bg-slate-50 dark:bg-slate-800/50 flex items-center justify-between">
|
||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||
Найдено замков: {importRows.length}
|
||
{' · '}
|
||
<span className="text-emerald-600 dark:text-emerald-400">
|
||
совпадений: {importRows.filter(r => r.roomId && !r.skip).length}
|
||
</span>
|
||
{importRows.some(r => !r.roomId) && (
|
||
<span className="text-amber-500 ml-1">
|
||
· без номера: {importRows.filter(r => !r.roomId).length}
|
||
</span>
|
||
)}
|
||
{importRows.some(r => r.skip) && (
|
||
<span className="text-slate-400 ml-1">
|
||
· уже привязаны: {importRows.filter(r => r.skip).length}
|
||
</span>
|
||
)}
|
||
</span>
|
||
<button onClick={() => setImportRows(null)} className="text-slate-400 hover:text-slate-600">
|
||
<X size={14} />
|
||
</button>
|
||
</div>
|
||
<div className="divide-y divide-slate-100 dark:divide-slate-800 max-h-72 overflow-y-auto">
|
||
{importRows.map((row, i) => {
|
||
const matchedRoom = rooms.find(r => r.id === row.roomId)
|
||
return (
|
||
<div
|
||
key={i}
|
||
className={cn(
|
||
'flex items-center gap-3 px-4 py-2.5 text-sm',
|
||
row.skip ? 'opacity-40' : '',
|
||
)}
|
||
>
|
||
<div className="w-4 shrink-0">
|
||
{row.skip
|
||
? <Check size={13} className="text-slate-400" />
|
||
: row.roomId
|
||
? <Check size={13} className="text-emerald-500" />
|
||
: <AlertCircle size={13} className="text-amber-400" />}
|
||
</div>
|
||
<span className="w-10 font-mono text-slate-500 shrink-0">{row.doorName}</span>
|
||
<span className="flex-1 font-mono text-xs text-slate-600 dark:text-slate-400">{row.lockMac}</span>
|
||
<select
|
||
className="input text-xs py-1 w-44"
|
||
value={row.roomId}
|
||
disabled={row.skip}
|
||
onChange={e => setImportRows(prev => prev!.map((r, j) =>
|
||
j === i ? { ...r, roomId: e.target.value } : r
|
||
))}
|
||
>
|
||
<option value="">— номер не выбран —</option>
|
||
{rooms.map(r => (
|
||
<option key={r.id} value={r.id}>
|
||
№{r.number}{r.name ? ` ${r.name}` : ''}
|
||
</option>
|
||
))}
|
||
</select>
|
||
{row.skip && <span className="text-xs text-slate-400 shrink-0">уже привязан</span>}
|
||
{!row.skip && matchedRoom && <span className="text-xs text-emerald-600 shrink-0">авто</span>}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
<div className="px-4 py-3 bg-slate-50 dark:bg-slate-800/50 flex justify-end gap-2">
|
||
<button onClick={() => setImportRows(null)} className="btn-secondary text-sm">
|
||
Отмена
|
||
</button>
|
||
<button
|
||
onClick={handleImport}
|
||
disabled={importing || importRows.every(r => r.skip)}
|
||
className="btn-primary flex items-center gap-2 text-sm"
|
||
>
|
||
{importing ? <RefreshCw size={14} className="animate-spin" /> : <Save size={14} />}
|
||
Импортировать {importRows.filter(r => !r.skip).length} замков
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Форма добавления */}
|
||
{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, i) => (
|
||
<div key={m.lockMac ?? i}>
|
||
{editingMac === m.lockMac ? (
|
||
/* ── Режим редактирования ── */
|
||
<div className="p-4 rounded-xl border-2 border-brand-400 bg-brand-50 dark:bg-brand-900/10 space-y-3">
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<div>
|
||
<label className="form-label">Номер</label>
|
||
<select
|
||
value={editRoomId}
|
||
onChange={e => setEditRoomId(e.target.value)}
|
||
className="input w-full"
|
||
>
|
||
<option value="">— Без комнаты —</option>
|
||
{rooms.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={editLockMac}
|
||
onChange={e => setEditLockMac(e.target.value.replace(/[^0-9A-Fa-f:]/g, '').toUpperCase())}
|
||
maxLength={17}
|
||
className="input w-full font-mono text-sm"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="form-label">Название (необяз.)</label>
|
||
<input
|
||
type="text"
|
||
value={editLockName}
|
||
onChange={e => setEditLockName(e.target.value)}
|
||
placeholder="Замок номера 101"
|
||
className="input w-full"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={handleSaveEdit}
|
||
disabled={editLoading || !editLockMac.trim()}
|
||
className="btn-primary flex items-center gap-2 text-sm"
|
||
>
|
||
{editLoading ? <RefreshCw size={13} className="animate-spin" /> : <Check size={13} />}
|
||
Сохранить
|
||
</button>
|
||
<button
|
||
onClick={() => setEditingMac(null)}
|
||
disabled={editLoading}
|
||
className="btn-secondary text-sm"
|
||
>
|
||
Отмена
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
/* ── Просмотр ── */
|
||
<div className={cn(
|
||
'flex items-center justify-between p-3 rounded-lg border bg-white dark:bg-slate-900',
|
||
m.roomId
|
||
? 'border-slate-200 dark:border-slate-700'
|
||
: 'border-amber-200 dark:border-amber-700/40',
|
||
)}>
|
||
<div className="flex items-center gap-3">
|
||
<div className={cn(
|
||
'w-8 h-8 rounded-lg flex items-center justify-center shrink-0',
|
||
m.roomId
|
||
? 'bg-brand-50 dark:bg-brand-900/20'
|
||
: 'bg-amber-50 dark:bg-amber-900/20',
|
||
)}>
|
||
<Lock size={14} className={m.roomId ? 'text-brand-600 dark:text-brand-400' : 'text-amber-500'} />
|
||
</div>
|
||
<div>
|
||
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">
|
||
{m.roomId
|
||
? <>Номер {m.roomNumber}{m.roomName ? ` — ${m.roomName}` : ''}</>
|
||
: <span className="text-amber-600 dark:text-amber-400">Комната не привязана{m.lockName ? ` — ${m.lockName}` : ''}</span>
|
||
}
|
||
</p>
|
||
<p className="text-xs text-slate-500 font-mono">
|
||
{m.lockMac}
|
||
{m.roomId && m.lockName && <span className="text-slate-400 font-sans ml-2">{m.lockName}</span>}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
onClick={() => handleStartEdit(m)}
|
||
className="btn-ghost p-2 text-slate-400 hover:text-brand-600"
|
||
title="Редактировать"
|
||
>
|
||
<Pencil size={14} />
|
||
</button>
|
||
<button
|
||
onClick={() => handleUnmap(m.lockMac)}
|
||
className="btn-ghost p-2 text-red-400 hover:text-red-600"
|
||
title="Удалить привязку"
|
||
>
|
||
<Trash2 size={14} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</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
|