import { useState, useRef, useCallback, useMemo, useEffect } from 'react' import { addDays, format, parseISO, getDay, startOfDay } from 'date-fns' import { ru } from 'date-fns/locale' import { ChevronLeft, ChevronRight, X, Check, CalendarDays, Plus, Pencil, Trash2, AlertCircle, Loader2, MousePointer2, Rows3, ChevronDown, RefreshCw, } from 'lucide-react' import { cn } from '../lib/utils' import { RATE_CHANNELS, DEFAULT_CHANNEL_MARKUP } from '../data/ratesData' import type { PriceCell, RatePeriod } from '../data/ratesData' import { useAuth } from '../contexts/AuthContext' import { api } from '../lib/api' interface AvailabilityCat { id: string name: string color: string // hex color basePrice: number hourlyBasePrice: number } function buildPriceGrid( categories: AvailabilityCat[], days = 90, ): Record> { const result: Record> = {} const today = new Date() for (const cat of categories) { result[cat.id] = {} for (let i = 0; i < days; i++) { const d = addDays(today, i) const dateStr = format(d, 'yyyy-MM-dd') const channelPrices: Record = {} for (const ch of RATE_CHANNELS) { channelPrices[ch.id] = Math.round(cat.basePrice * (DEFAULT_CHANNEL_MARKUP[ch.id] ?? 1)) } result[cat.id][dateStr] = { price: cat.basePrice, extraPerson: 0, minNights: 1, channelPrices, closed: false } } } return result } // ─── 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, cellEditMode, selectedCat, categories, hourlyMode, }: { dates: string[] prices: Record> selection: Selection | null dragging: boolean onCellDown: (date: string, catId: string) => void onCellEnter: (date: string) => void activeChannel: string cellEditMode: boolean selectedCat: string | null categories: AvailabilityCat[] hourlyMode: boolean }) { const today = format(new Date(), DATE_FMT) const isSelected = useCallback((date: string, catId: string) => { if (!selection) return false const [s, e] = normRange(selection.start, selection.end) const inRange = date >= s && date <= e if (cellEditMode && selectedCat) return inRange && catId === selectedCat return inRange }, [selection, cellEditMode, selectedCat]) return (
{/* Date header */}
Категория
{dates.map(d => { const dt = parseISO(d) const dow = getDay(dt) const isWe = dow === 0 || dow === 6 const isTd = d === today return (
{format(dt, 'EEE', { locale: ru })} {format(dt, 'd')} {format(dt, 'MMM', { locale: ru })}
) })}
{/* Category rows */} {categories.map(cat => { const isActiveCat = cellEditMode && selectedCat === cat.id return (
{/* Label */}

{cat.name}

база: {fmtPrice(cat.basePrice)}

{/* 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, cat.id) const price = hourlyMode ? (cell?.hourlyPrice ?? cat.hourlyBasePrice ?? 0) : (activeChannel === 'direct' ? cell?.price : cell?.channelPrices[activeChannel] ?? cell?.price) return (
onCellDown(d, cat.id)} 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 ? ( ) : ( <> {price ? price.toLocaleString('ru-RU') : '—'} {hourlyMode && ( )} {!hourlyMode && cell?.fromPeriod && !isSel && ( П )} {cell?.minNights > 1 && ( min {cell.minNights}н )} )}
) })}
) })}
) } // ─── Edit Panel ─────────────────────────────────────────────────────────────── function EditPanel({ selection, prices, onApply, onClose, onlyCategoryId, categories, hourlyMode, }: { selection: Selection prices: Record> onApply: (updates: { startDate: string endDate: string categoryPrices: Record extraPerson: number minNights: number channelMarkup: Record closed: boolean onlyCategoryId?: string hourlyPrice?: number | null }) => void onClose: () => void onlyCategoryId?: string categories: AvailabilityCat[] hourlyMode: boolean }) { const [s, e] = normRange(selection.start, selection.end) const activeCat = onlyCategoryId ? categories.find(c => c.id === onlyCategoryId) ?? categories[0] : categories[0] // Initial values from first cell of selection const firstCell = prices[activeCat.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 [hourlyPrice, setHourlyPrice] = useState(firstCell?.hourlyPrice ?? activeCat.hourlyBasePrice ?? 0) const [channelMarkup, setChannelMarkup] = useState>( { ...DEFAULT_CHANNEL_MARKUP }, ) const [catPrices, setCatPrices] = useState>(() => { const r: Record = {} const catsToInit = onlyCategoryId ? categories.filter(c => c.id === onlyCategoryId) : categories for (const cat of catsToInit) { 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 (
{/* Header */}

{onlyCategoryId ? ( {activeCat.name} ) : 'Редактировать цены'}

{nightCount} {nightCount === 1 ? 'день' : nightCount < 5 ? 'дня' : 'дней'} {onlyCategoryId && ' • только эта категория'}

{/* Date range */}
setStartDate(e.target.value)} className="input text-sm flex-1 py-1.5" /> setEndDate(e.target.value)} className="input text-sm flex-1 py-1.5" />
{/* Close toggle */}

Закрыто для продажи

Все номера недоступны в этот период

{/* Category prices */} {!closed && (
{(onlyCategoryId ? [activeCat] : categories).map(cat => (
{cat.name}
setCatPrices(p => ({ ...p, [cat.id]: +ev.target.value }))} className="input text-sm py-1.5 pl-6 w-full" min={0} step={100} />
))}
)} {/* Hourly price (when hourly mode active) */} {hourlyMode && !closed && (
setHourlyPrice(+e.target.value)} placeholder={String(activeCat.hourlyBasePrice || 0)} className="input text-sm py-1.5 pl-7" min={0} step={100} />
)} {/* Extra person */} {!closed && (
setExtraPerson(+e.target.value)} className="input text-sm py-1.5 pl-7" min={0} step={100} />
)} {/* Min nights */} {!closed && (
setMinNights(Math.max(1, +e.target.value))} className="input text-sm py-1.5 w-24" min={1} max={30} />
)} {/* Channel markup */} {!closed && (
{RATE_CHANNELS.map(ch => { const markup = channelMarkup[ch.id] ?? 1.0 const pct = Math.round((markup - 1) * 100) return (
{ch.icon} {ch.name}
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} /> %
) })}

0% = та же цена, +15% = цена выше на 15%

)}
{/* Footer */}
) } // ─── Period Modal ───────────────────────────────────────────────────────────── function PeriodModal({ period, onSave, onClose, categories, }: { period?: RatePeriod onSave: (p: RatePeriod) => void onClose: () => void categories: AvailabilityCat[] }) { 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>( period?.categoryPrices ?? Object.fromEntries(categories.map(c => [c.id, c.basePrice])), ) const [markup, setMarkup] = useState>( 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 (

{period ? 'Редактировать период' : 'Новый тарифный период'}

{/* Name */}
setName(e.target.value)} />
{/* Dates */}
setStartDate(e.target.value)} />
setEndDate(e.target.value)} />
{/* Category prices */}
{categories.map(cat => (
{cat.name}
setCatPrices(p => ({ ...p, [cat.id]: +ev.target.value }))} className="input text-sm py-1.5 pl-6 w-full" />
))}
{/* Extra + min nights */}
setExtraPerson(+e.target.value)} />
setMinNights(+e.target.value)} />
{/* Channel markup */}
{RATE_CHANNELS.map(ch => { const pct = Math.round(((markup[ch.id] ?? 1) - 1) * 100) return (
{ch.icon} {ch.name}
setMarkup(p => ({ ...p, [ch.id]: 1 + +ev.target.value / 100 }))} className="input text-sm py-1 w-16 text-center" /> %
) })}
{/* Notes */}