diff --git a/src/pages/TTLockPage.tsx b/src/pages/TTLockPage.tsx index df304a7..b7a967a 100644 --- a/src/pages/TTLockPage.tsx +++ b/src/pages/TTLockPage.tsx @@ -3,7 +3,7 @@ import * as XLSX from 'xlsx' import { KeyRound, Save, RefreshCw, Check, X, AlertCircle, 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' import { api, type TTLockConfig, type RoomLockMapping, type Workstation } from '../lib/api' 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 }: { value: string onChange: (v: string) => void }) { - // Parse comma-separated string into a Set of numbers const selected = new Set( value.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n) && n >= 1 && n <= 16) ) const toggle = (n: number) => { const next = new Set(selected) - if (next.has(n)) { - next.delete(n) - } else { - next.add(n) - } - // Sort numerically and join - const sorted = Array.from(next).sort((a, b) => a - b) - onChange(sorted.join(',')) + if (next.has(n)) { next.delete(n) } else { next.add(n) } + onChange(Array.from(next).sort((a, b) => a - b).join(',')) } return (
-
+
{Array.from({ length: 16 }, (_, i) => i + 1).map(n => (
) } +// ── Toast ───────────────────────────────────────────────────────────────────── + +interface ToastItem { id: number; type: 'success' | 'error'; message: string } + +function ToastContainer({ toasts, onRemove }: { toasts: ToastItem[]; onRemove: (id: number) => void }) { + return ( +
+ {toasts.map(t => ( +
+ {t.type === 'success' + ? + : } + {t.message} + +
+ ))} +
+ ) +} + // ── DLL error note ──────────────────────────────────────────────────────────── function isDllEntryError(msg: string): boolean { return msg.includes('точку входа') || msg.toLowerCase().includes('entrypoint') || msg.toLowerCase().includes('entry point') @@ -135,15 +159,26 @@ export function TTLockPage() { const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [testingApi, setTestingApi] = useState(false) - const [error, setError] = useState(null) - const [success, setSuccess] = useState(null) + + // Toast notifications + const [toasts, setToasts] = useState([]) + const toastCounter = useRef(0) + + const showToast = useCallback((type: 'success' | 'error', message: string) => { + const id = ++toastCounter.current + setToasts(prev => [...prev, { id, type, message }]) + setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), 4000) + }, []) + + const removeToast = useCallback((id: number) => { + setToasts(prev => prev.filter(t => t.id !== id)) + }, []) // Форма настроек const [isEnabled, setIsEnabled] = useState(false) const [clientId, setClientId] = useState('') const [clientSecret, setClientSecret] = useState('') const [cardSectors, setCardSectors] = useState('1,2,3,4,5,6,7,8,9,10') - const [apiServer, setApiServer] = useState('https://euapi.ttlock.com') // Per-workstation COM port state const [wsComPorts, setWsComPorts] = useState>({}) @@ -154,29 +189,30 @@ export function TTLockPage() { const [wsPorts, setWsPorts] = useState>({}) const [wsPortsLoading, setWsPortsLoading] = useState>({}) - // Форма привязки замков + // Форма добавления привязки const [addRoomId, setAddRoomId] = useState('') const [addLockMac, setAddLockMac] = useState('') const [addLockName, setAddLockName] = useState('') - const [addBuildNo, setAddBuildNo] = useState('1') - const [addFloorNo, setAddFloorNo] = useState('1') const [addLoading, setAddLoading] = useState(false) + // Редактирование привязки + const [editingMac, setEditingMac] = useState(null) + const [editRoomId, setEditRoomId] = useState('') + const [editLockMac, setEditLockMac] = useState('') + const [editLockName, setEditLockName] = useState('') + const [editLoading, setEditLoading] = useState(false) + // Импорт из XLS interface ImportRow { - doorName: string // из файла — Door Name - lockMac: string // из файла — Lock Mac (без двоеточий) - roomId: string // авто-матченный room.id или '' - skip: boolean // уже привязан + doorName: string + lockMac: string + roomId: string + skip: boolean } const [importRows, setImportRows] = useState(null) const [importing, setImporting] = useState(false) const importFileRef = useRef(null) - const showSuccess = (msg: string) => { - setSuccess(msg); setTimeout(() => setSuccess(null), 3500) - } - const handleImportFile = async (file: File) => { try { const buf = await file.arrayBuffer() @@ -185,31 +221,23 @@ export function TTLockPage() { const rows = XLSX.utils.sheet_to_json(ws, { defval: '' }) as Array> const parsed: ImportRow[] = rows.map(row => { - const doorName = String(row['Door Name'] ?? '').trim() - const rawMac = String(row['Lock Mac'] ?? '').trim() - const mac = rawMac.replace(/:/g, '').toUpperCase() - - const matched = rooms.find(r => String(r.number).trim() === doorName) + const 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, - } + return { doorName, lockMac: mac, roomId: matched?.id ?? '', skip: alreadyMapped } }).filter(r => r.lockMac.length > 0) setImportRows(parsed) } catch { - setError('Не удалось прочитать файл. Убедитесь, что это .xls/.xlsx из TTHotel.') + showToast('error', 'Не удалось прочитать файл. Убедитесь, что это .xls/.xlsx из TTHotel.') } } const handleImport = async () => { if (!importRows) return setImporting(true) - setError(null) let ok = 0, fail = 0 await Promise.allSettled( importRows @@ -228,7 +256,7 @@ export function TTLockPage() { setImporting(false) setImportRows(null) await load() - showSuccess(`Импортировано: ${ok} замков${fail ? `, ошибок: ${fail}` : ''}`) + showToast('success', `Импортировано: ${ok} замков${fail ? `, ошибок: ${fail}` : ''}`) } const load = useCallback(async () => { @@ -245,13 +273,12 @@ export function TTLockPage() { setIsEnabled(cfg.isEnabled) setClientId(cfg.clientId) setCardSectors(cfg.cardSectors || '1,2,3,4,5,6,7,8,9,10') - setApiServer(cfg.apiServer || 'https://euapi.ttlock.com') setMappings(maps) setRooms(roomList) setWorkstations(wsList) setWsComPorts(Object.fromEntries(wsList.map(w => [w.id, w.ttlockComPort ?? '']))) } catch { - setError('Не удалось загрузить настройки') + showToast('error', 'Не удалось загрузить настройки') } finally { setLoading(false) } @@ -261,20 +288,19 @@ export function TTLockPage() { const handleSave = async () => { setSaving(true) - setError(null) try { await api.ttlock.updateConfig(slug, { isEnabled, clientId: clientId.trim(), clientSecret: clientSecret.trim() || undefined, cardSectors: cardSectors.trim(), - apiServer: apiServer, + apiServer: 'https://euapi.ttlock.com', }) - showSuccess('Настройки сохранены') + showToast('success', 'Настройки сохранены') setClientSecret('') await load() } catch { - setError('Ошибка сохранения') + showToast('error', 'Ошибка сохранения') } finally { setSaving(false) } @@ -282,14 +308,13 @@ export function TTLockPage() { const handleTestApi = async () => { const ws = workstations[0] - if (!ws) { setError('Нет рабочих мест — добавьте на странице «Оборудование»'); return } + if (!ws) { showToast('error', 'Нет рабочих мест — добавьте на странице «Оборудование»'); return } setTestingApi(true) - setError(null) try { await api.ttlock.testApi(slug, ws.id) - showSuccess('TTLock Cloud API работает — учётные данные верны') + showToast('success', 'Подключение к TTLock Cloud API успешно') } catch (e) { - setError(e instanceof Error ? e.message : 'Ошибка проверки API') + showToast('error', e instanceof Error ? e.message : 'Ошибка проверки API') } finally { setTestingApi(false) } @@ -298,36 +323,62 @@ export function TTLockPage() { const handleAddMapping = async () => { if (!addRoomId || !addLockMac.trim()) return setAddLoading(true) - setError(null) try { await api.ttlock.mapRoom(slug, { roomId: addRoomId, lockMac: addLockMac.trim(), lockName: addLockName.trim() || undefined, - buildNo: parseInt(addBuildNo) || 1, - floorNo: parseInt(addFloorNo) || 1, }) setAddRoomId('') setAddLockMac('') setAddLockName('') - setAddBuildNo('1') - setAddFloorNo('1') await load() - showSuccess('Замок привязан к номеру') + showToast('success', 'Замок привязан к номеру') } catch (e) { - setError(e instanceof Error ? e.message : 'Ошибка привязки замка') + showToast('error', e instanceof Error ? e.message : 'Ошибка привязки замка') } finally { 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 { - 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() - 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 { - setError('Ошибка удаления привязки') + showToast('error', 'Ошибка удаления привязки') } } @@ -337,7 +388,7 @@ export function TTLockPage() { const result = await api.workstations.listPorts(slug, wsId) setWsPorts(prev => ({ ...prev, [wsId]: result.ports })) } catch { - // If agent offline or error, leave wsPorts[wsId] undefined — fallback to text input + // agent offline — fallback to text input } finally { setWsPortsLoading(prev => ({ ...prev, [wsId]: false })) } @@ -356,6 +407,9 @@ export function TTLockPage() { return (
+ {/* Floating toasts */} + + {/* Header */}
@@ -416,42 +470,7 @@ export function TTLockPage() {
- {/* Сервер TTLock API */} -
- -
- {[ - { value: 'https://euapi.ttlock.com', label: 'EU', hint: 'euapi.ttlock.com' }, - { value: 'https://cnapi.ttlock.com', label: 'China', hint: 'cnapi.ttlock.com' }, - ].map(opt => ( - - ))} -
-

- Выберите регион API. EU — для большинства стран, China — для Китая. -

-
- - {/* Секторы карты — визуальный пикер */} + {/* Секторы карты */}
@@ -460,7 +479,7 @@ export function TTLockPage() {
-
- )} - {success && ( -
- - {success} -
- )} - {workstations.length === 0 && (

Нет рабочих мест. Добавьте их на странице «Оборудование».

)}
{workstations.map(ws => { - const hasPort = !!wsComPorts[ws.id]?.trim() - const portList = wsPorts[ws.id] + const hasPort = !!wsComPorts[ws.id]?.trim() + const portList = wsPorts[ws.id] const portsLoading = wsPortsLoading[ws.id] ?? false return ( @@ -535,7 +529,7 @@ export function TTLockPage() { )}
- {/* COM port — dropdown if ports loaded, text input otherwise */} + {/* COM port */}
{portList && portList.length > 0 ? ( ))} - - {row.skip && ( - уже привязан - )} - {!row.skip && matchedRoom && ( - авто - )} + {row.skip && уже привязан} + {!row.skip && matchedRoom && авто}
) })} @@ -797,7 +729,7 @@ export function TTLockPage() { disabled={importing || importRows.every(r => r.skip)} className="btn-primary flex items-center gap-2 text-sm" > - {importing ? : } + {importing ? : } Импортировать {importRows.filter(r => !r.skip).length} замков
@@ -842,28 +774,6 @@ export function TTLockPage() { />
-
-
- - setAddBuildNo(e.target.value)} - className="input w-full" - /> -
-
- - setAddFloorNo(e.target.value)} - className="input w-full" - /> -
-
+ +
+ + ) : ( + /* ── Просмотр ── */
- +
+
+ +
+
+

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

+

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

+
+
+
+ + +
-
-

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

-

- {m.lockMac} - {m.roomId && m.lockName && {m.lockName}} - здание {m.buildNo ?? 1}, этаж {m.floorNo ?? 1} -

-
- - + )} ))}