Files
hotelsync/src/pages/TvWelcomePage.tsx
HotelSync ed397dbcd9 Remove pull integration; persist push log to DB (last 50 per hotel)
- Remove TravelLine/netup-pms pull route (travelline.ts unused)
- Remove pull integration section from TvWelcomePage (token field, API URL, etc.)
- Migration 008: netup_push_log table (hotel_id, action, status, request/response body)
- recordPush() now writes to DB in addition to in-memory buffer
- getLog reads from DB so logs survive server restarts
- clearLog deletes from DB
- Limit enforced: 50 rows per hotel (oldest auto-deleted)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 12:55:39 +03:00

467 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback } from 'react'
import { Tv2, Plug, Map, Activity, CheckCircle2, XCircle, Loader2, Save, Eye, EyeOff, RefreshCw, Trash2 } from 'lucide-react'
import { cn } from '../lib/utils'
import { api } from '../lib/api'
import { useAuth } from '../contexts/AuthContext'
type Tab = 'connection' | 'rooms' | 'log'
const LANGUAGES = [
{ value: 'ru_RU', label: 'Русский' },
{ value: 'en_US', label: 'English' },
{ value: 'de_DE', label: 'Deutsch' },
{ value: 'zh_CN', label: '中文' },
{ value: 'ar_AE', label: 'العربية' },
]
type PushEvent = {
ts: string
action: string
roomNumber: string
netupRoom: string
url: string
requestBody?: unknown
status: 'ok' | 'error' | 'skipped'
httpStatus?: number
responseBody?: string
error?: string
}
export function TvWelcomePage() {
const { user } = useAuth()
const slug = user?.hotelSlug ?? ''
const [tab, setTab] = useState<Tab>('connection')
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<{ ok: boolean; msg: string } | null>(null)
const [showPass, setShowPass] = useState(false)
// Connection settings
const [serverUrl, setServerUrl] = useState('')
const [username, setUsername] = useState('admin')
const [password, setPassword] = useState('')
const [language, setLanguage] = useState('ru_RU')
const [enabled, setEnabled] = useState(true)
const [settingsLoaded, setSettingsLoaded] = useState(false)
// Room mappings
const [rooms, setRooms] = useState<{ id: string; number: string; type: string; netupRoomNumber: string }[]>([])
const [roomsSaving, setRoomsSaving] = useState(false)
const [roomsSaved, setRoomsSaved] = useState(false)
// Diagnostics
const [pushEvents, setPushEvents] = useState<PushEvent[]>([])
const [logLoading, setLogLoading] = useState(false)
const [selectedPush, setSelectedPush] = useState<PushEvent | null>(null)
const [testingCheckin, setTestingCheckin] = useState(false)
const [testCheckinResult, setTestCheckinResult] = useState<{ ok: boolean; msg: string } | null>(null)
// Load settings
useEffect(() => {
if (!slug) return
api.netup.getSettings(slug)
.then(s => {
setServerUrl(s.serverUrl)
setUsername(s.username)
setLanguage(s.defaultLanguage)
setEnabled(s.enabled)
setSettingsLoaded(true)
})
.catch(() => setSettingsLoaded(true))
}, [slug])
// Load room mappings when tab switches (also needed on 'log' for test button)
useEffect(() => {
if ((tab !== 'rooms' && tab !== 'log') || !slug) return
if (rooms.length > 0) return
api.netup.getRoomMappings(slug).then(setRooms).catch(() => {})
}, [tab, slug, rooms.length])
// Load push log when diagnostics tab opens
const loadLog = useCallback(() => {
if (!slug) return
setLogLoading(true)
api.netup.getLog(slug)
.then(r => setPushEvents(r.pushEvents))
.catch(() => {})
.finally(() => setLogLoading(false))
}, [slug])
useEffect(() => {
if (tab !== 'log') return
loadLog()
}, [tab, loadLog])
const handleSaveConnection = async () => {
setSaving(true)
setTestResult(null)
try {
await api.netup.saveSettings(slug, {
server_url: serverUrl, username,
password: password || undefined,
default_language: language, enabled,
tl_token: '',
})
setPassword('')
} finally {
setSaving(false)
}
}
const handleTest = async () => {
setTesting(true)
setTestResult(null)
try {
await api.netup.saveSettings(slug, {
server_url: serverUrl, username,
password: password || undefined,
default_language: language, enabled,
tl_token: '',
})
const res = await api.netup.testConnection(slug)
setTestResult({ ok: true, msg: res.message })
} catch (err) {
setTestResult({ ok: false, msg: err instanceof Error ? err.message : 'Ошибка подключения' })
} finally {
setTesting(false)
}
}
const handleSaveRooms = async () => {
setRoomsSaving(true)
setRoomsSaved(false)
try {
await api.netup.saveRoomMappings(slug, rooms.map(r => ({
pms_room_id: r.id, netup_room_number: r.netupRoomNumber,
})))
setRoomsSaved(true)
setTimeout(() => setRoomsSaved(false), 2500)
} finally {
setRoomsSaving(false)
}
}
const handleClearLog = async () => {
await api.netup.clearLog(slug).catch(() => {})
setPushEvents([])
setSelectedPush(null)
}
const handleTestCheckin = async () => {
if (!rooms.length) return
const room = rooms.find(r => r.netupRoomNumber) ?? rooms[0]
setTestingCheckin(true)
setTestCheckinResult(null)
try {
const res = await api.netup.testCheckin(slug, room.id)
setTestCheckinResult({ ok: true, msg: res.message })
} catch (err) {
setTestCheckinResult({ ok: false, msg: err instanceof Error ? err.message : 'Ошибка' })
} finally {
setTestingCheckin(false)
setTimeout(loadLog, 500)
}
}
const updateRoomNetup = (id: string, val: string) =>
setRooms(prev => prev.map(r => r.id === id ? { ...r, netupRoomNumber: val } : r))
return (
<div className="p-6 max-w-3xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-violet-100 dark:bg-violet-900/40 flex items-center justify-center shrink-0">
<Tv2 size={20} className="text-violet-600 dark:text-violet-400" />
</div>
<div>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">Приветствие на TV</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">Интеграция с NetUP IPTV Middleware</p>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 p-1 bg-slate-100 dark:bg-slate-800 rounded-xl w-fit">
{([
{ id: 'connection', label: 'Подключение', icon: Plug },
{ id: 'rooms', label: 'Сопоставление номеров', icon: Map },
{ id: 'log', label: 'Диагностика', icon: Activity },
] as const).map(t => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={cn(
'flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all',
tab === t.id
? 'bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 shadow-sm'
: 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300',
)}
>
<t.icon size={14} />
{t.label}
</button>
))}
</div>
{/* ── Tab: Connection ──────────────────────────────────────────────────── */}
{tab === 'connection' && (
<div className="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 p-6 space-y-5">
{/* Enabled toggle */}
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-slate-900 dark:text-slate-100">Интеграция включена</p>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
При заселении/выезде автоматически уведомлять NetUP
</p>
</div>
<button
onClick={() => setEnabled(v => !v)}
className={cn('relative w-12 h-6 rounded-full transition-colors', enabled ? 'bg-violet-500' : 'bg-slate-300 dark:bg-slate-600')}
>
<span className={cn('absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform', enabled ? 'translate-x-6' : 'translate-x-0.5')} />
</button>
</div>
<hr className="border-slate-100 dark:border-slate-700" />
{/* Server URL */}
<div className="space-y-1.5">
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">Адрес сервера NetUP</label>
<input
type="text" value={serverUrl} onChange={e => setServerUrl(e.target.value)}
placeholder="http://192.168.1.100:8880"
className="input w-full font-mono text-sm"
/>
<p className="text-xs text-slate-400 dark:text-slate-500">
Внешний адрес и порт, на который пробрасывается 172.16.0.12:80
</p>
</div>
{/* Credentials */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">Логин</label>
<input type="text" value={username} onChange={e => setUsername(e.target.value)} className="input w-full" />
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">Пароль</label>
<div className="relative">
<input
type={showPass ? 'text' : 'password'} value={password}
onChange={e => setPassword(e.target.value)}
placeholder="Не изменять" className="input w-full pr-9"
/>
<button
type="button" onClick={() => setShowPass(v => !v)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
{showPass ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
</div>
<p className="text-xs text-slate-400 dark:text-slate-500">Оставьте пустым, чтобы не менять</p>
</div>
</div>
{/* Default language */}
<div className="space-y-1.5">
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">Язык по умолчанию</label>
<select value={language} onChange={e => setLanguage(e.target.value)} className="input w-full">
{LANGUAGES.map(l => <option key={l.value} value={l.value}>{l.label}</option>)}
</select>
<p className="text-xs text-slate-400 dark:text-slate-500">
Язык меню на TV, если для гостя не указан язык
</p>
</div>
{/* Test result */}
{testResult && (
<div className={cn(
'flex items-center gap-2 px-4 py-3 rounded-lg text-sm',
testResult.ok
? 'bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-400 border border-emerald-200 dark:border-emerald-800'
: 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 border border-red-200 dark:border-red-800',
)}>
{testResult.ok ? <CheckCircle2 size={15} className="shrink-0" /> : <XCircle size={15} className="shrink-0" />}
{testResult.msg}
</div>
)}
{/* Actions */}
<div className="flex gap-3 pt-1">
<button onClick={handleTest} disabled={testing || !serverUrl} className="btn-secondary flex items-center gap-2">
{testing ? <Loader2 size={14} className="animate-spin" /> : <Plug size={14} />}
Проверить подключение
</button>
<button onClick={handleSaveConnection} disabled={saving || !settingsLoaded} className="btn-primary flex items-center gap-2">
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
Сохранить
</button>
</div>
</div>
)}
{/* ── Tab: Room Mapping ────────────────────────────────────────────────── */}
{tab === 'rooms' && (
<div className="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 overflow-hidden">
<div className="px-5 py-4 border-b border-slate-100 dark:border-slate-700">
<p className="font-medium text-slate-900 dark:text-slate-100">Сопоставление номеров</p>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
Укажите номер в NetUP, соответствующий каждому номеру в PMS
</p>
</div>
{rooms.length === 0 ? (
<div className="px-5 py-8 text-center text-sm text-slate-400 dark:text-slate-500">
Нет номеров. Сначала добавьте номера в разделе «Номера».
</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100 dark:border-slate-700">
<th className="px-5 py-3 text-left font-medium text-slate-500 dark:text-slate-400">Номер в PMS</th>
<th className="px-5 py-3 text-left font-medium text-slate-500 dark:text-slate-400">Тип</th>
<th className="px-5 py-3 text-left font-medium text-slate-500 dark:text-slate-400">Номер в NetUP</th>
</tr>
</thead>
<tbody>
{rooms.map((room, i) => (
<tr key={room.id} className={cn('border-b border-slate-50 dark:border-slate-700/50 last:border-0', i % 2 !== 0 && 'bg-slate-50/50 dark:bg-slate-700/20')}>
<td className="px-5 py-3 font-medium text-slate-900 dark:text-slate-100">{room.number}</td>
<td className="px-5 py-3 text-slate-500 dark:text-slate-400">{room.type}</td>
<td className="px-5 py-3">
<input
type="text" value={room.netupRoomNumber}
onChange={e => updateRoomNetup(room.id, e.target.value)}
placeholder="Не задан" className="input py-1.5 w-36 text-sm"
/>
</td>
</tr>
))}
</tbody>
</table>
)}
<div className="px-5 py-4 border-t border-slate-100 dark:border-slate-700 flex items-center gap-3">
<button onClick={handleSaveRooms} disabled={roomsSaving || rooms.length === 0} className="btn-primary flex items-center gap-2">
{roomsSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
Сохранить сопоставление
</button>
{roomsSaved && (
<span className="flex items-center gap-1.5 text-sm text-emerald-600 dark:text-emerald-400">
<CheckCircle2 size={14} /> Сохранено
</span>
)}
</div>
</div>
)}
{/* ── Tab: Diagnostics ─────────────────────────────────────────────────── */}
{tab === 'log' && (
<div className="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 p-5">
<div className="flex items-center justify-between mb-4">
<div>
<p className="font-medium text-slate-900 dark:text-slate-100">Вызовы в NetUP</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
Заселения и выезды, отправленные в NetUP. Нажмите на строку для деталей.
</p>
</div>
<div className="flex gap-2">
<button onClick={loadLog} disabled={logLoading} className="btn-secondary flex items-center gap-1.5 text-sm">
{logLoading ? <Loader2 size={13} className="animate-spin" /> : <RefreshCw size={13} />}
Обновить
</button>
{pushEvents.length > 0 && (
<button onClick={handleClearLog} className="btn-secondary flex items-center gap-1.5 text-sm text-red-500 dark:text-red-400">
<Trash2 size={13} />
Очистить
</button>
)}
</div>
</div>
{/* Test check-in */}
<div className="mb-4 pb-4 border-b border-slate-100 dark:border-slate-700 flex items-center gap-3 flex-wrap">
<button
onClick={handleTestCheckin}
disabled={testingCheckin || rooms.length === 0}
className="btn-secondary flex items-center gap-2 text-sm"
>
{testingCheckin ? <Loader2 size={13} className="animate-spin" /> : <Plug size={13} />}
Тест: отправить заселение в NetUP
</button>
{rooms.length === 0 && <p className="text-xs text-slate-400">Сначала настройте сопоставление номеров</p>}
{testCheckinResult && (
<span className={cn('flex items-center gap-1.5 text-sm', testCheckinResult.ok ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400')}>
{testCheckinResult.ok ? <CheckCircle2 size={13} /> : <XCircle size={13} />}
{testCheckinResult.msg}
</span>
)}
</div>
{/* Events list */}
{pushEvents.length === 0 ? (
<div className="py-8 text-center">
<Activity size={32} className="mx-auto mb-3 text-slate-300 dark:text-slate-600" />
<p className="text-sm text-slate-500 dark:text-slate-400">Вызовов ещё не было</p>
<p className="text-xs text-slate-400 dark:text-slate-500 mt-1">
Измените статус брони на «Заселён» или нажмите «Тест» выше
</p>
</div>
) : (
<div className="divide-y divide-slate-100 dark:divide-slate-700/50">
{pushEvents.map((e, i) => (
<div key={i}>
<button
onClick={() => setSelectedPush(selectedPush === e ? null : e)}
className={cn(
'w-full flex items-center gap-3 py-2.5 px-1 text-sm rounded-lg transition-colors',
selectedPush === e ? 'bg-slate-50 dark:bg-slate-700/40' : 'hover:bg-slate-50/60 dark:hover:bg-slate-700/20',
)}
>
<span className={cn('w-2 h-2 rounded-full shrink-0',
e.status === 'ok' ? 'bg-emerald-500' : e.status === 'skipped' ? 'bg-amber-400' : 'bg-red-500'
)} />
<span className="font-medium text-slate-700 dark:text-slate-200 w-20 shrink-0">
{e.action === 'check-in' ? 'Заселение' : 'Выезд'}
</span>
<span className="text-slate-500 dark:text-slate-400">
Номер <span className="font-medium text-slate-700 dark:text-slate-200">{e.roomNumber}</span>
{e.netupRoom && <> NetUP <span className="font-mono text-xs">{e.netupRoom}</span></>}
</span>
{e.error && <span className="text-red-500 dark:text-red-400 text-xs flex-1 truncate text-left">{e.error}</span>}
{e.httpStatus && e.status === 'ok' && <span className="text-xs text-emerald-600 dark:text-emerald-400">HTTP {e.httpStatus}</span>}
<span className="ml-auto text-xs text-slate-400 shrink-0">{new Date(e.ts).toLocaleTimeString('ru-RU')}</span>
</button>
{selectedPush === e && (
<div className="mx-1 mb-2 bg-slate-900 dark:bg-slate-950 rounded-xl p-4 space-y-3 text-xs font-mono">
<div>
<p className="text-slate-400 font-sans font-semibold mb-1">URL запроса</p>
<p className="text-violet-400 break-all">{e.url}</p>
</div>
{e.requestBody != null && (
<div>
<p className="text-slate-400 font-sans font-semibold mb-1">Тело запроса</p>
<pre className="text-emerald-300 whitespace-pre-wrap break-all">{JSON.stringify(e.requestBody, null, 2)}</pre>
</div>
)}
{e.responseBody && (
<div>
<p className="text-slate-400 font-sans font-semibold mb-1">Ответ NetUP</p>
<pre className="text-red-300 whitespace-pre-wrap break-all">{e.responseBody}</pre>
</div>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
)
}