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>
)}
</>
)
}