import { useState } from 'react' import { Plus, Edit2, Trash2, ToggleLeft, ToggleRight, Sun, Cloud, CloudRain, Snowflake, CalendarDays, TrendingUp, TrendingDown, ChevronLeft, ChevronRight, Flame, BarChart2, } from 'lucide-react' import { Modal } from '../components/ui/Modal' import { cn } from '../lib/utils' import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, getDay } from 'date-fns' import { ru } from 'date-fns/locale' // ─── Types ──────────────────────────────────────────────────────────────────── type RuleType = 'weekend' | 'holiday' | 'season' | 'custom' | 'weather' type WeatherType = 'sunny' | 'cloudy' | 'rain' | 'snow' interface PricingRule { id: string name: string type: RuleType isActive: boolean modifierType: 'percent' | 'fixed' modifierValue: number // positive = markup, negative = discount priority: number // higher = applied last (wins) // type-specific weekdays?: number[] // 0=Sun, 1=Mon ... 6=Sat dateFrom?: string dateTo?: string weatherTypes?: WeatherType[] } // ─── Mock Rules ─────────────────────────────────────────────────────────────── const MOCK_RULES: PricingRule[] = [ { id: 'r-1', name: 'Надбавка за выходные', type: 'weekend', isActive: true, modifierType: 'percent', modifierValue: 20, priority: 1, weekdays: [5, 6], // Fri, Sat }, { id: 'r-2', name: 'Новогодние праздники', type: 'holiday', isActive: true, modifierType: 'percent', modifierValue: 60, priority: 3, dateFrom: '2026-12-30', dateTo: '2027-01-08', }, { id: 'r-3', name: '8 Марта', type: 'holiday', isActive: true, modifierType: 'percent', modifierValue: 30, priority: 3, dateFrom: '2026-03-06', dateTo: '2026-03-09', }, { id: 'r-4', name: 'Майские праздники', type: 'holiday', isActive: true, modifierType: 'percent', modifierValue: 40, priority: 3, dateFrom: '2026-04-30', dateTo: '2026-05-10', }, { id: 'r-5', name: 'Низкий сезон (январь–февраль)', type: 'season', isActive: true, modifierType: 'percent', modifierValue: -20, priority: 0, dateFrom: '2026-01-09', dateTo: '2026-02-28', }, { id: 'r-6', name: 'Высокий сезон (июнь–август)', type: 'season', isActive: true, modifierType: 'percent', modifierValue: 35, priority: 0, dateFrom: '2026-06-01', dateTo: '2026-08-31', }, { id: 'r-7', name: 'Солнечная погода +10%', type: 'weather', isActive: false, modifierType: 'percent', modifierValue: 10, priority: 1, weatherTypes: ['sunny'], }, { id: 'r-8', name: 'Дождь — скидка 10%', type: 'weather', isActive: true, modifierType: 'percent', modifierValue: -10, priority: 1, weatherTypes: ['rain', 'snow'], }, ] // ─── Occupancy Types & Mock Data ────────────────────────────────────────────── interface MonthlyOccupancy { month: number // 1–12 occupancy: number // 0–100 totalBookings: number revenue: number } interface OccupancyRule { id: string thresholdMin: number thresholdMax: number modifierValue: number // % isActive: boolean } const MOCK_OCCUPANCY: Record = { 2025: [ { month: 1, occupancy: 45, totalBookings: 38, revenue: 1_260_000 }, { month: 2, occupancy: 50, totalBookings: 42, revenue: 1_400_000 }, { month: 3, occupancy: 58, totalBookings: 51, revenue: 1_620_000 }, { month: 4, occupancy: 65, totalBookings: 57, revenue: 1_820_000 }, { month: 5, occupancy: 78, totalBookings: 68, revenue: 2_185_000 }, { month: 6, occupancy: 88, totalBookings: 77, revenue: 2_465_000 }, { month: 7, occupancy: 96, totalBookings: 84, revenue: 2_688_000 }, { month: 8, occupancy: 92, totalBookings: 80, revenue: 2_576_000 }, { month: 9, occupancy: 82, totalBookings: 72, revenue: 2_296_000 }, { month: 10, occupancy: 70, totalBookings: 61, revenue: 1_960_000 }, { month: 11, occupancy: 54, totalBookings: 47, revenue: 1_512_000 }, { month: 12, occupancy: 86, totalBookings: 75, revenue: 2_408_000 }, ], 2026: [ { month: 1, occupancy: 48, totalBookings: 40, revenue: 1_344_000 }, { month: 2, occupancy: 55, totalBookings: 46, revenue: 1_540_000 }, { month: 3, occupancy: 67, totalBookings: 58, revenue: 1_876_000 }, ], } const MOCK_OCCUPANCY_RULES: OccupancyRule[] = [ { id: 'or-1', thresholdMin: 0, thresholdMax: 40, modifierValue: -15, isActive: true }, { id: 'or-2', thresholdMin: 40, thresholdMax: 60, modifierValue: 0, isActive: true }, { id: 'or-3', thresholdMin: 60, thresholdMax: 80, modifierValue: 10, isActive: true }, { id: 'or-4', thresholdMin: 80, thresholdMax: 95, modifierValue: 25, isActive: true }, { id: 'or-5', thresholdMin: 95, thresholdMax: 100, modifierValue: 40, isActive: true }, ] const MONTH_NAMES = ['Янв','Фев','Мар','Апр','Май','Июн','Июл','Авг','Сен','Окт','Ноя','Дек'] // ─── Helpers ────────────────────────────────────────────────────────────────── const RULE_TYPE_META: Record = { weekend: { label: 'Выходные', color: 'text-blue-700 dark:text-blue-400', bg: 'bg-blue-50 dark:bg-blue-900/20' }, holiday: { label: 'Праздник', color: 'text-amber-700 dark:text-amber-400', bg: 'bg-amber-50 dark:bg-amber-900/20' }, season: { label: 'Сезон', color: 'text-green-700 dark:text-green-400', bg: 'bg-green-50 dark:bg-green-900/20' }, custom: { label: 'Свой период', color: 'text-violet-700 dark:text-violet-400', bg: 'bg-violet-50 dark:bg-violet-900/20' }, weather: { label: 'Погода', color: 'text-cyan-700 dark:text-cyan-400', bg: 'bg-cyan-50 dark:bg-cyan-900/20' }, } const WEATHER_META: Record = { sunny: { icon: Sun, label: 'Ясно' }, cloudy: { icon: Cloud, label: 'Облачно' }, rain: { icon: CloudRain, label: 'Дождь' }, snow: { icon: Snowflake, label: 'Снег' }, } const WEEKDAY_LABELS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'] // Compute effective multiplier for a date from active rules function getEffectiveModifier(date: Date, rules: PricingRule[]): number { const dateStr = format(date, 'yyyy-MM-dd') const dayOfWeek = getDay(date) // 0=Sun const weekend = dayOfWeek === 0 || dayOfWeek === 6 const activeRules = rules .filter(r => r.isActive) .sort((a, b) => a.priority - b.priority) let totalMod = 0 for (const rule of activeRules) { let applies = false if (rule.type === 'weekend') { applies = (rule.weekdays ?? [5, 6]).some(d => { // map our "Fri=5, Sat=6" to getDay (Fri=5, Sat=6) return d === dayOfWeek }) || weekend && !rule.weekdays } if (rule.type === 'holiday' || rule.type === 'season' || rule.type === 'custom') { if (rule.dateFrom && rule.dateTo) { applies = dateStr >= rule.dateFrom && dateStr <= rule.dateTo } } // weather rules don't apply to static calendar preview if (applies) { totalMod += rule.modifierType === 'percent' ? rule.modifierValue : 0 } } return totalMod } // ─── Rule Modal ─────────────────────────────────────────────────────────────── const EMPTY_RULE: Omit = { name: '', type: 'weekend', isActive: true, modifierType: 'percent', modifierValue: 20, priority: 1, weekdays: [5, 6], } function RuleModal({ rule, onSave, onClose, }: { rule?: PricingRule onSave: (r: Omit) => void onClose: () => void }) { const [form, setForm] = useState>(rule ? { ...rule } : { ...EMPTY_RULE }) const set = (k: K, v: typeof form[K]) => setForm(prev => ({ ...prev, [k]: v })) const toggleWeekday = (d: number) => setForm(prev => ({ ...prev, weekdays: prev.weekdays?.includes(d) ? prev.weekdays.filter(x => x !== d) : [...(prev.weekdays ?? []), d], })) const toggleWeatherType = (w: WeatherType) => setForm(prev => ({ ...prev, weatherTypes: prev.weatherTypes?.includes(w) ? prev.weatherTypes.filter(x => x !== w) : [...(prev.weatherTypes ?? []), w], })) return ( } >
{/* Name */}
set('name', e.target.value)} />
{/* Type */}
{(Object.entries(RULE_TYPE_META) as [RuleType, typeof RULE_TYPE_META[RuleType]][]).map(([k, v]) => ( ))}
{/* Type-specific params */} {form.type === 'weekend' && (
{WEEKDAY_LABELS.map((label, idx) => ( ))}
)} {['holiday', 'season', 'custom'].includes(form.type) && (
set('dateFrom', e.target.value)} />
set('dateTo', e.target.value)} />
)} {form.type === 'weather' && (
{(Object.entries(WEATHER_META) as [WeatherType, typeof WEATHER_META[WeatherType]][]).map(([k, v]) => ( ))}

Погодные правила применяются автоматически при подключении к API прогноза погоды (OpenWeather)

)} {/* Modifier */}
set('modifierValue', parseInt(e.target.value) || 0)} />

