Add NetUP IPTV integration module (TV Welcome settings page)

- DB: migrations/002_netup.sql — netup_settings + netup_room_mapping tables
- Backend: routes/netup.ts — GET/PATCH settings, POST test, GET/POST room
  mappings, POST send TV message; notifyNetupCheckin/Checkout helpers
- Backend: bookings PATCH — fire-and-forget NetUP check-in/out on status change
- Frontend: TvWelcomePage — connection settings tab + room mapping tab
- Frontend: sidebarItem added to tv-welcome module (shows in sidebar menu)
- Frontend: ModulesPage Settings button navigates to /tv-welcome
- Frontend: BookingModal — "Сообщение гостю на TV" panel for existing bookings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-18 18:18:41 +03:00
parent bc12f8aabc
commit 61d25628ae
10 changed files with 808 additions and 4 deletions

View File

@@ -36,6 +36,7 @@ import { DiscountsPage } from './pages/DiscountsPage'
import { GuestReviewPage } from './pages/GuestReviewPage'
import { GuestRoomServicePage } from './pages/GuestRoomServicePage'
import { ResetPasswordPage } from './pages/ResetPasswordPage'
import { TvWelcomePage } from './pages/TvWelcomePage'
export default function App() {
return (
@@ -81,6 +82,7 @@ export default function App() {
<Route path="/loyalty" element={<LoyaltyPage />} />
<Route path="/maintenance" element={<MaintenancePage />} />
<Route path="/discounts" element={<DiscountsPage />} />
<Route path="/tv-welcome" element={<TvWelcomePage />} />
</Route>
<Route path="/" element={<Navigate to="/login" replace />} />

View File

@@ -1,12 +1,15 @@
import { useState } from 'react'
import { format } from 'date-fns'
import { Star, Banknote, CreditCard, AlertCircle, Map, Plus, Trash2, IdCard, CalendarDays, Tag, ScanLine, Minus, BedDouble } from 'lucide-react'
import { Star, Banknote, CreditCard, AlertCircle, Map, Plus, Trash2, IdCard, CalendarDays, Tag, ScanLine, Minus, BedDouble, Tv2, Send, Loader2, CheckCircle2 } from 'lucide-react'
import { MOCK_DISCOUNTS } from '../../pages/DiscountsPage'
import type { Discount } from '../../pages/DiscountsPage'
import { Modal } from '../ui/Modal'
import { cn, BOOKING_STATUS_LABELS } from '../../lib/utils'
import type { Booking, DraftBooking, Room, BookingStatus } from '../../types'
import { FloorMapModal } from '../floormap/FloorMapModal'
import { useModules } from '../../contexts/ModulesContext'
import { useAuth } from '../../contexts/AuthContext'
import { api } from '../../lib/api'
type DocType = 'rf_passport' | 'foreign_passport' | 'other'
@@ -80,6 +83,33 @@ function availableRooms(rooms: Room[], bookings: Booking[], checkIn: string, che
}
export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSave, existing }: BookingModalProps) {
const { statuses } = useModules()
const { user } = useAuth()
const tvEnabled = statuses['tv-welcome'] === 'active'
// TV message state
const [tvMsg, setTvMsg] = useState('')
const [tvSending, setTvSending] = useState(false)
const [tvSent, setTvSent] = useState(false)
const [tvError, setTvError] = useState('')
const handleSendTv = async () => {
if (!tvMsg.trim() || !existing?.roomId || !user?.hotelSlug) return
setTvSending(true)
setTvError('')
setTvSent(false)
try {
await api.netup.sendMessage(user.hotelSlug, existing.roomId, tvMsg, existing.guestName)
setTvSent(true)
setTvMsg('')
setTimeout(() => setTvSent(false), 3000)
} catch (err) {
setTvError(err instanceof Error ? err.message : 'Ошибка отправки')
} finally {
setTvSending(false)
}
}
const [form, setForm] = useState({
roomId: existing?.roomId ?? draft.roomId,
guestName: existing?.guestName ?? '',
@@ -470,6 +500,46 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
</div>
</div>
{/* TV Message — только для существующих броней с активным модулем */}
{tvEnabled && existing && existing.roomId && (
<div className="rounded-xl border border-violet-200 dark:border-violet-800/60 bg-violet-50 dark:bg-violet-900/20 p-3 space-y-2">
<div className="flex items-center gap-2">
<Tv2 size={14} className="text-violet-600 dark:text-violet-400 shrink-0" />
<span className="text-sm font-medium text-violet-700 dark:text-violet-300">
Сообщение гостю на TV
</span>
</div>
<textarea
rows={2}
value={tvMsg}
onChange={e => setTvMsg(e.target.value)}
placeholder="Введите сообщение для гостя..."
className="w-full rounded-lg border border-violet-200 dark:border-violet-700 bg-white dark:bg-slate-800 px-3 py-2 text-sm text-slate-900 dark:text-slate-100 placeholder-slate-400 resize-none focus:outline-none focus:ring-2 focus:ring-violet-400"
/>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleSendTv}
disabled={tvSending || !tvMsg.trim()}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-violet-600 hover:bg-violet-700 disabled:opacity-50 text-white text-xs font-medium transition-colors"
>
{tvSending
? <Loader2 size={12} className="animate-spin" />
: <Send size={12} />}
Отправить на TV
</button>
{tvSent && (
<span className="flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400">
<CheckCircle2 size={12} /> Отправлено
</span>
)}
{tvError && (
<span className="text-xs text-red-500">{tvError}</span>
)}
</div>
</div>
)}
{/* Extra beds */}
<div className="flex items-center justify-between px-3 py-2 rounded-xl bg-slate-50 dark:bg-slate-700/40 border border-slate-200 dark:border-slate-600">
<div className="flex items-center gap-2">

View File

@@ -156,6 +156,11 @@ export const MODULES_DATA: ModuleDef[] = [
'Поддержка Samsung SSSP, LG webOS, Android TV',
'Брендирование под стиль отеля',
],
sidebarItem: {
path: '/tv-welcome',
label: 'TV Приветствие',
icon: Tv2,
},
},
{
id: 'smart-locks',

View File

@@ -265,6 +265,29 @@ export const api = {
update: (slug: string, data: HotelPayload) =>
req<Hotel>('PATCH', `/api/hotels/${slug}`, toHotelPayload(data)),
},
// ── NetUP IPTV ────────────────────────────────────────────────────────────
netup: {
getSettings: (slug: string) =>
req<{ server_url: string; username: string; password: string; default_language: string; enabled: boolean }>(
'GET', `/api/hotels/${slug}/netup/settings`),
saveSettings: (slug: string, data: { server_url: string; username: string; password?: string; default_language: string; enabled: boolean }) =>
req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/netup/settings`, data),
testConnection: (slug: string) =>
req<{ ok: boolean; message: string }>('POST', `/api/hotels/${slug}/netup/test`),
getRoomMappings: (slug: string) =>
req<{ id: string; number: string; type: string; netup_room_number: string }[]>(
'GET', `/api/hotels/${slug}/netup/rooms`),
saveRoomMappings: (slug: string, mappings: { pms_room_id: string; netup_room_number: string }[]) =>
req<{ ok: boolean }>('POST', `/api/hotels/${slug}/netup/rooms`, { mappings }),
sendMessage: (slug: string, roomId: string, message: string, guestName?: string) =>
req<{ ok: boolean }>('POST', `/api/hotels/${slug}/netup/message`, { room_id: roomId, message, guest_name: guestName }),
},
}
// ── Payload types & converters ───────────────────────────────────────────────

View File

@@ -1,4 +1,5 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
CheckCircle2, Lock, Zap, ChevronDown,
Star, ArrowUpRight, Puzzle,
@@ -21,6 +22,7 @@ const STATUS_CFG: Record<ModuleStatus, { label: string; pill: string }> = {
function ModuleCard({ mod, status }: { mod: ModuleDef; status: ModuleStatus }) {
const [open, setOpen] = useState(false)
const navigate = useNavigate()
const Icon = mod.icon
const cfg = STATUS_CFG[status]
@@ -127,7 +129,13 @@ function ModuleCard({ mod, status }: { mod: ModuleDef; status: ModuleStatus }) {
</div>
{status === 'active' && (
<button className="btn-secondary text-sm flex items-center gap-1.5 py-1.5 px-3">
<button
onClick={() => mod.sidebarItem && navigate(mod.sidebarItem.path)}
className={cn(
'btn-secondary text-sm flex items-center gap-1.5 py-1.5 px-3',
!mod.sidebarItem && 'opacity-50 cursor-default',
)}
>
Настройки <ArrowUpRight size={13} />
</button>
)}

362
src/pages/TvWelcomePage.tsx Normal file
View File

@@ -0,0 +1,362 @@
import { useState, useEffect } from 'react'
import { Tv2, Plug, Map, CheckCircle2, XCircle, Loader2, Save, Eye, EyeOff } from 'lucide-react'
import { cn } from '../lib/utils'
import { api } from '../lib/api'
import { useAuth } from '../contexts/AuthContext'
type Tab = 'connection' | 'rooms'
const LANGUAGES = [
{ value: 'ru_RU', label: 'Русский' },
{ value: 'en_US', label: 'English' },
{ value: 'de_DE', label: 'Deutsch' },
{ value: 'zh_CN', label: '中文' },
{ value: 'ar_AE', label: 'العربية' },
]
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; netup_room_number: string }[]>([])
const [roomsSaving, setRoomsSaving] = useState(false)
const [roomsSaved, setRoomsSaved] = useState(false)
// Load settings
useEffect(() => {
if (!slug) return
api.netup.getSettings(slug)
.then(s => {
setServerUrl(s.server_url)
setUsername(s.username)
setLanguage(s.default_language)
setEnabled(s.enabled)
setSettingsLoaded(true)
})
.catch(() => setSettingsLoaded(true))
}, [slug])
// Load room mappings when tab switches
useEffect(() => {
if (tab !== 'rooms' || !slug) return
api.netup.getRoomMappings(slug)
.then(setRooms)
.catch(() => {})
}, [tab, slug])
const handleSaveConnection = async () => {
setSaving(true)
setTestResult(null)
try {
await api.netup.saveSettings(slug, {
server_url: serverUrl,
username,
password: password || undefined,
default_language: language,
enabled,
})
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,
})
const res = await api.netup.testConnection(slug)
setTestResult({ ok: true, msg: res.message })
} catch (err) {
const msg = err instanceof Error ? err.message : 'Ошибка подключения'
setTestResult({ ok: false, msg })
} 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.netup_room_number,
})))
setRoomsSaved(true)
setTimeout(() => setRoomsSaved(false), 2500)
} finally {
setRoomsSaving(false)
}
}
const updateRoomNetup = (id: string, val: string) =>
setRooms(prev => prev.map(r => r.id === id ? { ...r, netup_room_number: 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 },
] 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.netup_room_number}
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>
)}
</div>
)
}