Initial commit: HotelSync PMS v0.1.0

- React 18 + TypeScript + Vite + Tailwind CSS
- Шахматка бронирований (drag-to-book)
- Страницы: Calendar, Bookings, Rooms, Housekeeping, Channels, API Docs, Settings
- Роли: super_admin, hotel_manager, housekeeper
- Светлая/тёмная тема
- Docker + Nginx конфигурация
- Лендинг hotelsync.ru
This commit is contained in:
2026-03-10 20:38:32 +03:00
commit 420d55d57e
45 changed files with 7274 additions and 0 deletions

202
src/pages/ChannelsPage.tsx Normal file
View File

@@ -0,0 +1,202 @@
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: '🟢',
}
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
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>
{/* Channels */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{channels.map(channel => (
<ChannelCard
key={channel.id}
channel={channel}
onSync={() => triggerSync(channel.id)}
onToggle={() => toggleChannel(channel.id)}
/>
))}
</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">{
// get room number from localRoomId
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>
)
}