feat: TTLockPage — import locks from TTHotel XLS file

- Parse .xls/.xlsx via SheetJS (xlsx package)
- Auto-match Door Name → room number; unmatched rows kept with empty room
- Preview table: green=matched, amber=unmatched, grey=already mapped
- Per-row room dropdown for manual correction before import
- room_id nullable in room_lock_mappings (migration 052)
- Conflict key changed from (hotel_id, room_id) to (hotel_id, lock_mac)
- LEFT JOIN in GET room-locks to show unmatched locks
- DELETE by UUID or MAC depending on whether room is assigned

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-01 23:08:24 +03:00
parent ea63dec525
commit 1ec3664886
4 changed files with 243 additions and 18 deletions

View File

@@ -1,8 +1,8 @@
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useRef } from 'react'
import {
KeyRound, Save, RefreshCw, Check, X, AlertCircle,
ChevronDown, ChevronRight, Trash2, Plus, Eye, EyeOff,
Lock, Unlock, Info, HardDriveDownload, Zap, Terminal,
Lock, Unlock, Info, HardDriveDownload, Zap, Terminal, Upload,
} from 'lucide-react'
import { api, type TTLockConfig, type RoomLockMapping, type Workstation } from '../lib/api'
import { useAuth } from '../contexts/AuthContext'
@@ -157,10 +157,79 @@ export function TTLockPage() {
const [addLockName, setAddLockName] = useState('')
const [addLoading, setAddLoading] = useState(false)
// Импорт из XLS
interface ImportRow {
doorName: string // из файла — Door Name
lockMac: string // из файла — Lock Mac (без двоеточий)
roomId: string // авто-матченный room.id или ''
skip: boolean // уже привязан
}
const [importRows, setImportRows] = useState<ImportRow[] | null>(null)
const [importing, setImporting] = useState(false)
const importFileRef = useRef<HTMLInputElement>(null)
const showSuccess = (msg: string) => {
setSuccess(msg); setTimeout(() => setSuccess(null), 3500)
}
const handleImportFile = async (file: File) => {
try {
const { read, utils } = await import('xlsx')
const buf = await file.arrayBuffer()
const wb = read(buf, { type: 'array' })
const ws = wb.Sheets[wb.SheetNames[0]]
// header: true — первая строка как ключи
const rows = utils.sheet_to_json<Record<string, string>>(ws, { defval: '' })
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 {
setError('Не удалось прочитать файл. Убедитесь, что это .xls/.xlsx из TTHotel.')
}
}
const handleImport = async () => {
if (!importRows) return
setImporting(true)
setError(null)
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()
showSuccess(`Импортировано: ${ok} замков${fail ? `, ошибок: ${fail}` : ''}`)
}
const load = useCallback(async () => {
if (!slug) return
setLoading(true)
@@ -524,6 +593,125 @@ export function TTLockPage() {
Введите без двоеточий: <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>
{/* Door Name из файла */}
<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>
{/* Комната — dropdown для ручного выбора */}
<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" /> : <HardDriveDownload 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">
@@ -576,27 +764,40 @@ export function TTLockPage() {
{/* Список привязок */}
{mappings.length > 0 ? (
<div className="space-y-2">
{mappings.map(m => (
{mappings.map((m, i) => (
<div
key={m.roomId}
className="flex items-center justify-between p-3 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900"
key={m.roomId ?? m.lockMac ?? i}
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="w-8 h-8 rounded-lg bg-brand-50 dark:bg-brand-900/20 flex items-center justify-center shrink-0">
<Lock size={14} className="text-brand-600 dark:text-brand-400" />
<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.roomNumber}{m.roomName ? `${m.roomName}` : ''}
{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.lockName && <span className="text-slate-400 font-sans ml-2">{m.lockName}</span>}
{m.roomId && m.lockName && <span className="text-slate-400 font-sans ml-2">{m.lockName}</span>}
</p>
</div>
</div>
<button
onClick={() => handleUnmap(m.roomId)}
onClick={() => handleUnmap(m.roomId ?? m.lockMac)}
className="btn-ghost p-2 text-red-500 hover:text-red-700"
title="Удалить привязку"
>