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() {
)
})}
@@ -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() {
/>
-
0 ? (
{mappings.map((m, i) => (
-
-
+
+ {editingMac === m.lockMac ? (
+ /* ── Режим редактирования ── */
+
+
+
+
+
+
+
+
+ setEditLockMac(e.target.value.replace(/[^0-9A-Fa-f:]/g, '').toUpperCase())}
+ maxLength={17}
+ className="input w-full font-mono text-sm"
+ />
+
+
+
+ setEditLockName(e.target.value)}
+ placeholder="Замок номера 101"
+ className="input w-full"
+ />
+
+
+
+
+ {editLoading ? : }
+ Сохранить
+
+ setEditingMac(null)}
+ disabled={editLoading}
+ className="btn-secondary text-sm"
+ >
+ Отмена
+
+
+
+ ) : (
+ /* ── Просмотр ── */
-
+
+
+
+
+
+
+ {m.roomId
+ ? <>Номер {m.roomNumber}{m.roomName ? ` — ${m.roomName}` : ''}>
+ : Комната не привязана{m.lockName ? ` — ${m.lockName}` : ''}
+ }
+
+
+ {m.lockMac}
+ {m.roomId && m.lockName && {m.lockName}}
+
+
+
+
+
handleStartEdit(m)}
+ className="btn-ghost p-2 text-slate-400 hover:text-brand-600"
+ title="Редактировать"
+ >
+
+
+
handleUnmap(m.lockMac)}
+ className="btn-ghost p-2 text-red-400 hover:text-red-600"
+ title="Удалить привязку"
+ >
+
+
+
-
-
- {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}
-
-
-
-
handleUnmap(m.roomId ?? m.lockMac)}
- className="btn-ghost p-2 text-red-500 hover:text-red-700"
- title="Удалить привязку"
- >
-
-
+ )}
))}