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>
This commit is contained in:
@@ -3,7 +3,7 @@ import * as XLSX from 'xlsx'
|
|||||||
import {
|
import {
|
||||||
KeyRound, Save, RefreshCw, Check, X, AlertCircle,
|
KeyRound, Save, RefreshCw, Check, X, AlertCircle,
|
||||||
ChevronDown, ChevronRight, Trash2, Plus, Eye, EyeOff,
|
ChevronDown, ChevronRight, Trash2, Plus, Eye, EyeOff,
|
||||||
Lock, Unlock, Info, HardDriveDownload, Zap, Terminal, Upload, Globe, Search,
|
Lock, Unlock, Info, Zap, Upload, Globe, Pencil,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { api, type TTLockConfig, type RoomLockMapping, type Workstation } from '../lib/api'
|
import { api, type TTLockConfig, type RoomLockMapping, type Workstation } from '../lib/api'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
@@ -66,32 +66,25 @@ function Hint({ children }: { children: React.ReactNode }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sector Picker ─────────────────────────────────────────────────────────────
|
// ── Sector Picker — 2 rows of 8 ───────────────────────────────────────────────
|
||||||
|
|
||||||
function SectorPicker({ value, onChange }: {
|
function SectorPicker({ value, onChange }: {
|
||||||
value: string
|
value: string
|
||||||
onChange: (v: string) => void
|
onChange: (v: string) => void
|
||||||
}) {
|
}) {
|
||||||
// Parse comma-separated string into a Set of numbers
|
|
||||||
const selected = new Set(
|
const selected = new Set(
|
||||||
value.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n) && n >= 1 && n <= 16)
|
value.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n) && n >= 1 && n <= 16)
|
||||||
)
|
)
|
||||||
|
|
||||||
const toggle = (n: number) => {
|
const toggle = (n: number) => {
|
||||||
const next = new Set(selected)
|
const next = new Set(selected)
|
||||||
if (next.has(n)) {
|
if (next.has(n)) { next.delete(n) } else { next.add(n) }
|
||||||
next.delete(n)
|
onChange(Array.from(next).sort((a, b) => a - b).join(','))
|
||||||
} else {
|
|
||||||
next.add(n)
|
|
||||||
}
|
|
||||||
// Sort numerically and join
|
|
||||||
const sorted = Array.from(next).sort((a, b) => a - b)
|
|
||||||
onChange(sorted.join(','))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex flex-wrap gap-2 mt-1">
|
<div className="grid grid-cols-8 gap-2 mt-1">
|
||||||
{Array.from({ length: 16 }, (_, i) => i + 1).map(n => (
|
{Array.from({ length: 16 }, (_, i) => i + 1).map(n => (
|
||||||
<button
|
<button
|
||||||
key={n}
|
key={n}
|
||||||
@@ -111,12 +104,43 @@ function SectorPicker({ value, onChange }: {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-500 mt-2">
|
<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>
|
Выбрано: <code className="bg-slate-100 dark:bg-slate-800 px-1 rounded font-mono">{value || '(нет)'}</code>
|
||||||
{' — '}секторы Mifare-карты, которые использует TTHotel. Обычно 1–10.
|
{' — '}секторы Mifare-карты, которые использует TTHotel.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 ────────────────────────────────────────────────────────────
|
// ── DLL error note ────────────────────────────────────────────────────────────
|
||||||
function isDllEntryError(msg: string): boolean {
|
function isDllEntryError(msg: string): boolean {
|
||||||
return msg.includes('точку входа') || msg.toLowerCase().includes('entrypoint') || msg.toLowerCase().includes('entry point')
|
return msg.includes('точку входа') || msg.toLowerCase().includes('entrypoint') || msg.toLowerCase().includes('entry point')
|
||||||
@@ -135,15 +159,26 @@ export function TTLockPage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [testingApi, setTestingApi] = useState(false)
|
const [testingApi, setTestingApi] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [success, setSuccess] = useState<string | null>(null)
|
// 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 [isEnabled, setIsEnabled] = useState(false)
|
||||||
const [clientId, setClientId] = useState('')
|
const [clientId, setClientId] = useState('')
|
||||||
const [clientSecret, setClientSecret] = useState('')
|
const [clientSecret, setClientSecret] = useState('')
|
||||||
const [cardSectors, setCardSectors] = useState('1,2,3,4,5,6,7,8,9,10')
|
const [cardSectors, setCardSectors] = useState('1,2,3,4,5,6,7,8,9,10')
|
||||||
const [apiServer, setApiServer] = useState('https://euapi.ttlock.com')
|
|
||||||
|
|
||||||
// Per-workstation COM port state
|
// Per-workstation COM port state
|
||||||
const [wsComPorts, setWsComPorts] = useState<Record<string, string>>({})
|
const [wsComPorts, setWsComPorts] = useState<Record<string, string>>({})
|
||||||
@@ -154,29 +189,30 @@ export function TTLockPage() {
|
|||||||
const [wsPorts, setWsPorts] = useState<Record<string, { port: string; description?: string }[]>>({})
|
const [wsPorts, setWsPorts] = useState<Record<string, { port: string; description?: string }[]>>({})
|
||||||
const [wsPortsLoading, setWsPortsLoading] = useState<Record<string, boolean>>({})
|
const [wsPortsLoading, setWsPortsLoading] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
// Форма привязки замков
|
// Форма добавления привязки
|
||||||
const [addRoomId, setAddRoomId] = useState('')
|
const [addRoomId, setAddRoomId] = useState('')
|
||||||
const [addLockMac, setAddLockMac] = useState('')
|
const [addLockMac, setAddLockMac] = useState('')
|
||||||
const [addLockName, setAddLockName] = useState('')
|
const [addLockName, setAddLockName] = useState('')
|
||||||
const [addBuildNo, setAddBuildNo] = useState('1')
|
|
||||||
const [addFloorNo, setAddFloorNo] = useState('1')
|
|
||||||
const [addLoading, setAddLoading] = useState(false)
|
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
|
// Импорт из XLS
|
||||||
interface ImportRow {
|
interface ImportRow {
|
||||||
doorName: string // из файла — Door Name
|
doorName: string
|
||||||
lockMac: string // из файла — Lock Mac (без двоеточий)
|
lockMac: string
|
||||||
roomId: string // авто-матченный room.id или ''
|
roomId: string
|
||||||
skip: boolean // уже привязан
|
skip: boolean
|
||||||
}
|
}
|
||||||
const [importRows, setImportRows] = useState<ImportRow[] | null>(null)
|
const [importRows, setImportRows] = useState<ImportRow[] | null>(null)
|
||||||
const [importing, setImporting] = useState(false)
|
const [importing, setImporting] = useState(false)
|
||||||
const importFileRef = useRef<HTMLInputElement>(null)
|
const importFileRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const showSuccess = (msg: string) => {
|
|
||||||
setSuccess(msg); setTimeout(() => setSuccess(null), 3500)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleImportFile = async (file: File) => {
|
const handleImportFile = async (file: File) => {
|
||||||
try {
|
try {
|
||||||
const buf = await file.arrayBuffer()
|
const buf = await file.arrayBuffer()
|
||||||
@@ -185,31 +221,23 @@ export function TTLockPage() {
|
|||||||
const rows = XLSX.utils.sheet_to_json(ws, { defval: '' }) as Array<Record<string, unknown>>
|
const rows = XLSX.utils.sheet_to_json(ws, { defval: '' }) as Array<Record<string, unknown>>
|
||||||
|
|
||||||
const parsed: ImportRow[] = rows.map(row => {
|
const parsed: ImportRow[] = rows.map(row => {
|
||||||
const doorName = String(row['Door Name'] ?? '').trim()
|
const doorName = String(row['Door Name'] ?? '').trim()
|
||||||
const rawMac = String(row['Lock Mac'] ?? '').trim()
|
const rawMac = String(row['Lock Mac'] ?? '').trim()
|
||||||
const mac = rawMac.replace(/:/g, '').toUpperCase()
|
const mac = rawMac.replace(/:/g, '').toUpperCase()
|
||||||
|
const matched = rooms.find(r => String(r.number).trim() === doorName)
|
||||||
const matched = rooms.find(r => String(r.number).trim() === doorName)
|
|
||||||
const alreadyMapped = matched ? mappings.some(m => m.roomId === matched.id) : false
|
const alreadyMapped = matched ? mappings.some(m => m.roomId === matched.id) : false
|
||||||
|
return { doorName, lockMac: mac, roomId: matched?.id ?? '', skip: alreadyMapped }
|
||||||
return {
|
|
||||||
doorName,
|
|
||||||
lockMac: mac,
|
|
||||||
roomId: matched?.id ?? '',
|
|
||||||
skip: alreadyMapped,
|
|
||||||
}
|
|
||||||
}).filter(r => r.lockMac.length > 0)
|
}).filter(r => r.lockMac.length > 0)
|
||||||
|
|
||||||
setImportRows(parsed)
|
setImportRows(parsed)
|
||||||
} catch {
|
} catch {
|
||||||
setError('Не удалось прочитать файл. Убедитесь, что это .xls/.xlsx из TTHotel.')
|
showToast('error', 'Не удалось прочитать файл. Убедитесь, что это .xls/.xlsx из TTHotel.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleImport = async () => {
|
const handleImport = async () => {
|
||||||
if (!importRows) return
|
if (!importRows) return
|
||||||
setImporting(true)
|
setImporting(true)
|
||||||
setError(null)
|
|
||||||
let ok = 0, fail = 0
|
let ok = 0, fail = 0
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
importRows
|
importRows
|
||||||
@@ -228,7 +256,7 @@ export function TTLockPage() {
|
|||||||
setImporting(false)
|
setImporting(false)
|
||||||
setImportRows(null)
|
setImportRows(null)
|
||||||
await load()
|
await load()
|
||||||
showSuccess(`Импортировано: ${ok} замков${fail ? `, ошибок: ${fail}` : ''}`)
|
showToast('success', `Импортировано: ${ok} замков${fail ? `, ошибок: ${fail}` : ''}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -245,13 +273,12 @@ export function TTLockPage() {
|
|||||||
setIsEnabled(cfg.isEnabled)
|
setIsEnabled(cfg.isEnabled)
|
||||||
setClientId(cfg.clientId)
|
setClientId(cfg.clientId)
|
||||||
setCardSectors(cfg.cardSectors || '1,2,3,4,5,6,7,8,9,10')
|
setCardSectors(cfg.cardSectors || '1,2,3,4,5,6,7,8,9,10')
|
||||||
setApiServer(cfg.apiServer || 'https://euapi.ttlock.com')
|
|
||||||
setMappings(maps)
|
setMappings(maps)
|
||||||
setRooms(roomList)
|
setRooms(roomList)
|
||||||
setWorkstations(wsList)
|
setWorkstations(wsList)
|
||||||
setWsComPorts(Object.fromEntries(wsList.map(w => [w.id, w.ttlockComPort ?? ''])))
|
setWsComPorts(Object.fromEntries(wsList.map(w => [w.id, w.ttlockComPort ?? ''])))
|
||||||
} catch {
|
} catch {
|
||||||
setError('Не удалось загрузить настройки')
|
showToast('error', 'Не удалось загрузить настройки')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -261,20 +288,19 @@ export function TTLockPage() {
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError(null)
|
|
||||||
try {
|
try {
|
||||||
await api.ttlock.updateConfig(slug, {
|
await api.ttlock.updateConfig(slug, {
|
||||||
isEnabled,
|
isEnabled,
|
||||||
clientId: clientId.trim(),
|
clientId: clientId.trim(),
|
||||||
clientSecret: clientSecret.trim() || undefined,
|
clientSecret: clientSecret.trim() || undefined,
|
||||||
cardSectors: cardSectors.trim(),
|
cardSectors: cardSectors.trim(),
|
||||||
apiServer: apiServer,
|
apiServer: 'https://euapi.ttlock.com',
|
||||||
})
|
})
|
||||||
showSuccess('Настройки сохранены')
|
showToast('success', 'Настройки сохранены')
|
||||||
setClientSecret('')
|
setClientSecret('')
|
||||||
await load()
|
await load()
|
||||||
} catch {
|
} catch {
|
||||||
setError('Ошибка сохранения')
|
showToast('error', 'Ошибка сохранения')
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@@ -282,14 +308,13 @@ export function TTLockPage() {
|
|||||||
|
|
||||||
const handleTestApi = async () => {
|
const handleTestApi = async () => {
|
||||||
const ws = workstations[0]
|
const ws = workstations[0]
|
||||||
if (!ws) { setError('Нет рабочих мест — добавьте на странице «Оборудование»'); return }
|
if (!ws) { showToast('error', 'Нет рабочих мест — добавьте на странице «Оборудование»'); return }
|
||||||
setTestingApi(true)
|
setTestingApi(true)
|
||||||
setError(null)
|
|
||||||
try {
|
try {
|
||||||
await api.ttlock.testApi(slug, ws.id)
|
await api.ttlock.testApi(slug, ws.id)
|
||||||
showSuccess('TTLock Cloud API работает — учётные данные верны')
|
showToast('success', 'Подключение к TTLock Cloud API успешно')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Ошибка проверки API')
|
showToast('error', e instanceof Error ? e.message : 'Ошибка проверки API')
|
||||||
} finally {
|
} finally {
|
||||||
setTestingApi(false)
|
setTestingApi(false)
|
||||||
}
|
}
|
||||||
@@ -298,36 +323,62 @@ export function TTLockPage() {
|
|||||||
const handleAddMapping = async () => {
|
const handleAddMapping = async () => {
|
||||||
if (!addRoomId || !addLockMac.trim()) return
|
if (!addRoomId || !addLockMac.trim()) return
|
||||||
setAddLoading(true)
|
setAddLoading(true)
|
||||||
setError(null)
|
|
||||||
try {
|
try {
|
||||||
await api.ttlock.mapRoom(slug, {
|
await api.ttlock.mapRoom(slug, {
|
||||||
roomId: addRoomId,
|
roomId: addRoomId,
|
||||||
lockMac: addLockMac.trim(),
|
lockMac: addLockMac.trim(),
|
||||||
lockName: addLockName.trim() || undefined,
|
lockName: addLockName.trim() || undefined,
|
||||||
buildNo: parseInt(addBuildNo) || 1,
|
|
||||||
floorNo: parseInt(addFloorNo) || 1,
|
|
||||||
})
|
})
|
||||||
setAddRoomId('')
|
setAddRoomId('')
|
||||||
setAddLockMac('')
|
setAddLockMac('')
|
||||||
setAddLockName('')
|
setAddLockName('')
|
||||||
setAddBuildNo('1')
|
|
||||||
setAddFloorNo('1')
|
|
||||||
await load()
|
await load()
|
||||||
showSuccess('Замок привязан к номеру')
|
showToast('success', 'Замок привязан к номеру')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Ошибка привязки замка')
|
showToast('error', e instanceof Error ? e.message : 'Ошибка привязки замка')
|
||||||
} finally {
|
} finally {
|
||||||
setAddLoading(false)
|
setAddLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUnmap = async (roomId: string) => {
|
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 {
|
try {
|
||||||
await api.ttlock.unmapRoom(slug, roomId)
|
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()
|
await load()
|
||||||
showSuccess('Привязка удалена')
|
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 {
|
} catch {
|
||||||
setError('Ошибка удаления привязки')
|
showToast('error', 'Ошибка удаления привязки')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,7 +388,7 @@ export function TTLockPage() {
|
|||||||
const result = await api.workstations.listPorts(slug, wsId)
|
const result = await api.workstations.listPorts(slug, wsId)
|
||||||
setWsPorts(prev => ({ ...prev, [wsId]: result.ports }))
|
setWsPorts(prev => ({ ...prev, [wsId]: result.ports }))
|
||||||
} catch {
|
} catch {
|
||||||
// If agent offline or error, leave wsPorts[wsId] undefined — fallback to text input
|
// agent offline — fallback to text input
|
||||||
} finally {
|
} finally {
|
||||||
setWsPortsLoading(prev => ({ ...prev, [wsId]: false }))
|
setWsPortsLoading(prev => ({ ...prev, [wsId]: false }))
|
||||||
}
|
}
|
||||||
@@ -356,6 +407,9 @@ export function TTLockPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="max-w-3xl mx-auto p-6 space-y-6">
|
<div className="max-w-3xl mx-auto p-6 space-y-6">
|
||||||
|
|
||||||
|
{/* Floating toasts */}
|
||||||
|
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<div className="w-10 h-10 rounded-xl bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
|
||||||
@@ -416,42 +470,7 @@ export function TTLockPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Сервер TTLock API */}
|
{/* Секторы карты */}
|
||||||
<div>
|
|
||||||
<label className="form-label">Сервер TTLock API</label>
|
|
||||||
<div className="flex gap-3 mt-1">
|
|
||||||
{[
|
|
||||||
{ value: 'https://euapi.ttlock.com', label: 'EU', hint: 'euapi.ttlock.com' },
|
|
||||||
{ value: 'https://cnapi.ttlock.com', label: 'China', hint: 'cnapi.ttlock.com' },
|
|
||||||
].map(opt => (
|
|
||||||
<label
|
|
||||||
key={opt.value}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-2 px-4 py-2.5 rounded-lg border-2 cursor-pointer transition-colors select-none',
|
|
||||||
apiServer === opt.value
|
|
||||||
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300'
|
|
||||||
: 'border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 hover:border-slate-300',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="apiServer"
|
|
||||||
value={opt.value}
|
|
||||||
checked={apiServer === opt.value}
|
|
||||||
onChange={() => setApiServer(opt.value)}
|
|
||||||
className="sr-only"
|
|
||||||
/>
|
|
||||||
<span className="font-semibold text-sm">{opt.label}</span>
|
|
||||||
<span className="text-xs font-mono opacity-60">{opt.hint}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-slate-400 mt-1.5">
|
|
||||||
Выберите регион API. EU — для большинства стран, China — для Китая.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Секторы карты — визуальный пикер */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="form-label">Секторы карты</label>
|
<label className="form-label">Секторы карты</label>
|
||||||
<SectorPicker value={cardSectors} onChange={setCardSectors} />
|
<SectorPicker value={cardSectors} onChange={setCardSectors} />
|
||||||
@@ -460,7 +479,7 @@ export function TTLockPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button onClick={handleTestApi} disabled={testingApi || saving} className="btn-secondary 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} />}
|
{testingApi ? <RefreshCw size={15} className="animate-spin" /> : <Globe size={15} />}
|
||||||
Проверить API
|
Проверить подключение
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleSave} disabled={saving} className="btn-primary flex items-center gap-2">
|
<button onClick={handleSave} disabled={saving} className="btn-primary flex items-center gap-2">
|
||||||
{saving ? <RefreshCw size={15} className="animate-spin" /> : <Save size={15} />}
|
{saving ? <RefreshCw size={15} className="animate-spin" /> : <Save size={15} />}
|
||||||
@@ -479,38 +498,13 @@ export function TTLockPage() {
|
|||||||
Агент должен быть онлайн.
|
Агент должен быть онлайн.
|
||||||
</Hint>
|
</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 && (
|
{workstations.length === 0 && (
|
||||||
<p className="text-sm text-slate-500">Нет рабочих мест. Добавьте их на странице «Оборудование».</p>
|
<p className="text-sm text-slate-500">Нет рабочих мест. Добавьте их на странице «Оборудование».</p>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{workstations.map(ws => {
|
{workstations.map(ws => {
|
||||||
const hasPort = !!wsComPorts[ws.id]?.trim()
|
const hasPort = !!wsComPorts[ws.id]?.trim()
|
||||||
const portList = wsPorts[ws.id]
|
const portList = wsPorts[ws.id]
|
||||||
const portsLoading = wsPortsLoading[ws.id] ?? false
|
const portsLoading = wsPortsLoading[ws.id] ?? false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -535,7 +529,7 @@ export function TTLockPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* COM port — dropdown if ports loaded, text input otherwise */}
|
{/* COM port */}
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
{portList && portList.length > 0 ? (
|
{portList && portList.length > 0 ? (
|
||||||
<select
|
<select
|
||||||
@@ -584,9 +578,9 @@ export function TTLockPage() {
|
|||||||
setWsSaving(prev => ({ ...prev, [ws.id]: true }))
|
setWsSaving(prev => ({ ...prev, [ws.id]: true }))
|
||||||
try {
|
try {
|
||||||
await api.workstations.update(slug, ws.id, { ttlockComPort: wsComPorts[ws.id]?.trim() || null })
|
await api.workstations.update(slug, ws.id, { ttlockComPort: wsComPorts[ws.id]?.trim() || null })
|
||||||
showSuccess(`COM-порт для «${ws.name}» сохранён`)
|
showToast('success', `COM-порт для «${ws.name}» сохранён`)
|
||||||
await load()
|
await load()
|
||||||
} catch { setError('Ошибка сохранения') }
|
} catch { showToast('error', 'Ошибка сохранения') }
|
||||||
finally { setWsSaving(prev => ({ ...prev, [ws.id]: false })) }
|
finally { setWsSaving(prev => ({ ...prev, [ws.id]: false })) }
|
||||||
}}
|
}}
|
||||||
className="btn-secondary p-1.5 shrink-0"
|
className="btn-secondary p-1.5 shrink-0"
|
||||||
@@ -594,7 +588,7 @@ export function TTLockPage() {
|
|||||||
>
|
>
|
||||||
{wsSaving[ws.id]
|
{wsSaving[ws.id]
|
||||||
? <RefreshCw size={13} className="animate-spin" />
|
? <RefreshCw size={13} className="animate-spin" />
|
||||||
: <HardDriveDownload size={13} />}
|
: <Save size={13} />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Test encoder */}
|
{/* Test encoder */}
|
||||||
@@ -603,13 +597,14 @@ export function TTLockPage() {
|
|||||||
disabled={!ws.isOnline || !hasPort || wsTesting[ws.id]}
|
disabled={!ws.isOnline || !hasPort || wsTesting[ws.id]}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setWsTesting(prev => ({ ...prev, [ws.id]: true }))
|
setWsTesting(prev => ({ ...prev, [ws.id]: true }))
|
||||||
setError(null)
|
|
||||||
try {
|
try {
|
||||||
await api.ttlock.testEncoder(slug, ws.id)
|
await api.ttlock.testEncoder(slug, ws.id)
|
||||||
showSuccess(`Энкодер на «${ws.name}» отвечает — всё готово!`)
|
showToast('success', `Энкодер на «${ws.name}» отвечает — всё готово!`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : 'Ошибка проверки'
|
const msg = e instanceof Error ? e.message : 'Ошибка проверки'
|
||||||
setError(msg)
|
showToast('error', isDllEntryError(msg)
|
||||||
|
? `Имена функций в DLL не совпадают. Проверьте index.js в TTHotel.`
|
||||||
|
: msg)
|
||||||
} finally { setWsTesting(prev => ({ ...prev, [ws.id]: false })) }
|
} finally { setWsTesting(prev => ({ ...prev, [ws.id]: false })) }
|
||||||
}}
|
}}
|
||||||
className="btn-secondary p-1.5 shrink-0"
|
className="btn-secondary p-1.5 shrink-0"
|
||||||
@@ -619,55 +614,6 @@ export function TTLockPage() {
|
|||||||
? <RefreshCw size={13} className="animate-spin" />
|
? <RefreshCw size={13} className="animate-spin" />
|
||||||
: <Zap size={13} />}
|
: <Zap size={13} />}
|
||||||
</button>
|
</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>
|
|
||||||
|
|
||||||
{/* Find TTHotel API config */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!ws.isOnline}
|
|
||||||
onClick={async () => {
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const result = await api.ttlock.findApiConfig(slug, ws.id)
|
|
||||||
if (!result.results?.length) {
|
|
||||||
showSuccess('Ничего не найдено в исходниках TTHotel')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const out = result.results.map(r =>
|
|
||||||
`📄 ${r.path}\n${r.matches.join('\n')}`
|
|
||||||
).join('\n\n')
|
|
||||||
// Выводим в консоль браузера и в alert для простоты
|
|
||||||
console.log('[TTHotel API config scan]\n', out)
|
|
||||||
alert(`Найдено в TTHotel (см. консоль браузера):\n\n${out.slice(0, 2000)}`)
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : 'Ошибка сканирования'
|
|
||||||
setError(msg)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="btn-secondary p-1.5 shrink-0"
|
|
||||||
title={!ws.isOnline ? 'Агент офлайн' : 'Найти API-сервер TTHotel в исходниках'}
|
|
||||||
>
|
|
||||||
<Search size={13} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -744,24 +690,15 @@ export function TTLockPage() {
|
|||||||
row.skip ? 'opacity-40' : '',
|
row.skip ? 'opacity-40' : '',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Статус совпадения */}
|
|
||||||
<div className="w-4 shrink-0">
|
<div className="w-4 shrink-0">
|
||||||
{row.skip ? (
|
{row.skip
|
||||||
<Check size={13} className="text-slate-400" />
|
? <Check size={13} className="text-slate-400" />
|
||||||
) : row.roomId ? (
|
: row.roomId
|
||||||
<Check size={13} className="text-emerald-500" />
|
? <Check size={13} className="text-emerald-500" />
|
||||||
) : (
|
: <AlertCircle size={13} className="text-amber-400" />}
|
||||||
<AlertCircle size={13} className="text-amber-400" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Door Name из файла */}
|
|
||||||
<span className="w-10 font-mono text-slate-500 shrink-0">{row.doorName}</span>
|
<span className="w-10 font-mono text-slate-500 shrink-0">{row.doorName}</span>
|
||||||
|
|
||||||
{/* MAC */}
|
|
||||||
<span className="flex-1 font-mono text-xs text-slate-600 dark:text-slate-400">{row.lockMac}</span>
|
<span className="flex-1 font-mono text-xs text-slate-600 dark:text-slate-400">{row.lockMac}</span>
|
||||||
|
|
||||||
{/* Комната — dropdown для ручного выбора */}
|
|
||||||
<select
|
<select
|
||||||
className="input text-xs py-1 w-44"
|
className="input text-xs py-1 w-44"
|
||||||
value={row.roomId}
|
value={row.roomId}
|
||||||
@@ -777,13 +714,8 @@ export function TTLockPage() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{row.skip && <span className="text-xs text-slate-400 shrink-0">уже привязан</span>}
|
||||||
{row.skip && (
|
{!row.skip && matchedRoom && <span className="text-xs text-emerald-600 shrink-0">авто</span>}
|
||||||
<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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -797,7 +729,7 @@ export function TTLockPage() {
|
|||||||
disabled={importing || importRows.every(r => r.skip)}
|
disabled={importing || importRows.every(r => r.skip)}
|
||||||
className="btn-primary flex items-center gap-2 text-sm"
|
className="btn-primary flex items-center gap-2 text-sm"
|
||||||
>
|
>
|
||||||
{importing ? <RefreshCw size={14} className="animate-spin" /> : <HardDriveDownload size={14} />}
|
{importing ? <RefreshCw size={14} className="animate-spin" /> : <Save size={14} />}
|
||||||
Импортировать {importRows.filter(r => !r.skip).length} замков
|
Импортировать {importRows.filter(r => !r.skip).length} замков
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -842,28 +774,6 @@ export function TTLockPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="form-label">Здание <span className="font-normal text-slate-400">(buildNo из TTHotel)</span></label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={addBuildNo}
|
|
||||||
onChange={e => setAddBuildNo(e.target.value)}
|
|
||||||
className="input w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="form-label">Этаж <span className="font-normal text-slate-400">(floorNo из TTHotel)</span></label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={addFloorNo}
|
|
||||||
onChange={e => setAddFloorNo(e.target.value)}
|
|
||||||
className="input w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={handleAddMapping}
|
onClick={handleAddMapping}
|
||||||
disabled={!addRoomId || !addLockMac.trim() || addLoading}
|
disabled={!addRoomId || !addLockMac.trim() || addLoading}
|
||||||
@@ -879,45 +789,113 @@ export function TTLockPage() {
|
|||||||
{mappings.length > 0 ? (
|
{mappings.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{mappings.map((m, i) => (
|
{mappings.map((m, i) => (
|
||||||
<div
|
<div key={m.lockMac ?? i}>
|
||||||
key={m.roomId ?? m.lockMac ?? i}
|
{editingMac === m.lockMac ? (
|
||||||
className={cn(
|
/* ── Режим редактирования ── */
|
||||||
'flex items-center justify-between p-3 rounded-lg border bg-white dark:bg-slate-900',
|
<div className="p-4 rounded-xl border-2 border-brand-400 bg-brand-50 dark:bg-brand-900/10 space-y-3">
|
||||||
m.roomId
|
<div className="grid grid-cols-3 gap-3">
|
||||||
? 'border-slate-200 dark:border-slate-700'
|
<div>
|
||||||
: 'border-amber-200 dark:border-amber-700/40',
|
<label className="form-label">Номер</label>
|
||||||
)}
|
<select
|
||||||
>
|
value={editRoomId}
|
||||||
<div className="flex items-center gap-3">
|
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(
|
<div className={cn(
|
||||||
'w-8 h-8 rounded-lg flex items-center justify-center shrink-0',
|
'flex items-center justify-between p-3 rounded-lg border bg-white dark:bg-slate-900',
|
||||||
m.roomId
|
m.roomId
|
||||||
? 'bg-brand-50 dark:bg-brand-900/20'
|
? 'border-slate-200 dark:border-slate-700'
|
||||||
: 'bg-amber-50 dark:bg-amber-900/20',
|
: 'border-amber-200 dark:border-amber-700/40',
|
||||||
)}>
|
)}>
|
||||||
<Lock size={14} className={m.roomId ? 'text-brand-600 dark:text-brand-400' : 'text-amber-500'} />
|
<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>
|
)}
|
||||||
<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>}
|
|
||||||
<span className="text-slate-400 font-sans ml-2">здание {m.buildNo ?? 1}, этаж {m.floorNo ?? 1}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleUnmap(m.roomId ?? m.lockMac)}
|
|
||||||
className="btn-ghost p-2 text-red-500 hover:text-red-700"
|
|
||||||
title="Удалить привязку"
|
|
||||||
>
|
|
||||||
<Trash2 size={15} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user