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