Files
hotelsync/src/pages/ChannelsPage.tsx
HotelSync 0e0c11785f Wide BookingModal (2-col), RoomModal, RU channels, migration module, all modules active
- BookingModal: 2-column layout (size=2xl/max-w-3xl), no scroll needed; hourly mode toggle for rooms with allowHourly=true
- RoomModal: full add/edit form with hourly rate toggle and amenities checkboxes; RoomsPage wired up
- Channel manager: Яндекс Путешествия, Островок, Суточно.ру, OneTwoTrip added; split into RU/International sections
- Migration module: 3-step wizard (source select → file upload → progress/results)
- All modules set to active by default (version bump to reset localStorage)
- BookingCalendar booking blocks: guest count badge (Xг), unpaid red dot indicator

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 19:16:55 +03:00

233 lines
9.3 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 } from 'react'
import { RefreshCw, CheckCircle2, XCircle, Clock, Globe, AlertTriangle } from 'lucide-react'
import { MOCK_CHANNELS } from '../data/mockData'
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 [channels, setChannels] = useState<Channel[]>(MOCK_CHANNELS)
const triggerSync = (id: string) => {
setChannels(prev => prev.map(c =>
c.id === id ? { ...c, lastSyncStatus: 'syncing' } : c,
))
setTimeout(() => {
setChannels(prev => prev.map(c =>
c.id === id
? { ...c, lastSyncStatus: 'success', lastSyncAt: new Date().toISOString(), bookingsImported: c.bookingsImported + Math.floor(Math.random() * 3) }
: c,
))
}, 2000)
}
const toggleChannel = (id: string) => {
setChannels(prev => prev.map(c =>
c.id === id ? { ...c, isEnabled: !c.isEnabled } : 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))
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>
)
}