Пример: база 5 000 ₽ → +{form.modifierValue}% = {Math.round(5000 * (1 + form.modifierValue / 100)).toLocaleString('ru-RU')} ₽

set('priority', Math.max(0, parseInt(e.target.value) || 0))} />

При конфликте — выше приоритет побеждает

) } // ─── Price Calendar ─────────────────────────────────────────────────────────── function PriceCalendar({ rules, baseRate }: { rules: PricingRule[]; baseRate: number }) { const [month, setMonth] = useState(new Date(2026, 2)) // March 2026 const start = startOfMonth(month) const end = endOfMonth(month) const days = eachDayOfInterval({ start, end }) const offset = (getDay(start) + 6) % 7 // Mon=0 function modColor(mod: number) { if (mod > 40) return 'bg-red-200 dark:bg-red-900/50 text-red-900 dark:text-red-200' if (mod > 15) return 'bg-orange-100 dark:bg-orange-900/40 text-orange-900 dark:text-orange-200' if (mod > 0) return 'bg-amber-50 dark:bg-amber-900/20 text-amber-900 dark:text-amber-200' if (mod < -10) return 'bg-blue-100 dark:bg-blue-900/40 text-blue-900 dark:text-blue-200' if (mod < 0) return 'bg-sky-50 dark:bg-sky-900/20 text-sky-900 dark:text-sky-200' return 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300' } return (

