feat: TTLock integration + fix agent offline status

- Add TTLock electronic lock module: settings page, room-lock mapping,
  card issuance via agent command (ttlock:write_card)
- Fix agent offline: EquipmentPage now polls every 30s + reacts to
  workstation:online/offline WebSocket events instantly
- Backend broadcasts workstation online/offline events to hotel clients
  via existing WebSocket (agent-ws.ts)
- New DB migration 048_ttlock.sql: hotel_ttlock_config, room_lock_mappings,
  card_issuances tables
- New backend routes: GET/PATCH ttlock/config, POST ttlock/token,
  GET ttlock/locks, POST/DELETE ttlock/room-locks, POST issue-card
- "Выдать ключ (TTLock)" button in BookingDetailPanel for confirmed/checked_in
- TTLock page added to sidebar under Настройки

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-31 15:46:17 +03:00
parent 7a3772d1cf
commit be92d026e8
11 changed files with 1095 additions and 4 deletions

View File

@@ -4,7 +4,7 @@ import {
X, Mail, Calendar, Users, CreditCard, Tag, CheckCircle, XCircle,
Printer, ScanLine, Banknote, Building2, Plus, Pencil,
FileText, FileCheck, Receipt, IdCard, AlertTriangle,
Trash2, Loader2, UserCheck, Baby, Search, LogIn,
Trash2, Loader2, UserCheck, Baby, Search, LogIn, KeyRound,
} from 'lucide-react'
import {
cn, BOOKING_STATUS_BADGE, BOOKING_STATUS_LABELS,
@@ -302,6 +302,8 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
const [earlyOutMethod, setEarlyOutMethod] = useState<'cash' | 'card' | 'transfer'>('cash')
const [earlyOutSaving, setEarlyOutSaving] = useState(false)
const [cardIssuing, setCardIssuing] = useState(false)
const freeRoomsForRelocate = (rooms ?? []).filter(r =>
r.id !== booking.roomId &&
!(allBookings ?? []).some(b =>
@@ -392,6 +394,20 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
])
}
const handleIssueCard = async () => {
if (!slug || cardIssuing) return
setCardIssuing(true)
try {
const r = await api.ttlock.issueCard(slug, booking.id)
showToast(`✓ Ключ выдан (${r.roomName})`)
} catch (e) {
const msg = e instanceof Error ? e.message : 'Ошибка выдачи ключа'
showToast(`${msg}`)
} finally {
setCardIssuing(false)
}
}
// Passport data
const nameParts = booking.guestName.split(' ')
const [passport, setPassport] = useState<PassportData>({
@@ -1323,6 +1339,17 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
>
<CheckCircle size={15} /> {isEarlyArrival ? `Заселить (+ ${formatCurrency(room!.earlyCheckinFee!)})` : 'Заселить'}
</button>
{slug && (
<button
onClick={handleIssueCard}
disabled={cardIssuing}
className="w-full btn-secondary justify-center flex items-center gap-2"
title="Записать карту-ключ для гостя"
>
{cardIssuing ? <Loader2 size={15} className="animate-spin" /> : <KeyRound size={15} />}
Выдать ключ (TTLock)
</button>
)}
</>
)
})()}
@@ -1357,6 +1384,17 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
>
<CheckCircle size={15} /> {isLateCheckout ? `Выселить (+ ${formatCurrency(room!.lateCheckoutFee!)})` : 'Выселить'}
</button>
{slug && (
<button
onClick={handleIssueCard}
disabled={cardIssuing}
className="w-full btn-secondary justify-center flex items-center gap-2"
title="Выдать дубликат карты-ключа"
>
{cardIssuing ? <Loader2 size={15} className="animate-spin" /> : <KeyRound size={15} />}
Выдать ключ (TTLock)
</button>
)}
</>
)
})()}