Files
hotelsync/src/pages/TTLockPage.tsx
HotelSync ed2dfd4c7f feat: TTLock UI — toast notifications, edit mappings, sector grid 2x8
- 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>
2026-04-02 18:58:33 +03:00

923 lines
40 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, 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