feat: TTLock encoder per workstation (Option A)

- Migration 051: add ttlock_com_port to workstations, drop com_port/workstation_id from hotel_ttlock_config
- Backend workstations PATCH: accepts ttlock_com_port alongside name
- Backend ttlock: test/issue-card use per-workstation com_port; auto-select online encoder if workstationId not specified
- TTLockPage Step 2: per-workstation table with COM port input, Save, Test buttons per row
- BookingDetailPanel: auto-issue if 1 encoder online, picker modal if multiple
- api.ts: TTLockConfig/Update cleaned up, Workstation gets ttlockComPort, issueCard/testEncoder updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-31 23:40:29 +03:00
parent 45697b56c3
commit f837a84db5
7 changed files with 263 additions and 175 deletions

View File

@@ -303,6 +303,9 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
const [earlyOutSaving, setEarlyOutSaving] = useState(false)
const [cardIssuing, setCardIssuing] = useState(false)
const [cardEncoders, setCardEncoders] = useState<Array<{ id: string; name: string }>>([])
const [cardEncoderPickerOpen, setCardEncoderPickerOpen] = useState(false)
const [selectedEncoderId, setSelectedEncoderId] = useState('')
const freeRoomsForRelocate = (rooms ?? []).filter(r =>
r.id !== booking.roomId &&
@@ -396,9 +399,38 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
const handleIssueCard = async () => {
if (!slug || cardIssuing) return
setCardIssuing(true)
// Load available encoders
let encoders: Array<{ id: string; name: string }> = []
try {
const r = await api.ttlock.issueCard(slug, booking.id)
const wsList = await api.workstations.list(slug)
encoders = wsList
.filter(w => w.isOnline && w.ttlockComPort)
.map(w => ({ id: w.id, name: w.name }))
} catch { /* ignore */ }
if (encoders.length === 0) {
showToast('✗ Нет онлайн-рабочих мест с настроенным энкодером')
return
}
if (encoders.length === 1) {
// Auto-issue
await doIssueCard(encoders[0].id)
return
}
// Multiple encoders — show picker
setCardEncoders(encoders)
setSelectedEncoderId(encoders[0].id)
setCardEncoderPickerOpen(true)
}
const doIssueCard = async (workstationId: string) => {
setCardIssuing(true)
setCardEncoderPickerOpen(false)
try {
const r = await api.ttlock.issueCard(slug!, booking.id, workstationId)
showToast(`✓ Ключ выдан (${r.roomName})`)
} catch (e) {
const msg = e instanceof Error ? e.message : 'Ошибка выдачи ключа'
@@ -1481,6 +1513,27 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
</p>
</div>
</div>
{cardEncoderPickerOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white dark:bg-slate-800 rounded-2xl p-6 w-80 shadow-2xl space-y-4">
<h3 className="font-semibold text-slate-900 dark:text-slate-100">Выбрать энкодер</h3>
<select
className="input w-full"
value={selectedEncoderId}
onChange={e => setSelectedEncoderId(e.target.value)}
>
{cardEncoders.map(e => (
<option key={e.id} value={e.id}>{e.name}</option>
))}
</select>
<div className="flex gap-2 justify-end">
<button onClick={() => setCardEncoderPickerOpen(false)} className="btn-secondary">Отмена</button>
<button onClick={() => doIssueCard(selectedEncoderId)} className="btn-primary">Выдать ключ</button>
</div>
</div>
</div>
)}
</>
)
}

View File

