- 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>
253 lines
10 KiB
TypeScript
253 lines
10 KiB
TypeScript
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>
|
||
)
|
||
}
|