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:
18
backend/migrations/052_ttlock_nullable_room.sql
Normal file
18
backend/migrations/052_ttlock_nullable_room.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
-- TTLock: room_id в room_lock_mappings становится необязательным
|
||||||
|
-- Нужно для импорта замков из TTHotel без привязки к номеру в системе
|
||||||
|
|
||||||
|
ALTER TABLE room_lock_mappings
|
||||||
|
ALTER COLUMN room_id DROP NOT NULL;
|
||||||
|
|
||||||
|
-- Добавляем lock_name если не было
|
||||||
|
ALTER TABLE room_lock_mappings
|
||||||
|
ADD COLUMN IF NOT EXISTS lock_name TEXT;
|
||||||
|
|
||||||
|
-- Уникальность теперь по lock_mac (один MAC — одна запись)
|
||||||
|
-- Старый unique(hotel_id, room_id) оставляем (работает с NULL через NULLS NOT DISTINCT в PG15+
|
||||||
|
-- или просто убираем и заменяем на lock_mac)
|
||||||
|
ALTER TABLE room_lock_mappings
|
||||||
|
DROP CONSTRAINT IF EXISTS room_lock_mappings_hotel_id_room_id_key;
|
||||||
|
|
||||||
|
ALTER TABLE room_lock_mappings
|
||||||
|
ADD CONSTRAINT room_lock_mappings_hotel_id_lock_mac_key UNIQUE (hotel_id, lock_mac);
|
||||||
@@ -177,9 +177,9 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
`SELECT m.room_id, m.lock_mac, m.lock_name,
|
`SELECT m.room_id, m.lock_mac, m.lock_name,
|
||||||
r.name AS room_name, r.number AS room_number
|
r.name AS room_name, r.number AS room_number
|
||||||
FROM room_lock_mappings m
|
FROM room_lock_mappings m
|
||||||
JOIN rooms r ON r.id = m.room_id
|
LEFT JOIN rooms r ON r.id = m.room_id
|
||||||
WHERE m.hotel_id = $1
|
WHERE m.hotel_id = $1
|
||||||
ORDER BY r.number`,
|
ORDER BY r.number NULLS LAST, m.lock_name`,
|
||||||
[hotelId],
|
[hotelId],
|
||||||
)
|
)
|
||||||
return rows.map(r => ({
|
return rows.map(r => ({
|
||||||
@@ -192,7 +192,7 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ── POST /api/hotels/:slug/ttlock/room-locks ─────────────────────────────────
|
// ── POST /api/hotels/:slug/ttlock/room-locks ─────────────────────────────────
|
||||||
fastify.post<SlugParam & { Body: { room_id: string; lock_mac: string; lock_name?: string } }>(
|
fastify.post<SlugParam & { Body: { room_id?: string; lock_mac: string; lock_name?: string } }>(
|
||||||
'/api/hotels/:slug/ttlock/room-locks',
|
'/api/hotels/:slug/ttlock/room-locks',
|
||||||
{ onRequest: [fastify.authenticate] },
|
{ onRequest: [fastify.authenticate] },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
@@ -203,7 +203,6 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||||
|
|
||||||
const { room_id: roomId, lock_mac: lockMac, lock_name: lockName } = req.body
|
const { room_id: roomId, lock_mac: lockMac, lock_name: lockName } = req.body
|
||||||
// Нормализуем MAC: убираем двоеточия, приводим к верхнему регистру
|
|
||||||
const mac = (lockMac ?? '').replace(/:/g, '').toUpperCase()
|
const mac = (lockMac ?? '').replace(/:/g, '').toUpperCase()
|
||||||
if (!/^[0-9A-F]{12}$/.test(mac)) {
|
if (!/^[0-9A-F]{12}$/.test(mac)) {
|
||||||
return reply.code(400).send({ error: 'MAC-адрес должен быть 12 HEX-символов (напр. 42A6BBF5ECE5)' })
|
return reply.code(400).send({ error: 'MAC-адрес должен быть 12 HEX-символов (напр. 42A6BBF5ECE5)' })
|
||||||
@@ -212,8 +211,8 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
await db.query(
|
await db.query(
|
||||||
`INSERT INTO room_lock_mappings (hotel_id, room_id, lock_mac, lock_name)
|
`INSERT INTO room_lock_mappings (hotel_id, room_id, lock_mac, lock_name)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
ON CONFLICT (hotel_id, room_id) DO UPDATE SET lock_mac = $3, lock_name = $4`,
|
ON CONFLICT (hotel_id, lock_mac) DO UPDATE SET room_id = $2, lock_name = $4`,
|
||||||
[hotelId, roomId, mac, lockName ?? null],
|
[hotelId, roomId ?? null, mac, lockName ?? null],
|
||||||
)
|
)
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
},
|
},
|
||||||
@@ -227,7 +226,14 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
const hotelId = await getHotelId(slug)
|
const hotelId = await getHotelId(slug)
|
||||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||||
|
|
||||||
|
// roomId может быть UUID (привязан к комнате) или MAC-адрес (без комнаты)
|
||||||
|
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(roomId)
|
||||||
|
if (isUuid) {
|
||||||
await db.query('DELETE FROM room_lock_mappings WHERE hotel_id = $1 AND room_id = $2', [hotelId, roomId])
|
await db.query('DELETE FROM room_lock_mappings WHERE hotel_id = $1 AND room_id = $2', [hotelId, roomId])
|
||||||
|
} else {
|
||||||
|
const mac = roomId.replace(/:/g, '').toUpperCase()
|
||||||
|
await db.query('DELETE FROM room_lock_mappings WHERE hotel_id = $1 AND lock_mac = $2', [hotelId, mac])
|
||||||
|
}
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -622,7 +622,7 @@ export const api = {
|
|||||||
getRoomLocks: (slug: string) =>
|
getRoomLocks: (slug: string) =>
|
||||||
req<RoomLockMapping[]>('GET', `/api/hotels/${slug}/ttlock/room-locks`),
|
req<RoomLockMapping[]>('GET', `/api/hotels/${slug}/ttlock/room-locks`),
|
||||||
|
|
||||||
mapRoom: (slug: string, data: { roomId: string; lockMac: string; lockName?: string }) =>
|
mapRoom: (slug: string, data: { roomId?: string; lockMac: string; lockName?: string }) =>
|
||||||
req<{ ok: boolean }>('POST', `/api/hotels/${slug}/ttlock/room-locks`, data),
|
req<{ ok: boolean }>('POST', `/api/hotels/${slug}/ttlock/room-locks`, data),
|
||||||
|
|
||||||
unmapRoom: (slug: string, roomId: string) =>
|
unmapRoom: (slug: string, roomId: string) =>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
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,
|
Lock, Unlock, Info, HardDriveDownload, Zap, Terminal, Upload,
|
||||||
} 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'
|
||||||
@@ -157,10 +157,79 @@ export function TTLockPage() {
|
|||||||
const [addLockName, setAddLockName] = useState('')
|
const [addLockName, setAddLockName] = useState('')
|
||||||
const [addLoading, setAddLoading] = useState(false)
|
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) => {
|
const showSuccess = (msg: string) => {
|
||||||
setSuccess(msg); setTimeout(() => setSuccess(null), 3500)
|
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 () => {
|
const load = useCallback(async () => {
|
||||||
if (!slug) return
|
if (!slug) return
|
||||||
setLoading(true)
|
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>
|
Введите без двоеточий: <code className="bg-blue-100 dark:bg-blue-900/40 px-1 rounded font-mono">42A6BBF5ECE5</code>
|
||||||
</Hint>
|
</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 && (
|
{unmappedRooms.length > 0 && (
|
||||||
<div className="p-4 bg-slate-50 dark:bg-slate-800/50 rounded-xl space-y-3">
|
<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 ? (
|
{mappings.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{mappings.map(m => (
|
{mappings.map((m, i) => (
|
||||||
<div
|
<div
|
||||||
key={m.roomId}
|
key={m.roomId ?? m.lockMac ?? i}
|
||||||
className="flex items-center justify-between p-3 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900"
|
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="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">
|
<div className={cn(
|
||||||
<Lock size={14} className="text-brand-600 dark:text-brand-400" />
|
'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>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">
|
<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>
|
||||||
<p className="text-xs text-slate-500 font-mono">
|
<p className="text-xs text-slate-500 font-mono">
|
||||||
{m.lockMac}
|
{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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleUnmap(m.roomId)}
|
onClick={() => handleUnmap(m.roomId ?? m.lockMac)}
|
||||||
className="btn-ghost p-2 text-red-500 hover:text-red-700"
|
className="btn-ghost p-2 text-red-500 hover:text-red-700"
|
||||||
title="Удалить привязку"
|
title="Удалить привязку"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user