@@ -345,8 +345,11 @@ export const api = {
req<Workstation[]>('GET', `/api/hotels/${slug}/workstations`),
create: (slug: string, name: string) =>
req<Workstation>('POST', `/api/hotels/${slug}/workstations`, { name }),
update: (slug: string, id: string, name: string) =>
req<Workstation>('PATCH', `/api/hotels/${slug}/workstations/${id}`, { name }),
update: (slug: string, id: string, data: { name?: string; ttlockComPort?: string | null }) =>
req<Workstation>('PATCH', `/api/hotels/${slug}/workstations/${id}`, {
name: data.name,
ttlock_com_port: data.ttlockComPort,
}),
remove: (slug: string, id: string) =>
req<void>('DELETE', `/api/hotels/${slug}/workstations/${id}`),
pairCode: (slug: string, id: string) =>
@@ -610,8 +613,8 @@ export const api = {
updateConfig: (slug: string, data: TTLockConfigUpdate) =>
req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/ttlock/config`, data),
testEncoder: (slug: string) =>
req<{ ok: boolean }>('POST', `/api/hotels/${slug}/ttlock/test`),
testEncoder: (slug: string, workstationId: string) =>
req<{ ok: boolean }>('POST', `/api/hotels/${slug}/ttlock/test`, { workstationId }),
getRoomLocks: (slug: string) =>
req<RoomLockMapping[]>('GET', `/api/hotels/${slug}/ttlock/room-locks`),
@@ -622,8 +625,8 @@ export const api = {
unmapRoom: (slug: string, roomId: string) =>
req<{ ok: boolean }>('DELETE', `/api/hotels/${slug}/ttlock/room-locks/${roomId}`),
issueCard: (slug: string, bookingId: string) =>
req<CardIssuanceResult>('POST', `/api/hotels/${slug}/bookings/${bookingId}/issue-card`),
issueCard: (slug: string, bookingId: string, workstationId?: string) =>
req<CardIssuanceResult>('POST', `/api/hotels/${slug}/bookings/${bookingId}/issue-card`, workstationId ? { workstationId } : undefined),
getCards: (slug: string, bookingId: string) =>
req<CardIssuance[]>('GET', `/api/hotels/${slug}/bookings/${bookingId}/cards`),
@@ -1076,25 +1079,22 @@ export interface Workstation {
isOnline: boolean
lastSeen: string | null
agentVersion?: string
ttlockComPort?: string | null
createdAt: string
devices: WorkstationDevice[] | null
}
export interface TTLockConfig {
isEnabled: boolean
clientId: string
cardSectors: string
comPort: string
workstationId: string | null
isEnabled: boolean
clientId: string
cardSectors: string
}
export interface TTLockConfigUpdate {
isEnabled?: boolean
clientId?: string
clientSecret?: string
cardSectors?: string
comPort?: string
workstationId?: string | null
isEnabled?: boolean
clientId?: string
clientSecret?: string
cardSectors?: string
}
export interface RoomLockMapping {

View File

@@ -647,7 +647,7 @@ function WorkstationCard({
const saveName = async () => {
if (!name.trim() || name === ws.name) { setEditing(false); return }
await api.workstations.update(slug, ws.id, name.trim())
await api.workstations.update(slug, ws.id, { name: name.trim() })
setEditing(false)
onRefresh()
}

View File

@@ -77,7 +77,6 @@ export function TTLockPage() {
const [workstations, setWorkstations] = useState<Workstation[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
@@ -86,8 +85,11 @@ export function TTLockPage() {
const [clientId, setClientId] = useState('')
const [clientSecret, setClientSecret] = useState('')
const [cardSectors, setCardSectors] = useState('1,2,3,4,5,6,7,8,9,10')
const [comPort, setComPort] = useState('')
const [workstationId, setWorkstationId] = useState('')
// Per-workstation COM port state
const [wsComPorts, setWsComPorts] = useState<Record<string, string>>({})
const [wsSaving, setWsSaving] = useState<Record<string, boolean>>({})
const [wsTesting, setWsTesting] = useState<Record<string, boolean>>({})
// Форма привязки замков
const [addRoomId, setAddRoomId] = useState('')
@@ -113,11 +115,10 @@ export function TTLockPage() {
setIsEnabled(cfg.isEnabled)
setClientId(cfg.clientId)
setCardSectors(cfg.cardSectors || '1,2,3,4,5,6,7,8,9,10')
setComPort(cfg.comPort)
setWorkstationId(cfg.workstationId ?? '')
setMappings(maps)
setRooms(roomList)
setWorkstations(wsList)
setWsComPorts(Object.fromEntries(wsList.map(w => [w.id, w.ttlockComPort ?? ''])))
} catch {
setError('Не удалось загрузить настройки')
} finally {
@@ -136,8 +137,6 @@ export function TTLockPage() {
clientId: clientId.trim(),
clientSecret: clientSecret.trim() || undefined,
cardSectors: cardSectors.trim(),
comPort: comPort.trim(),
workstationId: workstationId || null,
})
showSuccess('Настройки сохранены')
setClientSecret('')
@@ -149,19 +148,6 @@ export function TTLockPage() {
}
}
const handleTest = async () => {
setTesting(true)
setError(null)
try {
await api.ttlock.testEncoder(slug)
showSuccess('Энкодер найден и отвечает — всё готово к работе!')
} catch (e) {
setError(e instanceof Error ? e.message : 'Ошибка проверки энкодера')
} finally {
setTesting(false)
}
}
const handleAddMapping = async () => {
if (!addRoomId || !addLockMac.trim()) return
setAddLoading(true)
@@ -203,7 +189,6 @@ export function TTLockPage() {
}
const unmappedRooms = rooms.filter(r => !mappings.some(m => m.roomId === r.id))
const selectedWs = workstations.find(w => w.id === workstationId)
return (
<div className="max-w-3xl mx-auto p-6 space-y-6">
@@ -283,60 +268,6 @@ export function TTLockPage() {
</div>
</div>
<button onClick={handleSave} disabled={saving} className="btn-primary flex items-center gap-2">
{saving ? <RefreshCw size={15} className="animate-spin" /> : <Save size={15} />}
Сохранить
</button>
</div>
</Section>
{/* ── Шаг 2: Рабочее место и энкодер ── */}
<Section title="Шаг 2 — Рабочее место и карточный энкодер">
<div className="space-y-5">
<Hint>
Выберите компьютер на ресепшен, к которому подключён USB-энкодер карт.
COM-порт найдите в Диспетчере устройств Windows: <strong>Порты (COM и LPT)</strong>.
</Hint>
{/* Рабочее место */}
<div>
<label className="form-label">Рабочее место (ПК с энкодером)</label>
<select
value={workstationId}
onChange={e => setWorkstationId(e.target.value)}
className="input w-full"
>
<option value=""> Выберите рабочее место </option>
{workstations.map(w => (
<option key={w.id} value={w.id}>
{w.name} {w.isOnline ? '✓ онлайн' : '✗ офлайн'}
{w.hostname ? ` (${w.hostname})` : ''}
</option>
))}
</select>
{selectedWs && !selectedWs.isOnline && (
<p className="text-xs text-amber-600 dark:text-amber-400 mt-1 flex items-center gap-1">
<AlertCircle size={12} />
Агент на этом компьютере сейчас офлайн. Для выдачи карт агент должен быть запущен.
</p>
)}
</div>
{/* COM-порт */}
<div>
<label className="form-label">COM-порт энкодера</label>
<input
type="text"
value={comPort}
onChange={e => setComPort(e.target.value)}
placeholder="Например: COM3"
className="input w-full font-mono"
/>
<p className="text-xs text-slate-500 mt-1">
Windows: Диспетчер устройств Порты (COM и LPT) найдите «USB Serial Port»
</p>
</div>
{/* Секторы карты */}
<div>
<label className="form-label">Секторы карты</label>
@@ -353,20 +284,79 @@ export function TTLockPage() {
</p>
</div>
<div className="flex items-center gap-3">
<button onClick={handleSave} disabled={saving} className="btn-primary flex items-center gap-2">
{saving ? <RefreshCw size={15} className="animate-spin" /> : <Save size={15} />}
Сохранить
</button>
<button
onClick={handleTest}
disabled={testing || !workstationId || !comPort}
className="btn-secondary flex items-center gap-2"
title={!workstationId || !comPort ? 'Сначала заполните рабочее место и COM-порт' : ''}
>
{testing ? <RefreshCw size={15} className="animate-spin" /> : <Monitor size={15} />}
Проверить энкодер
</button>
<button onClick={handleSave} disabled={saving} className="btn-primary flex items-center gap-2">
{saving ? <RefreshCw size={15} className="animate-spin" /> : <Save size={15} />}
Сохранить
</button>
</div>
</Section>
{/* ── Шаг 2: Рабочие места и энкодеры ── */}
<Section title="Шаг 2 — Рабочие места и энкодеры">
<div className="space-y-4">
<Hint>
Для каждого рабочего места укажите COM-порт USB-энкодера карт (найти в Диспетчере устройств Порты).
Например: <strong>COM3</strong>. Агент должен быть онлайн.
</Hint>
{workstations.length === 0 && (
<p className="text-sm text-slate-500">Нет рабочих мест. Добавьте их на странице «Оборудование».</p>
)}
<div className="space-y-3">
{workstations.map(ws => (
<div key={ws.id} className="flex items-center gap-3 p-3 rounded-xl border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/40">
{/* Online dot */}
<div className={cn('w-2.5 h-2.5 rounded-full shrink-0', ws.isOnline ? 'bg-emerald-500' : 'bg-slate-300 dark:bg-slate-600')} />
{/* Name */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{ws.name}</p>
{ws.hostname && <p className="text-xs text-slate-400 truncate">{ws.hostname}</p>}
</div>
{/* COM port input */}
<input
type="text"
className="input w-28 text-sm font-mono"
placeholder="COM3"
value={wsComPorts[ws.id] ?? ''}
onChange={e => setWsComPorts(prev => ({ ...prev, [ws.id]: e.target.value }))}
/>
{/* Save */}
<button
type="button"
disabled={wsSaving[ws.id]}
onClick={async () => {
setWsSaving(prev => ({ ...prev, [ws.id]: true }))
try {
await api.workstations.update(slug, ws.id, { ttlockComPort: wsComPorts[ws.id]?.trim() || null })
showSuccess(`COM-порт для «${ws.name}» сохранён`)
await load()
} catch { setError('Ошибка сохранения') }
finally { setWsSaving(prev => ({ ...prev, [ws.id]: false })) }
}}
className="btn-secondary text-xs px-3 py-1.5 shrink-0"
>
{wsSaving[ws.id] ? <RefreshCw size={12} className="animate-spin" /> : <Save size={13} />}
</button>
{/* Test */}
<button
type="button"
disabled={!ws.isOnline || !wsComPorts[ws.id]?.trim() || wsTesting[ws.id]}
onClick={async () => {
setWsTesting(prev => ({ ...prev, [ws.id]: true }))
setError(null)
try {
await api.ttlock.testEncoder(slug, ws.id)
showSuccess(`Энкодер на «${ws.name}» отвечает — всё готово!`)
} catch (e) {
setError(e instanceof Error ? e.message : 'Ошибка проверки')
} finally { setWsTesting(prev => ({ ...prev, [ws.id]: false })) }
}}
className="btn-secondary text-xs px-3 py-1.5 shrink-0"
title={!ws.isOnline ? 'Агент офлайн' : !wsComPorts[ws.id]?.trim() ? 'Укажите COM-порт' : 'Проверить связь с энкодером'}
>
{wsTesting[ws.id] ? <RefreshCw size={12} className="animate-spin" /> : <Check size={13} />}
</button>
</div>
))}
</div>
</div>
</Section>