Add Availability Calendar, Modules system, and route refactoring

- Add AvailabilityPage with price grid (categories × dates), drag-select,
  channel prices, EditPanel, PeriodModal, and rate periods tab
- Add ModulesContext with localStorage persistence and dynamic sidebar items
- Add ModulesPage with WiFi Auth, Payments, TV Welcome, Smart Locks,
  OLAP Reports, Website Builder, Booking Widget modules
- Add ReportsPage (OLAP analytics with KPI cards, charts, tables)
- Add WebsitePage and BookingWidgetPage placeholders
- Remove hotel slug from URL routing; hotel context from JWT
- Add Доступность nav item in Sidebar under Управление
- Fix LoginPage redirects and HotelSync branding

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 14:43:18 +03:00
parent 63c9ae574f
commit b277e3e38d
14 changed files with 1869 additions and 78 deletions

View File

@@ -0,0 +1,899 @@
import { useState, useRef, useCallback, useMemo } from 'react'
import { addDays, format, parseISO, isWithinInterval, startOfDay, getDay, isSameDay } from 'date-fns'
import { ru } from 'date-fns/locale'
import {
ChevronLeft, ChevronRight, X, Check, CalendarDays,
ListFilter, Plus, Pencil, Trash2, AlertCircle, RefreshCw,
} from 'lucide-react'
import { cn } from '../lib/utils'
import {
ROOM_CATEGORIES, RATE_CHANNELS, DEMO_PERIODS,
buildInitialPriceGrid, DEFAULT_CHANNEL_MARKUP,
} from '../data/ratesData'
import type { PriceCell, RatePeriod } from '../data/ratesData'
// ─── Constants ────────────────────────────────────────────────────────────────
const CELL_W = 72
const ROW_H = 52
const LABEL_W = 172
const DAYS = 45
const DATE_FMT = 'yyyy-MM-dd'
// ─── Helpers ─────────────────────────────────────────────────────────────────
function fmtPrice(n: number) {
return '₽\u00a0' + n.toLocaleString('ru-RU')
}
function fmtDate(s: string) {
return format(parseISO(s), 'd MMM', { locale: ru })
}
function normRange(a: string, b: string): [string, string] {
return a <= b ? [a, b] : [b, a]
}
function datesInRange(start: string, end: string): string[] {
const [s, e] = normRange(start, end)
const result: string[] = []
let cur = parseISO(s)
const endD = parseISO(e)
while (cur <= endD) {
result.push(format(cur, DATE_FMT))
cur = addDays(cur, 1)
}
return result
}
// ─── Types ────────────────────────────────────────────────────────────────────
interface Selection { start: string; end: string }
// ─── Price Grid ───────────────────────────────────────────────────────────────
function PriceGrid({
dates, prices, selection, dragging,
onCellDown, onCellEnter, activeChannel,
}: {
dates: string[]
prices: Record<string, Record<string, PriceCell>>
selection: Selection | null
dragging: boolean
onCellDown: (date: string) => void
onCellEnter: (date: string) => void
activeChannel: string
}) {
const today = format(new Date(), DATE_FMT)
const isSelected = useCallback((date: string) => {
if (!selection) return false
const [s, e] = normRange(selection.start, selection.end)
return date >= s && date <= e
}, [selection])
return (
<div className="overflow-x-auto" style={{ cursor: dragging ? 'col-resize' : 'default' }}>
<div style={{ minWidth: LABEL_W + dates.length * CELL_W }}>
{/* Date header */}
<div className="flex sticky top-0 z-10 bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700">
<div style={{ width: LABEL_W, minWidth: LABEL_W }}
className="shrink-0 flex items-end px-4 pb-2 text-xs font-semibold text-slate-500 uppercase tracking-wide">
Категория
</div>
{dates.map(d => {
const dt = parseISO(d)
const dow = getDay(dt)
const isWe = dow === 0 || dow === 6
const isTd = d === today
return (
<div
key={d}
style={{ width: CELL_W, minWidth: CELL_W }}
className={cn(
'shrink-0 flex flex-col items-center justify-end pb-1.5 pt-2 text-center select-none',
isWe && 'bg-amber-50/60 dark:bg-amber-900/10',
isTd && 'bg-brand-50 dark:bg-brand-900/20',
)}
>
<span className={cn(
'text-[10px] font-medium uppercase',
isWe ? 'text-amber-600 dark:text-amber-400' : 'text-slate-400',
)}>
{format(dt, 'EEE', { locale: ru })}
</span>
<span className={cn(
'text-sm font-bold leading-tight',
isTd ? 'text-brand-600 dark:text-brand-400' : 'text-slate-700 dark:text-slate-300',
)}>
{format(dt, 'd')}
</span>
<span className="text-[10px] text-slate-400">
{format(dt, 'MMM', { locale: ru })}
</span>
</div>
)
})}
</div>
{/* Category rows */}
{ROOM_CATEGORIES.map(cat => (
<div key={cat.id} className="flex border-b border-slate-100 dark:border-slate-700/60">
{/* Label */}
<div
style={{ width: LABEL_W, minWidth: LABEL_W, height: ROW_H }}
className="shrink-0 flex items-center gap-2.5 px-4 border-r border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800"
>
<div className={cn('w-2.5 h-2.5 rounded-full shrink-0', cat.color)} />
<div className="min-w-0">
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{cat.name}</p>
<p className="text-xs text-slate-400">
база: {fmtPrice(cat.basePrice)}
</p>
</div>
</div>
{/* Cells */}
{dates.map(d => {
const cell = prices[cat.id]?.[d]
const dt = parseISO(d)
const dow = getDay(dt)
const isWe = dow === 0 || dow === 6
const isTd = d === today
const isSel = isSelected(d)
const price = activeChannel === 'direct'
? cell?.price
: cell?.channelPrices[activeChannel] ?? cell?.price
return (
<div
key={d}
style={{ width: CELL_W, minWidth: CELL_W, height: ROW_H }}
onMouseDown={() => onCellDown(d)}
onMouseEnter={() => onCellEnter(d)}
className={cn(
'shrink-0 flex flex-col items-center justify-center select-none cursor-pointer',
'border-r border-slate-100 dark:border-slate-700/40',
'transition-colors',
isWe && !isSel && 'bg-amber-50/40 dark:bg-amber-900/10',
isTd && !isSel && 'bg-brand-50/40 dark:bg-brand-900/10',
isSel
? 'bg-brand-100 dark:bg-brand-800/40 ring-inset ring-1 ring-brand-400'
: 'hover:bg-slate-50 dark:hover:bg-slate-700/30',
cell?.closed && 'opacity-40',
)}
>
{cell?.closed ? (
<X size={14} className="text-slate-400" />
) : (
<>
<span className={cn(
'text-sm font-semibold leading-tight',
isSel ? 'text-brand-700 dark:text-brand-300' : 'text-slate-800 dark:text-slate-200',
)}>
{price ? price.toLocaleString('ru-RU') : '—'}
</span>
{cell?.minNights > 1 && (
<span className="text-[10px] text-slate-400 leading-tight">
min {cell.minNights}н
</span>
)}
</>
)}
</div>
)
})}
</div>
))}
</div>
</div>
)
}
// ─── Edit Panel ───────────────────────────────────────────────────────────────
function EditPanel({
selection,
prices,
onApply,
onClose,
}: {
selection: Selection
prices: Record<string, Record<string, PriceCell>>
onApply: (updates: {
startDate: string
endDate: string
categoryPrices: Record<string, number>
extraPerson: number
minNights: number
channelMarkup: Record<string, number>
closed: boolean
}) => void
onClose: () => void
}) {
const [s, e] = normRange(selection.start, selection.end)
// Initial values from first cell of selection
const firstCell = prices[ROOM_CATEGORIES[0].id]?.[s]
const [startDate, setStartDate] = useState(s)
const [endDate, setEndDate] = useState(e)
const [extraPerson, setExtraPerson] = useState(firstCell?.extraPerson ?? 0)
const [minNights, setMinNights] = useState(firstCell?.minNights ?? 1)
const [closed, setClosed] = useState(false)
const [channelMarkup, setChannelMarkup] = useState<Record<string, number>>(
{ ...DEFAULT_CHANNEL_MARKUP },
)
const [catPrices, setCatPrices] = useState<Record<string, number>>(() => {
const r: Record<string, number> = {}
for (const cat of ROOM_CATEGORIES) {
r[cat.id] = prices[cat.id]?.[s]?.price ?? cat.basePrice
}
return r
})
const nightCount = useMemo(() => {
try {
return datesInRange(startDate, endDate).length
} catch { return 1 }
}, [startDate, endDate])
return (
<div className="w-80 shrink-0 border-l border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 flex flex-col overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-200 dark:border-slate-700">
<div>
<p className="font-semibold text-slate-900 dark:text-slate-100 text-sm">Редактировать цены</p>
<p className="text-xs text-slate-400 mt-0.5">{nightCount} {nightCount === 1 ? 'день' : 'дней'}</p>
</div>
<button onClick={onClose} className="btn-ghost p-1.5"><X size={15} /></button>
</div>
<div className="flex-1 p-4 space-y-5 overflow-y-auto">
{/* Date range */}
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
Период
</label>
<div className="flex items-center gap-2">
<input
type="date"
value={startDate}
onChange={e => setStartDate(e.target.value)}
className="input text-sm flex-1 py-1.5"
/>
<span className="text-slate-400 text-sm"></span>
<input
type="date"
value={endDate}
onChange={e => setEndDate(e.target.value)}
className="input text-sm flex-1 py-1.5"
/>
</div>
</div>
{/* Close toggle */}
<div className="flex items-center justify-between py-2 px-3 rounded-lg bg-slate-50 dark:bg-slate-700/40">
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">Закрыто для продажи</p>
<p className="text-xs text-slate-400">Все номера недоступны в этот период</p>
</div>
<button
onClick={() => setClosed(v => !v)}
className={cn(
'relative w-10 h-5 rounded-full transition-colors shrink-0',
closed ? 'bg-red-500' : 'bg-slate-200 dark:bg-slate-600',
)}
>
<span className={cn(
'absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform',
closed ? 'translate-x-5' : 'translate-x-0.5',
)} />
</button>
</div>
{/* Category prices */}
{!closed && (
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
Цена по категориям
</label>
<div className="space-y-2">
{ROOM_CATEGORIES.map(cat => (
<div key={cat.id} className="flex items-center gap-2">
<div className={cn('w-2 h-2 rounded-full shrink-0', cat.color)} />
<span className="text-xs text-slate-600 dark:text-slate-400 w-28 shrink-0 truncate">
{cat.name}
</span>
<div className="relative flex-1">
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-xs text-slate-400"></span>
<input
type="number"
value={catPrices[cat.id] ?? cat.basePrice}
onChange={ev => setCatPrices(p => ({ ...p, [cat.id]: +ev.target.value }))}
className="input text-sm py-1.5 pl-6 w-full"
min={0}
step={100}
/>
</div>
</div>
))}
</div>
</div>
)}
{/* Extra person */}
{!closed && (
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
Доп. место (цена за персону)
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm text-slate-400"></span>
<input
type="number"
value={extraPerson}
onChange={e => setExtraPerson(+e.target.value)}
className="input text-sm py-1.5 pl-7"
min={0}
step={100}
/>
</div>
</div>
)}
{/* Min nights */}
{!closed && (
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
Мин. ночей
</label>
<input
type="number"
value={minNights}
onChange={e => setMinNights(Math.max(1, +e.target.value))}
className="input text-sm py-1.5 w-24"
min={1}
max={30}
/>
</div>
)}
{/* Channel markup */}
{!closed && (
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
Наценка по каналам
</label>
<div className="space-y-2">
{RATE_CHANNELS.map(ch => {
const markup = channelMarkup[ch.id] ?? 1.0
const pct = Math.round((markup - 1) * 100)
return (
<div key={ch.id} className="flex items-center gap-2">
<span className="text-sm w-7 shrink-0">{ch.icon}</span>
<span className="text-xs text-slate-600 dark:text-slate-400 flex-1 truncate">{ch.name}</span>
<div className="flex items-center gap-1">
<input
type="number"
value={pct}
onChange={ev => setChannelMarkup(p => ({ ...p, [ch.id]: 1 + +ev.target.value / 100 }))}
className="input text-sm py-1 w-16 text-center"
step={1}
min={-50}
max={200}
/>
<span className="text-xs text-slate-400">%</span>
</div>
</div>
)
})}
</div>
<p className="text-xs text-slate-400 mt-2 flex items-start gap-1">
<AlertCircle size={11} className="mt-0.5 shrink-0" />
0% = та же цена, +15% = цена выше на 15%
</p>
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-slate-200 dark:border-slate-700 flex gap-2">
<button
onClick={() => onApply({
startDate, endDate, categoryPrices: catPrices,
extraPerson, minNights, channelMarkup, closed,
})}
className="btn-primary flex-1 justify-center gap-2 text-sm py-2"
>
<Check size={14} /> Применить
</button>
<button onClick={onClose} className="btn-secondary text-sm py-2 px-3">
Отмена
</button>
</div>
</div>
)
}
// ─── Period Modal ─────────────────────────────────────────────────────────────
function PeriodModal({
period,
onSave,
onClose,
}: {
period?: RatePeriod
onSave: (p: RatePeriod) => void
onClose: () => void
}) {
const [name, setName] = useState(period?.name ?? '')
const [startDate, setStartDate] = useState(period?.startDate ?? format(new Date(), DATE_FMT))
const [endDate, setEndDate] = useState(period?.endDate ?? format(addDays(new Date(), 7), DATE_FMT))
const [notes, setNotes] = useState(period?.notes ?? '')
const [minNights, setMinNights] = useState(period?.minNights ?? 1)
const [extraPerson, setExtraPerson] = useState(period?.extraPersonPrice ?? 0)
const [catPrices, setCatPrices] = useState<Record<string, number>>(
period?.categoryPrices ?? Object.fromEntries(ROOM_CATEGORIES.map(c => [c.id, c.basePrice])),
)
const [markup, setMarkup] = useState<Record<string, number>>(
period?.channelMarkup ?? { ...DEFAULT_CHANNEL_MARKUP },
)
const save = () => {
if (!name || !startDate || !endDate) return
onSave({
id: period?.id ?? `p-${Date.now()}`,
name, startDate, endDate, notes,
categoryPrices: catPrices,
channelMarkup: markup,
extraPersonPrice: extraPerson,
minNights,
})
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40">
<div className="bg-white dark:bg-slate-800 rounded-2xl w-full max-w-2xl shadow-xl flex flex-col max-h-[90vh]">
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-slate-700">
<h3 className="font-bold text-slate-900 dark:text-slate-100">
{period ? 'Редактировать период' : 'Новый тарифный период'}
</h3>
<button onClick={onClose} className="btn-ghost p-1.5"><X size={16} /></button>
</div>
<div className="overflow-y-auto p-6 space-y-5 flex-1">
{/* Name */}
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">
Название периода *
</label>
<input
className="input"
placeholder="Например: Новогодние праздники"
value={name}
onChange={e => setName(e.target.value)}
/>
</div>
{/* Dates */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">
Начало *
</label>
<input type="date" className="input" value={startDate}
onChange={e => setStartDate(e.target.value)} />
</div>
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">
Конец *
</label>
<input type="date" className="input" value={endDate}
onChange={e => setEndDate(e.target.value)} />
</div>
</div>
{/* Category prices */}
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
Цены по категориям
</label>
<div className="grid grid-cols-2 gap-2">
{ROOM_CATEGORIES.map(cat => (
<div key={cat.id} className="flex items-center gap-2">
<div className={cn('w-2 h-2 rounded-full shrink-0', cat.color)} />
<span className="text-xs text-slate-600 dark:text-slate-400 flex-1 truncate min-w-0">
{cat.name}
</span>
<div className="relative w-28 shrink-0">
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-xs text-slate-400"></span>
<input
type="number" min={0} step={100}
value={catPrices[cat.id] ?? cat.basePrice}
onChange={ev => setCatPrices(p => ({ ...p, [cat.id]: +ev.target.value }))}
className="input text-sm py-1.5 pl-6 w-full"
/>
</div>
</div>
))}
</div>
</div>
{/* Extra + min nights */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">
Доп. место (/чел)
</label>
<input type="number" min={0} step={100} className="input" value={extraPerson}
onChange={e => setExtraPerson(+e.target.value)} />
</div>
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">
Мин. ночей
</label>
<input type="number" min={1} max={30} className="input" value={minNights}
onChange={e => setMinNights(+e.target.value)} />
</div>
</div>
{/* Channel markup */}
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
Наценка по каналам
</label>
<div className="grid grid-cols-2 gap-2">
{RATE_CHANNELS.map(ch => {
const pct = Math.round(((markup[ch.id] ?? 1) - 1) * 100)
return (
<div key={ch.id} className="flex items-center gap-2">
<span>{ch.icon}</span>
<span className="text-xs text-slate-600 dark:text-slate-400 flex-1 truncate">{ch.name}</span>
<div className="flex items-center gap-1 shrink-0">
<input type="number" step={1} min={-50} max={200}
value={pct}
onChange={ev => setMarkup(p => ({ ...p, [ch.id]: 1 + +ev.target.value / 100 }))}
className="input text-sm py-1 w-16 text-center"
/>
<span className="text-xs text-slate-400">%</span>
</div>
</div>
)
})}
</div>
</div>
{/* Notes */}
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1.5">
Примечание
</label>
<textarea className="input resize-none" rows={2} value={notes}
onChange={e => setNotes(e.target.value)} placeholder="Необязательно" />
</div>
</div>
<div className="px-6 py-4 border-t border-slate-200 dark:border-slate-700 flex justify-end gap-2">
<button onClick={onClose} className="btn-secondary">Отмена</button>
<button onClick={save} disabled={!name}
className="btn-primary gap-2">
<Check size={15} /> Сохранить
</button>
</div>
</div>
</div>
)
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export function AvailabilityPage() {
const today = useMemo(() => new Date(), [])
const [offset, setOffset] = useState(0)
const [tab, setTab] = useState<'grid' | 'periods'>('grid')
const [activeChannel, setActiveChannel] = useState('direct')
const [prices, setPrices] = useState(() => buildInitialPriceGrid())
const [periods, setPeriods] = useState<RatePeriod[]>(DEMO_PERIODS)
const [selection, setSelection] = useState<Selection | null>(null)
const [dragging, setDragging] = useState(false)
const [dragStart, setDragStart] = useState<string | null>(null)
const [showEditPanel, setShowEditPanel] = useState(false)
const [periodModal, setPeriodModal] = useState<RatePeriod | null | 'new'>(null)
const dates = useMemo(() =>
Array.from({ length: DAYS }, (_, i) =>
format(addDays(today, offset + i), DATE_FMT),
), [today, offset])
// ── Mouse handlers ──
const handleCellDown = (date: string) => {
setDragging(true)
setDragStart(date)
setSelection({ start: date, end: date })
setShowEditPanel(false)
}
const handleCellEnter = (date: string) => {
if (!dragging || !dragStart) return
setSelection({ start: dragStart, end: date })
}
const handleMouseUp = useCallback(() => {
if (dragging && selection) {
setShowEditPanel(true)
}
setDragging(false)
setDragStart(null)
}, [dragging, selection])
// ── Apply price changes ──
const applyPrices = ({
startDate, endDate, categoryPrices, extraPerson, minNights, channelMarkup, closed,
}: {
startDate: string; endDate: string
categoryPrices: Record<string, number>
extraPerson: number; minNights: number
channelMarkup: Record<string, number>
closed: boolean
}) => {
const days = datesInRange(startDate, endDate)
setPrices(prev => {
const next = { ...prev }
for (const cat of ROOM_CATEGORIES) {
next[cat.id] = { ...prev[cat.id] }
const basePrice = categoryPrices[cat.id] ?? cat.basePrice
for (const d of days) {
const channelPrices: Record<string, number> = {}
for (const ch of RATE_CHANNELS) {
channelPrices[ch.id] = Math.round(basePrice * (channelMarkup[ch.id] ?? 1))
}
next[cat.id][d] = {
price: basePrice, extraPerson, minNights, channelPrices, closed,
}
}
}
return next
})
setShowEditPanel(false)
setSelection(null)
}
// ── Apply period ──
const applyPeriod = (p: RatePeriod) => {
// Apply prices from period
applyPrices({
startDate: p.startDate,
endDate: p.endDate,
categoryPrices: p.categoryPrices,
extraPerson: p.extraPersonPrice,
minNights: p.minNights,
channelMarkup: p.channelMarkup,
closed: false,
})
}
const savePeriod = (p: RatePeriod) => {
setPeriods(prev => {
const idx = prev.findIndex(x => x.id === p.id)
return idx >= 0 ? prev.map(x => x.id === p.id ? p : x) : [...prev, p]
})
setPeriodModal(null)
}
const deletePeriod = (id: string) => {
setPeriods(prev => prev.filter(p => p.id !== id))
}
return (
<div
className="flex flex-col h-full"
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
{/* ── Top bar ── */}
<div className="shrink-0 px-5 py-3 bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-3">
<div className="flex bg-slate-100 dark:bg-slate-700 rounded-lg p-0.5">
{([['grid', 'Сетка цен'], ['periods', 'Периоды']] as const).map(([t, label]) => (
<button
key={t}
onClick={() => setTab(t)}
className={cn(
'px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
tab === t
? 'bg-white dark:bg-slate-600 text-slate-900 dark:text-slate-100 shadow-sm'
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300',
)}
>
{label}
</button>
))}
</div>
{tab === 'grid' && (
<div className="flex items-center gap-1">
<button onClick={() => setOffset(o => o - 14)} className="btn-ghost p-1.5">
<ChevronLeft size={16} />
</button>
<button
onClick={() => setOffset(0)}
className={cn('btn-ghost text-xs px-2 py-1', offset === 0 && 'text-brand-600 font-semibold')}
>
Сегодня
</button>
<button onClick={() => setOffset(o => o + 14)} className="btn-ghost p-1.5">
<ChevronRight size={16} />
</button>
</div>
)}
</div>
<div className="flex items-center gap-2">
{/* Channel selector */}
{tab === 'grid' && (
<div className="flex items-center gap-1 bg-slate-100 dark:bg-slate-700 rounded-lg p-0.5">
{RATE_CHANNELS.map(ch => (
<button
key={ch.id}
onClick={() => setActiveChannel(ch.id)}
className={cn(
'flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium transition-colors',
activeChannel === ch.id
? 'bg-white dark:bg-slate-600 text-slate-900 dark:text-slate-100 shadow-sm'
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300',
)}
>
<span>{ch.icon}</span>
<span className="hidden sm:inline">{ch.name}</span>
</button>
))}
</div>
)}
{tab === 'periods' && (
<button
onClick={() => setPeriodModal('new')}
className="btn-primary text-sm gap-1.5"
>
<Plus size={14} /> Добавить период
</button>
)}
</div>
</div>
{/* ── Content ── */}
<div className="flex-1 overflow-hidden flex">
{/* Grid tab */}
{tab === 'grid' && (
<>
<div className="flex-1 overflow-auto">
{/* Legend */}
<div className="px-4 py-2 bg-slate-50 dark:bg-slate-700/30 border-b border-slate-200 dark:border-slate-700 flex items-center gap-4 text-xs text-slate-500 flex-wrap">
<span className="flex items-center gap-1.5">
<span className="w-3 h-3 rounded-sm bg-amber-100 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700 shrink-0" />
Выходные
</span>
<span className="flex items-center gap-1.5">
<span className="w-3 h-3 rounded-sm bg-brand-100 dark:bg-brand-900/30 border border-brand-200 dark:border-brand-700 shrink-0" />
Сегодня
</span>
<span className="flex items-center gap-1.5">
<span className="w-3 h-3 rounded-sm bg-brand-200 dark:bg-brand-800/60 border border-brand-400 shrink-0" />
Выделено
</span>
<span className="ml-auto text-slate-400 hidden md:block">
Выделите ячейки мышью для редактирования цен
</span>
</div>
<PriceGrid
dates={dates}
prices={prices}
selection={selection}
dragging={dragging}
onCellDown={handleCellDown}
onCellEnter={handleCellEnter}
activeChannel={activeChannel}
/>
</div>
{/* Edit panel */}
{showEditPanel && selection && (
<EditPanel
selection={selection}
prices={prices}
onApply={applyPrices}
onClose={() => { setShowEditPanel(false); setSelection(null) }}
/>
)}
</>
)}
{/* Periods tab */}
{tab === 'periods' && (
<div className="flex-1 overflow-auto p-5">
{periods.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-center">
<CalendarDays size={36} className="text-slate-300 dark:text-slate-600 mb-3" />
<p className="text-slate-500 dark:text-slate-400 font-medium">Нет тарифных периодов</p>
<p className="text-sm text-slate-400 mt-1">Добавьте период для управления ценами</p>
</div>
) : (
<div className="space-y-3 max-w-3xl">
{periods.map(p => (
<div key={p.id}
className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h4 className="font-semibold text-slate-900 dark:text-slate-100">{p.name}</h4>
<span className="text-xs bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400 px-2 py-0.5 rounded-full">
{fmtDate(p.startDate)} {fmtDate(p.endDate)}
</span>
{p.minNights > 1 && (
<span className="text-xs bg-brand-50 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 px-2 py-0.5 rounded-full">
мин. {p.minNights} н.
</span>
)}
</div>
{p.notes && (
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">{p.notes}</p>
)}
{/* Category prices */}
<div className="flex gap-3 flex-wrap mt-2">
{ROOM_CATEGORIES.map(cat => (
p.categoryPrices[cat.id] ? (
<span key={cat.id} className="text-xs text-slate-600 dark:text-slate-400 flex items-center gap-1">
<span className={cn('w-1.5 h-1.5 rounded-full', cat.color)} />
{cat.name}: <strong>{fmtPrice(p.categoryPrices[cat.id])}</strong>
</span>
) : null
))}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => applyPeriod(p)}
title="Применить к сетке"
className="btn-ghost p-1.5 text-brand-600"
>
<RefreshCw size={14} />
</button>
<button
onClick={() => setPeriodModal(p)}
className="btn-ghost p-1.5"
>
<Pencil size={14} />
</button>
<button
onClick={() => deletePeriod(p.id)}
className="btn-ghost p-1.5 text-red-500 hover:text-red-600"
>
<Trash2 size={14} />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* Period modal */}
{periodModal !== null && (
<PeriodModal
period={periodModal === 'new' ? undefined : periodModal}
onSave={savePeriod}
onClose={() => setPeriodModal(null)}
/>
)}
</div>
)
}