Предпросмотр цен — база {baseRate.toLocaleString('ru-RU')} ₽/ночь

{format(month, 'LLLL yyyy', { locale: ru })}
{['Пн','Вт','Ср','Чт','Пт','Сб','Вс'].map(d => (
{d}
))}
{Array.from({ length: offset }).map((_, i) =>
)} {days.map(day => { const mod = getEffectiveModifier(day, rules) const price = Math.round(baseRate * (1 + mod / 100)) const sameM = isSameMonth(day, month) return (

{format(day, 'd')}

{price.toLocaleString('ru-RU')}

{mod !== 0 && (

0 ? 'text-red-600 dark:text-red-400' : 'text-blue-600 dark:text-blue-400' )}> {mod > 0 ? `+${mod}` : mod}%

)}
) })}
{/* Legend */}
{[ { cls: 'bg-white dark:bg-slate-700', label: 'Базовая цена' }, { cls: 'bg-amber-50 dark:bg-amber-900/20', label: '+1–15%' }, { cls: 'bg-orange-100 dark:bg-orange-900/40', label: '+16–40%' }, { cls: 'bg-red-200 dark:bg-red-900/50', label: '+40%+' }, { cls: 'bg-blue-100 dark:bg-blue-900/40', label: 'Скидка' }, ].map(l => (
{l.label}
))}
) } // ─── Occupancy Tab ──────────────────────────────────────────────────────────── function OccupancyTab({ baseRate }: { baseRate: number }) { const [year, setYear] = useState(2026) const [occRules, setOccRules] = useState(MOCK_OCCUPANCY_RULES) const [editingRule, setEditingRule] = useState(null) const data = MOCK_OCCUPANCY[year] ?? [] const totalRevenue = data.reduce((s, m) => s + m.revenue, 0) const avgOccupancy = data.length ? Math.round(data.reduce((s, m) => s + m.occupancy, 0) / data.length) : 0 function occColor(occ: number) { if (occ >= 90) return 'bg-red-500' if (occ >= 75) return 'bg-orange-500' if (occ >= 60) return 'bg-amber-400' if (occ >= 40) return 'bg-emerald-500' return 'bg-blue-400' } const toggleOccRule = (id: string) => setOccRules(prev => prev.map(r => r.id === id ? { ...r, isActive: !r.isActive } : r)) const updateOccRule = (id: string, field: keyof OccupancyRule, value: number) => setOccRules(prev => prev.map(r => r.id === id ? { ...r, [field]: value } : r)) return (
{/* Year selector + summary */}
{[2025, 2026].map(y => ( ))}
{data.length > 0 && (

{avgOccupancy}%

Ср. нагрузка

{(totalRevenue / 1_000_000).toFixed(1)} млн ₽

Выручка за период

)}
{/* Bar chart */}

Нагрузка по месяцам — {year}

