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:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user