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:
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useState, useEffect } from 'react'
|
||||
import {
|
||||
CalendarDays, BookOpen, BedDouble, Globe, Settings,
|
||||
FileText, LayoutDashboard, Building2, Users, X, Hotel, Puzzle, CalendarRange, Map, LayoutGrid, UserCog, UsersRound,
|
||||
TrendingUp, Tag, Award, Wrench, Utensils, CalendarClock, Zap, ChevronRight, Star, CreditCard, Monitor, Wifi,
|
||||
TrendingUp, Tag, Award, Wrench, Utensils, CalendarClock, Zap, ChevronRight, Star, CreditCard, Monitor, Wifi, KeyRound,
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
import { useModules } from '../../contexts/ModulesContext'
|
||||
@@ -185,7 +185,7 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
||||
prices: ['/tariffs', '/dynamic-pricing', '/discounts', '/rental'],
|
||||
service: ['/housekeeping', '/technical', ...activeModuleItems.map(m => m.sidebarItem!.path)],
|
||||
management: ['/users', '/schedule', '/loyalty', '/maintenance', '/floor-map', '/channels'],
|
||||
settingsGroup:['/modules', '/settings', '/billing', '/equipment', '/wifi'],
|
||||
settingsGroup:['/modules', '/settings', '/billing', '/equipment', '/wifi', '/ttlock'],
|
||||
devGroup: ['/api-docs'],
|
||||
}
|
||||
|
||||
@@ -234,6 +234,7 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
||||
{ to: '/settings', icon: Settings, label: 'Настройки' },
|
||||
{ to: '/equipment', icon: Monitor, label: 'Оборудование' },
|
||||
{ to: '/wifi', icon: Wifi, label: 'WiFi авторизация' },
|
||||
{ to: '/ttlock', icon: KeyRound, label: 'Электронные замки' },
|
||||
// dev
|
||||
{ to: '/api-docs', icon: FileText, label: 'API & Документация' },
|
||||
]
|
||||
@@ -417,6 +418,7 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
||||
<NavItem to="/billing" icon={CreditCard} label="Тарифы и оплата" {...navItemProps} />
|
||||
<NavItem to="/equipment" icon={Monitor} label="Оборудование" {...navItemProps} />
|
||||
<NavItem to="/wifi" icon={Wifi} label="WiFi авторизация" {...navItemProps} />
|
||||
<NavItem to="/ttlock" icon={KeyRound} label="Эл. замки TTLock" {...navItemProps} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user