Files
hotelsync/src/pages/ChannelsPage.tsx
HotelSync 55a34dc0dc Connect ChannelsPage, FloorMapPage, SettingsPage, UsersPage to real API
- ChannelsPage: load from API, toggle/sync call real endpoints; normalize
  enabled→isEnabled, lastSyncedAt→lastSyncAt, add displayName/mappings defaults
- FloorMapPage: load rooms+bookings from API; create booking via API
- SettingsPage: load hotel via GET /api/hotels/:slug; save general section
  via PATCH (name, address, timezone, currency, check_in/out times)
- UsersPage: load users from API; create/update/delete via API;
  map backend User (name/role) → StaffUser (firstName/lastName/StaffRole)
- api.ts: add hotels.get/update, users.delete, channel normalization,
  HotelPayload/toHotelPayload
- types/index.ts: extend Hotel with phone/checkInTime/checkOutTime/optional fields;
  add User.createdAt/updatedAt
- backend/routes/hotels.ts: extend PATCH to allow address/phone/check_in_time/check_out_time
- backend/migrations/004_hotels_contacts.sql: add address/phone/check_in/out_time to hotels

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 14:21:45 +03:00

253 lines
10 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 } from 'react'
import { RefreshCw, CheckCircle2, XCircle, Clock, Globe, AlertTriangle } from 'lucide-react'
import { api } from '../lib/api'
import { useAuth } from '../contexts/AuthContext'
import type { Channel, SyncStatus } from '../types'
import { cn } from '../lib/utils'
import { Badge } from '../components/ui/Badge'
const CHANNEL_ICONS: Record<string, string> = {
booking_com: '🔵',
airbnb: '🔴',
expedia: '🟡',
vrbo: '🟢',
yandex_travel: '🔴',
ostrovok: '🟣',
sutochno: '🟠',
onetwotrip: '🔵',
}
const RUSSIAN_CHANNELS = new Set(['yandex_travel', 'ostrovok', 'sutochno', 'onetwotrip'])
function SyncStatusBadge({ status }: { status: SyncStatus }) {
const map: Record<SyncStatus, { icon: React.ElementType; label: string; cls: string }> = {
idle: { icon: Clock, label: 'Не настроен', cls: 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300' },
syncing: { icon: RefreshCw, label: 'Синхронизация', cls: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' },
success: { icon: CheckCircle2, label: 'Синхронизирован', cls: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300' },
error: { icon: XCircle, label: 'Ошибка', cls: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300' },
}
const { icon: Icon, label, cls } = map[status]
return (
<Badge className={cn(cls, 'gap-1')}>
<Icon size={10} className={status === 'syncing' ? 'animate-spin' : ''} />
{label}
</Badge>
)
}
export function ChannelsPage() {
const { user } = useAuth()
const slug = user?.hotelSlug ?? ''
const [channels, setChannels] = useState<Channel[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!slug) return
api.channels.list(slug)
.then(setChannels)
.catch(console.error)
.finally(() => setLoading(false))
}, [slug])
const triggerSync = async (id: string) => {
setChannels(prev => prev.map(c => c.id === id ? { ...c, lastSyncStatus: 'syncing' } : c))
try {
const { channel } = await api.channels.sync(slug, id)
setChannels(prev => prev.map(c => c.id === id ? { ...c, ...channel, lastSyncStatus: 'success' } : c))
} catch {
setChannels(prev => prev.map(c => c.id === id ? { ...c, lastSyncStatus: 'error' } : c))
}
}
const toggleChannel = async (id: string) => {
const ch = channels.find(c => c.id === id)
if (!ch) return
const next = !ch.isEnabled
setChannels(prev => prev.map(c => c.id === id ? { ...c, isEnabled: next } : c))
try {
const updated = await api.channels.update(slug, id, { enabled: next })
setChannels(prev => prev.map(c => c.id === id ? updated : c))
} catch {
setChannels(prev => prev.map(c => c.id === id ? { ...c, isEnabled: !next } : c))
}
}
const totalImported = channels.reduce((s, c) => s + c.bookingsImported, 0)
const activeCount = channels.filter(c => c.isEnabled).length
const internationalChannels = channels.filter(c => !RUSSIAN_CHANNELS.has(c.name))
const russianChannels = channels.filter(c => RUSSIAN_CHANNELS.has(c.name))
if (loading) return (
<div className="p-6 flex items-center justify-center text-slate-400 text-sm">Загрузка...</div>
)
return (
<div className="p-4 md:p-6 space-y-5">
{/* Header */}
<div>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">Менеджер каналов</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
Синхронизация с OTA площадками
</p>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[
{ label: 'Активных каналов', value: activeCount, icon: Globe },
{ label: 'Всего каналов', value: channels.length, icon: Globe },
{ label: 'Импортировано броней', value: totalImported, icon: CheckCircle2 },
{ label: 'Последняя синхронизация', value: 'Сегодня', icon: RefreshCw },
].map(s => (
<div key={s.label} className="card p-4">
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{s.value}</p>
<p className="text-sm text-slate-500 dark:text-slate-400">{s.label}</p>
</div>
))}
</div>
{/* International Channels */}
<div>
<h2 className="text-base font-semibold text-slate-700 dark:text-slate-300 mb-3">
🌍 Международные площадки
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{internationalChannels.map(channel => (
<ChannelCard
key={channel.id}
channel={channel}
onSync={() => triggerSync(channel.id)}
onToggle={() => toggleChannel(channel.id)}
/>
))}
</div>
</div>
{/* Russian Channels */}
<div>
<h2 className="text-base font-semibold text-slate-700 dark:text-slate-300 mb-3">
🇷🇺 Российские площадки
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{russianChannels.map(channel => (
<ChannelCard
key={channel.id}
channel={channel}
onSync={() => triggerSync(channel.id)}
onToggle={() => toggleChannel(channel.id)}
/>
))}
</div>
</div>
{/* Info */}
<div className="card p-4 border-amber-200 dark:border-amber-800/50 bg-amber-50 dark:bg-amber-900/10">
<div className="flex gap-3">
<AlertTriangle size={18} className="text-amber-600 dark:text-amber-400 shrink-0 mt-0.5" />
<div>
<p className="font-medium text-amber-800 dark:text-amber-300">Демо-режим</p>
<p className="text-sm text-amber-700 dark:text-amber-400 mt-0.5">
В демо-режиме синхронизация имитируется. В продакшн-версии используется webhook-интеграция
с реальными API каналов через менеджер каналов (Staah, SiteMinder, HotelRunner и др.)
</p>
</div>
</div>
</div>
</div>
)
}
function ChannelCard({ channel, onSync, onToggle }: {
channel: Channel
onSync: () => void
onToggle: () => void
}) {
const isSyncing = channel.lastSyncStatus === 'syncing'
return (
<div className={cn('card p-5 transition-all', !channel.isEnabled && 'opacity-60')}>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-11 h-11 rounded-xl bg-slate-100 dark:bg-slate-700 flex items-center justify-center text-2xl">
{CHANNEL_ICONS[channel.name]}
</div>
<div>
<p className="font-semibold text-slate-900 dark:text-slate-100">{channel.displayName}</p>
<SyncStatusBadge status={channel.lastSyncStatus} />
</div>
</div>
{/* Toggle */}
<button
onClick={onToggle}
className={cn(
'relative w-11 h-6 rounded-full transition-colors',
channel.isEnabled ? 'bg-brand-600' : 'bg-slate-300 dark:bg-slate-600',
)}
>
<div className={cn(
'absolute top-0.5 w-5 h-5 rounded-full bg-white shadow-sm transition-transform',
channel.isEnabled ? 'left-[22px]' : 'left-0.5',
)} />
</button>
</div>
{channel.isEnabled && (
<div className="space-y-2.5">
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-xs text-slate-500 dark:text-slate-400">Импортировано броней</p>
<p className="font-semibold text-slate-900 dark:text-slate-100">{channel.bookingsImported}</p>
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400">Последняя синхронизация</p>
<p className="font-semibold text-slate-900 dark:text-slate-100">
{channel.lastSyncAt
? new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(new Date(channel.lastSyncAt))
: '—'
}
</p>
</div>
</div>
{channel.mappings.length > 0 && (
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1.5">Маппинг номеров ({channel.mappings.length})</p>
<div className="space-y-1">
{channel.mappings.slice(0, 2).map(m => (
<div key={m.localRoomId} className="flex items-center justify-between text-xs">
<span className="text-slate-600 dark:text-slate-400">{
m.localRoomId.replace('r', '')
}</span>
<span className="text-slate-400"></span>
<span className="text-slate-600 dark:text-slate-400 truncate max-w-32">{m.channelRoomName}</span>
</div>
))}
{channel.mappings.length > 2 && (
<p className="text-xs text-slate-400">+{channel.mappings.length - 2} ещё</p>
)}
</div>
</div>
)}
<button
onClick={onSync}
disabled={isSyncing}
className="w-full btn-secondary text-xs py-2 justify-center gap-2"
>
<RefreshCw size={13} className={isSyncing ? 'animate-spin' : ''} />
{isSyncing ? 'Синхронизация...' : 'Синхронизировать сейчас'}
</button>
</div>
)}
{!channel.isEnabled && (
<p className="text-sm text-slate-500 dark:text-slate-400 text-center py-2">
Канал отключён. Включите для настройки интеграции.
</p>
)}
</div>
)
}