{data.length === 0 ? (

Нет данных за {year}

) : (
{data.map(m => (
{MONTH_NAMES[m.month - 1]}
{m.occupancy}%

{m.totalBookings} броней

{(m.revenue / 1000).toFixed(0)} тыс ₽

))}
)} {/* Legend */}
{[ { cls: 'bg-blue-400', label: 'до 40% — низкая' }, { cls: 'bg-emerald-500', label: '40–60% — средняя' }, { cls: 'bg-amber-400', label: '60–75% — хорошая' }, { cls: 'bg-orange-500', label: '75–90% — высокая' }, { cls: 'bg-red-500', label: '90%+ — пиковая' }, ].map(l => (
{l.label}
))}
{/* Occupancy-based pricing rules */}

Правила цен по нагрузке

Автоматически корректировать цены в зависимости от текущей загрузки отеля

{occRules.map(rule => (
{/* Toggle */} {/* Range label */}
Нагрузка {editingRule === rule.id ? ( <> updateOccRule(rule.id, 'thresholdMin', parseInt(e.target.value) || 0)} /> updateOccRule(rule.id, 'thresholdMax', parseInt(e.target.value) || 100)} /> % ) : ( = 90 ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300' : rule.thresholdMin >= 75 ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300' : rule.thresholdMin >= 60 ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300' : rule.thresholdMin >= 40 ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300' : 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300', )} > {rule.thresholdMin}–{rule.thresholdMax}% )}
{/* Modifier */}
{editingRule === rule.id ? (
updateOccRule(rule.id, 'modifierValue', parseInt(e.target.value) || 0)} /> %
) : ( 0 ? 'text-red-600 dark:text-red-400' : rule.modifierValue < 0 ? 'text-blue-600 dark:text-blue-400' : 'text-slate-500 dark:text-slate-400', )}> {rule.modifierValue > 0 ? '+' : ''}{rule.modifierValue}% )}
{/* Edit / Done */}
))}

Пример: если сейчас занято 85% номеров, к базовой цене {' '}{baseRate.toLocaleString('ru-RU')} ₽ применяется правило «80–95%» → +25% → {' '}{Math.round(baseRate * 1.25).toLocaleString('ru-RU')} ₽/ночь. Нагрузка обновляется в реальном времени.

) } // ─── Main Page ──────────────────────────────────────────────────────────────── export function DynamicPricingPage() { const [rules, setRules] = useState(MOCK_RULES) const [modal, setModal] = useState<'create' | PricingRule | null>(null) const [delTarget, setDel] = useState(null) const [pageTab, setPageTab] = useState<'rules' | 'occupancy'>('rules') const BASE_RATE = 5600 const save = (data: Omit) => { if (typeof modal === 'object' && modal !== null) { setRules(prev => prev.map(r => r.id === modal.id ? { ...data, id: modal.id } : r)) } else { setRules(prev => [...prev, { ...data, id: `r-${Date.now()}` }]) } setModal(null) } const toggleActive = (id: string) => setRules(prev => prev.map(r => r.id === id ? { ...r, isActive: !r.isActive } : r)) return (
{/* Header */}

Динамические цены

Правила автоматического изменения цен по дням, сезонам и погоде

{pageTab === 'rules' && ( )}
{/* Tabs */}
{([ ['rules', , 'Правила цен'], ['occupancy', , 'Нагрузка'], ] as const).map(([key, icon, label]) => ( ))}
{/* Occupancy tab */} {pageTab === 'occupancy' && } {/* Rules tab */} {pageTab === 'rules' && <> {/* Info */}

Правила применяются к базовой цене номера. При нескольких совпадающих правилах суммируются все надбавки/скидки. Правило с более высоким приоритетом перекрывает остальные при конфликте.

{/* Rules list */}

Правила ({rules.filter(r => r.isActive).length} активных)

{rules.map(rule => { const meta = RULE_TYPE_META[rule.type] return (
{rule.name} {meta.label}

{rule.type === 'weekend' && rule.weekdays && ( `Дни: ${rule.weekdays.map(d => WEEKDAY_LABELS[d]).join(', ')}` )} {['holiday','season','custom'].includes(rule.type) && rule.dateFrom && ( `${rule.dateFrom} — ${rule.dateTo}` )} {rule.type === 'weather' && rule.weatherTypes && ( `Погода: ${rule.weatherTypes.join(', ')}` )} {' · '} Приоритет: {rule.priority}

{rule.modifierValue > 0 ? : } 0 ? 'text-red-600 dark:text-red-400' : 'text-blue-600 dark:text-blue-400' )}> {rule.modifierValue > 0 ? '+' : ''}{rule.modifierValue} {rule.modifierType === 'percent' ? '%' : ' ₽'}
) })} {/* Weather integration note */}

Интеграция с погодой

Правила типа «Погода» применяются автоматически при подключении OpenWeather API в настройках. Прогноз загружается на 7 дней вперёд и обновляется каждые 6 часов.

{/* Price calendar */}
} {modal && ( setModal(null)} /> )} {delTarget && (

Удалить правило?

«{delTarget.name}» будет удалён.

)}
) }