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:
21
backend/migrations/002_netup.sql
Normal file
21
backend/migrations/002_netup.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- HotelSync Migration 002 — NetUP IPTV Integration
|
||||
-- Отдельные таблицы под модуль "Приветствие на TV"
|
||||
|
||||
CREATE TABLE IF NOT EXISTS netup_settings (
|
||||
hotel_id UUID PRIMARY KEY REFERENCES hotels(id) ON DELETE CASCADE,
|
||||
server_url TEXT NOT NULL DEFAULT '',
|
||||
username TEXT NOT NULL DEFAULT 'admin',
|
||||
password TEXT NOT NULL DEFAULT '',
|
||||
default_language TEXT NOT NULL DEFAULT 'ru_RU',
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Сопоставление: номер в PMS → номер в NetUP
|
||||
CREATE TABLE IF NOT EXISTS netup_room_mapping (
|
||||
hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE,
|
||||
pms_room_id UUID NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
||||
netup_room_number TEXT NOT NULL,
|
||||
PRIMARY KEY (hotel_id, pms_room_id)
|
||||
);
|
||||
@@ -15,6 +15,7 @@ import bookingsRoutes from './routes/bookings'
|
||||
import housekeepingRoutes from './routes/housekeeping'
|
||||
import channelsRoutes from './routes/channels'
|
||||
import usersRoutes from './routes/users'
|
||||
import netupRoutes from './routes/netup'
|
||||
|
||||
export async function buildApp() {
|
||||
const fastify = Fastify({
|
||||
@@ -32,7 +33,7 @@ export async function buildApp() {
|
||||
await fastify.register(cors, {
|
||||
origin: config.cors.origins,
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
methods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
})
|
||||
|
||||
await fastify.register(rateLimit, {
|
||||
@@ -71,6 +72,7 @@ export async function buildApp() {
|
||||
await fastify.register(housekeepingRoutes)
|
||||
await fastify.register(channelsRoutes)
|
||||
await fastify.register(usersRoutes)
|
||||
await fastify.register(netupRoutes)
|
||||
|
||||
return fastify
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FastifyPluginAsync } from 'fastify'
|
||||
import { db } from '../db'
|
||||
import { notifyNetupCheckin, notifyNetupCheckout } from './netup'
|
||||
|
||||
type SlugParam = { Params: { slug: string } }
|
||||
type SlugIdParam = { Params: { slug: string; id: string } }
|
||||
@@ -167,7 +168,16 @@ const bookings: FastifyPluginAsync = async (fastify) => {
|
||||
values,
|
||||
)
|
||||
if (!rows[0]) return reply.code(404).send({ error: 'Booking not found' })
|
||||
return rows[0]
|
||||
|
||||
// NetUP IPTV: fire-and-forget при заселении/выселении
|
||||
const updated = rows[0]
|
||||
if (request.body.status === 'checked_in') {
|
||||
notifyNetupCheckin(hotelId, updated.room_id, updated.guest_name, updated.id).catch(() => {})
|
||||
} else if (request.body.status === 'checked_out') {
|
||||
notifyNetupCheckout(hotelId, updated.room_id).catch(() => {})
|
||||
}
|
||||
|
||||
return updated
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
301
backend/src/routes/netup.ts
Normal file
301
backend/src/routes/netup.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { db } from '../db'
|
||||
|
||||
type SlugParam = { Params: { slug: string } }
|
||||
type SlugIdParam = { Params: { slug: string; id: string } }
|
||||
|
||||
const netup: FastifyPluginAsync = async (fastify) => {
|
||||
const getHotelId = async (slug: string): Promise<string | null> => {
|
||||
const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug])
|
||||
return rows[0]?.id ?? null
|
||||
}
|
||||
|
||||
const canAccess = (userSlug: string | null, role: string, slug: string) =>
|
||||
role === 'super_admin' || userSlug === slug
|
||||
|
||||
// ── Вспомогательная функция: HTTP запрос к NetUP MW ──────────────────────
|
||||
async function netupReq(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<{ ok: boolean; status: number; data: unknown }> {
|
||||
const base = serverUrl.replace(/\/$/, '')
|
||||
const url = `${base}/mw/a${path}`
|
||||
const cred = Buffer.from(`${username}:${password}`).toString('base64')
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Authorization': `Basic ${cred}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: AbortSignal.timeout(8000),
|
||||
})
|
||||
|
||||
let data: unknown = null
|
||||
try { data = await res.json() } catch { /* empty response */ }
|
||||
|
||||
return { ok: res.ok, status: res.status, data }
|
||||
}
|
||||
|
||||
// ── GET /api/hotels/:slug/netup/settings ────────────────────────────────
|
||||
fastify.get<SlugParam>(
|
||||
'/api/hotels/:slug/netup/settings',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug } = request.params
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
}
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { rows } = await db.query(
|
||||
'SELECT server_url, username, password, default_language, enabled FROM netup_settings WHERE hotel_id = $1',
|
||||
[hotelId],
|
||||
)
|
||||
return rows[0] ?? { server_url: '', username: 'admin', password: '', default_language: 'ru_RU', enabled: false }
|
||||
},
|
||||
)
|
||||
|
||||
// ── PATCH /api/hotels/:slug/netup/settings ───────────────────────────────
|
||||
fastify.patch<SlugParam & { Body: { server_url?: string; username?: string; password?: string; default_language?: string; enabled?: boolean } }>(
|
||||
'/api/hotels/:slug/netup/settings',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
if (request.user.role === 'housekeeper') return reply.code(403).send({ error: 'Forbidden' })
|
||||
const { slug } = request.params
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
}
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { server_url, username, password, default_language, enabled } = request.body
|
||||
|
||||
await db.query(
|
||||
`INSERT INTO netup_settings (hotel_id, server_url, username, password, default_language, enabled, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
||||
ON CONFLICT (hotel_id) DO UPDATE SET
|
||||
server_url = EXCLUDED.server_url,
|
||||
username = EXCLUDED.username,
|
||||
password = CASE WHEN EXCLUDED.password = '' THEN netup_settings.password ELSE EXCLUDED.password END,
|
||||
default_language = EXCLUDED.default_language,
|
||||
enabled = EXCLUDED.enabled,
|
||||
updated_at = NOW()`,
|
||||
[hotelId, server_url ?? '', username ?? 'admin', password ?? '', default_language ?? 'ru_RU', enabled ?? true],
|
||||
)
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
|
||||
// ── POST /api/hotels/:slug/netup/test ────────────────────────────────────
|
||||
fastify.post<SlugParam>(
|
||||
'/api/hotels/:slug/netup/test',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug } = request.params
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
}
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { rows } = await db.query(
|
||||
'SELECT server_url, username, password FROM netup_settings WHERE hotel_id = $1',
|
||||
[hotelId],
|
||||
)
|
||||
const cfg = rows[0]
|
||||
if (!cfg?.server_url) return reply.code(400).send({ error: 'Настройки не заполнены' })
|
||||
|
||||
try {
|
||||
const result = await netupReq(cfg.server_url, cfg.username, cfg.password, 'GET', '/hotel-room')
|
||||
if (result.ok) return { ok: true, message: 'Подключение успешно' }
|
||||
return reply.code(502).send({ error: `NetUP вернул статус ${result.status}` })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Ошибка соединения'
|
||||
return reply.code(502).send({ error: msg })
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// ── GET /api/hotels/:slug/netup/rooms ────────────────────────────────────
|
||||
fastify.get<SlugParam>(
|
||||
'/api/hotels/:slug/netup/rooms',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug } = request.params
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
}
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { rows } = await db.query(
|
||||
`SELECT r.id, r.number, r.type, COALESCE(m.netup_room_number, '') as netup_room_number
|
||||
FROM rooms r
|
||||
LEFT JOIN netup_room_mapping m ON m.pms_room_id = r.id AND m.hotel_id = $1
|
||||
WHERE r.hotel_id = $1
|
||||
ORDER BY r.number`,
|
||||
[hotelId],
|
||||
)
|
||||
return rows
|
||||
},
|
||||
)
|
||||
|
||||
// ── POST /api/hotels/:slug/netup/rooms ───────────────────────────────────
|
||||
// Body: { mappings: [{ pms_room_id, netup_room_number }] }
|
||||
fastify.post<SlugParam & { Body: { mappings: { pms_room_id: string; netup_room_number: string }[] } }>(
|
||||
'/api/hotels/:slug/netup/rooms',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
if (request.user.role === 'housekeeper') return reply.code(403).send({ error: 'Forbidden' })
|
||||
const { slug } = request.params
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
}
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { mappings } = request.body
|
||||
if (!Array.isArray(mappings)) return reply.code(400).send({ error: 'mappings required' })
|
||||
|
||||
// Delete existing and insert fresh
|
||||
await db.query('DELETE FROM netup_room_mapping WHERE hotel_id = $1', [hotelId])
|
||||
|
||||
for (const m of mappings) {
|
||||
if (m.netup_room_number?.trim()) {
|
||||
await db.query(
|
||||
`INSERT INTO netup_room_mapping (hotel_id, pms_room_id, netup_room_number)
|
||||
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
|
||||
[hotelId, m.pms_room_id, m.netup_room_number.trim()],
|
||||
)
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
|
||||
// ── POST /api/hotels/:slug/netup/message ─────────────────────────────────
|
||||
// Отправить сообщение на TV в номер (из карточки брони)
|
||||
fastify.post<SlugParam & { Body: { room_id: string; message: string; guest_name?: string } }>(
|
||||
'/api/hotels/:slug/netup/message',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
if (request.user.role === 'housekeeper') return reply.code(403).send({ error: 'Forbidden' })
|
||||
const { slug } = request.params
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
}
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { room_id, message, guest_name } = request.body
|
||||
if (!room_id || !message?.trim()) return reply.code(400).send({ error: 'room_id and message required' })
|
||||
|
||||
// Get NetUP settings
|
||||
const { rows: [cfg] } = await db.query(
|
||||
'SELECT server_url, username, password FROM netup_settings WHERE hotel_id = $1 AND enabled = true',
|
||||
[hotelId],
|
||||
)
|
||||
if (!cfg?.server_url) return reply.code(400).send({ error: 'NetUP не настроен' })
|
||||
|
||||
// Get room mapping
|
||||
const { rows: [mapping] } = await db.query(
|
||||
`SELECT m.netup_room_number, r.number as room_number
|
||||
FROM netup_room_mapping m
|
||||
JOIN rooms r ON r.id = m.pms_room_id
|
||||
WHERE m.hotel_id = $1 AND m.pms_room_id = $2`,
|
||||
[hotelId, room_id],
|
||||
)
|
||||
if (!mapping) return reply.code(400).send({ error: 'Сопоставление для этого номера не найдено' })
|
||||
|
||||
try {
|
||||
const result = await netupReq(
|
||||
cfg.server_url, cfg.username, cfg.password,
|
||||
'POST', '/system-message',
|
||||
{
|
||||
name: guest_name ? `Сообщение для ${guest_name}` : `Сообщение в номер ${mapping.room_number}`,
|
||||
recipient_type: 'all',
|
||||
recipient: 0,
|
||||
message,
|
||||
},
|
||||
)
|
||||
if (!result.ok) {
|
||||
return reply.code(502).send({ error: `NetUP вернул ошибку: ${result.status}` })
|
||||
}
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Ошибка соединения'
|
||||
return reply.code(502).send({ error: msg })
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// ── Внутренний хелпер: вызывается из bookings route при смене статуса ───
|
||||
// Экспортируем для использования в bookings.ts
|
||||
}
|
||||
|
||||
// ── Отдельная функция для check-in/check-out из bookings.ts ──────────────
|
||||
export async function notifyNetupCheckin(hotelId: string, roomId: string, guestName: string, reservationId: string, language?: string) {
|
||||
const { rows: [cfg] } = await db.query(
|
||||
'SELECT server_url, username, password, default_language FROM netup_settings WHERE hotel_id = $1 AND enabled = true',
|
||||
[hotelId],
|
||||
)
|
||||
if (!cfg?.server_url) return
|
||||
|
||||
const { rows: [mapping] } = await db.query(
|
||||
'SELECT netup_room_number FROM netup_room_mapping WHERE hotel_id = $1 AND pms_room_id = $2',
|
||||
[hotelId, roomId],
|
||||
)
|
||||
if (!mapping) return
|
||||
|
||||
try {
|
||||
const base = cfg.server_url.replace(/\/$/, '')
|
||||
const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64')
|
||||
await fetch(`${base}/mw/a/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-in`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Basic ${cred}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
reservation_id: reservationId,
|
||||
name: guestName,
|
||||
language: language ?? cfg.default_language,
|
||||
}),
|
||||
signal: AbortSignal.timeout(6000),
|
||||
})
|
||||
} catch {
|
||||
// fire-and-forget: не прерываем заселение если NetUP недоступен
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyNetupCheckout(hotelId: string, roomId: string) {
|
||||
const { rows: [cfg] } = await db.query(
|
||||
'SELECT server_url, username, password FROM netup_settings WHERE hotel_id = $1 AND enabled = true',
|
||||
[hotelId],
|
||||
)
|
||||
if (!cfg?.server_url) return
|
||||
|
||||
const { rows: [mapping] } = await db.query(
|
||||
'SELECT netup_room_number FROM netup_room_mapping WHERE hotel_id = $1 AND pms_room_id = $2',
|
||||
[hotelId, roomId],
|
||||
)
|
||||
if (!mapping) return
|
||||
|
||||
try {
|
||||
const base = cfg.server_url.replace(/\/$/, '')
|
||||
const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64')
|
||||
await fetch(`${base}/mw/a/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-out`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Basic ${cred}` },
|
||||
signal: AbortSignal.timeout(6000),
|
||||
})
|
||||
} catch {
|
||||
// fire-and-forget
|
||||
}
|
||||
}
|
||||
|
||||
export default netup
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────
|
||||
|
||||
@@ -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
362
src/pages/TvWelcomePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user