- 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>
467 lines
21 KiB
TypeScript
467 lines
21 KiB
TypeScript
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>
|
||
)
|
||
}
|