Files
hotelsync/src/pages/DynamicPricingPage.tsx
HotelSync 2fc66d1e84 Fix mobile responsiveness across all pages
- PosPage: rooms list becomes horizontal scroll row on mobile instead of w-56 sidebar
- AvailabilityPage: edit panel stacks below grid on mobile (was fixed w-80 side panel)
- UsersPage roles tab: sidebar stacks above permissions on mobile
- All page containers: p-6 → p-4 md:p-6 (TariffsPage, DiscountsPage, MaintenancePage, ReportsPage, DynamicPricingPage, GuestsPage, LoyaltyPage)
- Stats grids: lg:grid-cols-4 → md:grid-cols-4 (GuestsPage, TariffsPage, DiscountsPage, MaintenancePage, ReportsPage, LoyaltyPage)
- Modal form grids: grid-cols-3 → grid-cols-1 sm:grid-cols-3, grid-cols-5 → grid-cols-3 sm:grid-cols-5, grid-cols-4 → grid-cols-2 sm:grid-cols-4
- PosPage modals: fixed w-80/w-96 → responsive w-[calc(100vw-2rem)] sm:w-80/96

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 00:27:43 +03:00

952 lines
40 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 {
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 // 112
occupancy: number // 0100
totalBookings: number
revenue: number
}
interface OccupancyRule {
id: string
thresholdMin: number
thresholdMax: number
modifierValue: number // %
isActive: boolean
}
const MOCK_OCCUPANCY: Record<number, MonthlyOccupancy[]> = {
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<RuleType, { label: string; color: string; bg: string }> = {
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<WeatherType, { icon: React.ElementType; label: string }> = {
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<PricingRule, 'id'> = {
name: '', type: 'weekend', isActive: true,
modifierType: 'percent', modifierValue: 20, priority: 1,
weekdays: [5, 6],
}
function RuleModal({
rule, onSave, onClose,
}: {
rule?: PricingRule
onSave: (r: Omit<PricingRule, 'id'>) => void
onClose: () => void
}) {
const [form, setForm] = useState<Omit<PricingRule, 'id'>>(rule ? { ...rule } : { ...EMPTY_RULE })
const set = <K extends keyof typeof form>(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 (
<Modal
open
onClose={onClose}
title={rule ? 'Редактировать правило' : 'Новое правило цены'}
size="lg"
footer={
<>
<button onClick={onClose} className="btn-secondary">Отмена</button>
<button
onClick={() => { if (form.name) onSave(form) }}
className="btn-primary"
disabled={!form.name}
>
{rule ? 'Сохранить' : 'Добавить правило'}
</button>
</>
}
>
<div className="space-y-4">
{/* Name */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Название *</label>
<input type="text" className="input" placeholder="Надбавка за выходные"
value={form.name} onChange={e => set('name', e.target.value)} />
</div>
{/* Type */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Тип правила</label>
<div className="grid grid-cols-5 gap-2">
{(Object.entries(RULE_TYPE_META) as [RuleType, typeof RULE_TYPE_META[RuleType]][]).map(([k, v]) => (
<button
key={k}
type="button"
onClick={() => set('type', k)}
className={cn(
'py-2 rounded-xl border text-xs font-medium transition-colors',
form.type === k
? 'bg-brand-600 border-brand-600 text-white'
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-brand-400',
)}
>
{v.label}
</button>
))}
</div>
</div>
{/* Type-specific params */}
{form.type === 'weekend' && (
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Дни недели</label>
<div className="flex gap-2">
{WEEKDAY_LABELS.map((label, idx) => (
<button
key={idx}
type="button"
onClick={() => toggleWeekday(idx)}
className={cn(
'w-10 h-10 rounded-xl text-sm font-medium border transition-colors',
form.weekdays?.includes(idx)
? 'bg-brand-600 border-brand-600 text-white'
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300',
)}
>
{label}
</button>
))}
</div>
</div>
)}
{['holiday', 'season', 'custom'].includes(form.type) && (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Дата начала</label>
<input type="date" className="input"
value={form.dateFrom ?? ''} onChange={e => set('dateFrom', e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Дата конца</label>
<input type="date" className="input"
value={form.dateTo ?? ''} onChange={e => set('dateTo', e.target.value)} />
</div>
</div>
)}
{form.type === 'weather' && (
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Условие погоды</label>
<div className="flex gap-2">
{(Object.entries(WEATHER_META) as [WeatherType, typeof WEATHER_META[WeatherType]][]).map(([k, v]) => (
<button
key={k}
type="button"
onClick={() => toggleWeatherType(k)}
className={cn(
'flex-1 flex flex-col items-center gap-1 py-2.5 rounded-xl border text-xs font-medium transition-colors',
form.weatherTypes?.includes(k)
? 'bg-brand-600 border-brand-600 text-white'
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300',
)}
>
<v.icon size={18} />
{v.label}
</button>
))}
</div>
<p className="text-xs text-slate-400 mt-2">
Погодные правила применяются автоматически при подключении к API прогноза погоды (OpenWeather)
</p>
</div>
)}
{/* Modifier */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Изменение цены
</label>
<div className="flex gap-1.5">
<button
type="button"
onClick={() => set('modifierType', 'percent')}
className={cn('px-2.5 py-1.5 rounded-lg text-xs font-medium border transition-colors',
form.modifierType === 'percent'
? 'bg-brand-600 text-white border-brand-600'
: 'bg-white dark:bg-slate-700 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-600'
)}
>%</button>
<button
type="button"
onClick={() => set('modifierType', 'fixed')}
className={cn('px-2.5 py-1.5 rounded-lg text-xs font-medium border transition-colors',
form.modifierType === 'fixed'
? 'bg-brand-600 text-white border-brand-600'
: 'bg-white dark:bg-slate-700 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-600'
)}
></button>
<input
type="number"
className="input flex-1 text-sm"
placeholder="+20 или -10"
value={form.modifierValue || ''}
onChange={e => set('modifierValue', parseInt(e.target.value) || 0)}
/>
</div>
<p className="text-xs text-slate-400 mt-1">
Пример: база 5 000 +{form.modifierValue}%
= {Math.round(5000 * (1 + form.modifierValue / 100)).toLocaleString('ru-RU')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Приоритет
</label>
<input
type="number" min={0} max={10} className="input"
value={form.priority}
onChange={e => set('priority', Math.max(0, parseInt(e.target.value) || 0))}
/>
<p className="text-xs text-slate-400 mt-1">При конфликте выше приоритет побеждает</p>
</div>
</div>
</div>
</Modal>
)
}
// ─── 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 (
<div className="card p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300">
Предпросмотр цен база {baseRate.toLocaleString('ru-RU')} /ночь
</h3>
<div className="flex items-center gap-2">
<button onClick={() => setMonth(m => new Date(m.getFullYear(), m.getMonth() - 1))} className="btn-ghost p-1">
<ChevronLeft size={16} />
</button>
<span className="text-sm font-medium text-slate-700 dark:text-slate-300 w-32 text-center">
{format(month, 'LLLL yyyy', { locale: ru })}
</span>
<button onClick={() => setMonth(m => new Date(m.getFullYear(), m.getMonth() + 1))} className="btn-ghost p-1">
<ChevronRight size={16} />
</button>
</div>
</div>
<div className="grid grid-cols-7 gap-1 mb-1">
{['Пн','Вт','Ср','Чт','Пт','Сб','Вс'].map(d => (
<div key={d} className="text-center text-xs font-medium text-slate-400 py-1">{d}</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{Array.from({ length: offset }).map((_, i) => <div key={`e-${i}`} />)}
{days.map(day => {
const mod = getEffectiveModifier(day, rules)
const price = Math.round(baseRate * (1 + mod / 100))
const sameM = isSameMonth(day, month)
return (
<div
key={day.toISOString()}
className={cn(
'rounded-lg p-1.5 text-center transition-colors',
sameM ? modColor(mod) : 'opacity-0',
)}
>
<p className="text-xs font-medium">{format(day, 'd')}</p>
<p className="text-[10px] font-bold leading-tight">
{price.toLocaleString('ru-RU')}
</p>
{mod !== 0 && (
<p className={cn('text-[9px] leading-tight font-semibold',
mod > 0 ? 'text-red-600 dark:text-red-400' : 'text-blue-600 dark:text-blue-400'
)}>
{mod > 0 ? `+${mod}` : mod}%
</p>
)}
</div>
)
})}
</div>
{/* Legend */}
<div className="flex flex-wrap gap-3 mt-3 pt-3 border-t border-slate-200 dark:border-slate-700">
{[
{ cls: 'bg-white dark:bg-slate-700', label: 'Базовая цена' },
{ cls: 'bg-amber-50 dark:bg-amber-900/20', label: '+115%' },
{ cls: 'bg-orange-100 dark:bg-orange-900/40', label: '+1640%' },
{ cls: 'bg-red-200 dark:bg-red-900/50', label: '+40%+' },
{ cls: 'bg-blue-100 dark:bg-blue-900/40', label: 'Скидка' },
].map(l => (
<div key={l.label} className="flex items-center gap-1.5">
<div className={cn('w-3 h-3 rounded border border-slate-200 dark:border-slate-600', l.cls)} />
<span className="text-xs text-slate-500 dark:text-slate-400">{l.label}</span>
</div>
))}
</div>
</div>
)
}
// ─── Occupancy Tab ────────────────────────────────────────────────────────────
function OccupancyTab({ baseRate }: { baseRate: number }) {
const [year, setYear] = useState(2026)
const [occRules, setOccRules] = useState<OccupancyRule[]>(MOCK_OCCUPANCY_RULES)
const [editingRule, setEditingRule] = useState<string | null>(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 (
<div className="space-y-6">
{/* Year selector + summary */}
<div className="flex items-center justify-between">
<div className="flex gap-1.5">
{[2025, 2026].map(y => (
<button
key={y}
onClick={() => setYear(y)}
className={cn(
'px-4 py-1.5 rounded-lg text-sm font-medium border transition-colors',
year === y
? 'bg-brand-600 text-white border-brand-600'
: 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-300',
)}
>
{y}
</button>
))}
</div>
{data.length > 0 && (
<div className="flex gap-6 text-sm">
<div className="text-right">
<p className="font-bold text-slate-900 dark:text-slate-100">{avgOccupancy}%</p>
<p className="text-xs text-slate-500">Ср. нагрузка</p>
</div>
<div className="text-right">
<p className="font-bold text-slate-900 dark:text-slate-100">
{(totalRevenue / 1_000_000).toFixed(1)} млн
</p>
<p className="text-xs text-slate-500">Выручка за период</p>
</div>
</div>
)}
</div>
{/* Bar chart */}
<div className="card p-5">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-4">
Нагрузка по месяцам {year}
</h3>
{data.length === 0 ? (
<p className="text-sm text-slate-400 text-center py-8">Нет данных за {year}</p>
) : (
<div className="space-y-2">
{data.map(m => (
<div key={m.month} className="flex items-center gap-3">
<span className="text-xs font-medium text-slate-500 dark:text-slate-400 w-8 shrink-0">
{MONTH_NAMES[m.month - 1]}
</span>
<div className="flex-1 bg-slate-100 dark:bg-slate-700 rounded-full h-6 relative overflow-hidden">
<div
className={cn('h-full rounded-full transition-all', occColor(m.occupancy))}
style={{ width: `${m.occupancy}%` }}
/>
<span className="absolute inset-0 flex items-center px-2.5 text-xs font-semibold text-white mix-blend-difference">
{m.occupancy}%
</span>
</div>
<div className="text-right shrink-0 w-28">
<p className="text-xs font-medium text-slate-700 dark:text-slate-300">
{m.totalBookings} броней
</p>
<p className="text-[10px] text-slate-400">
{(m.revenue / 1000).toFixed(0)} тыс
</p>
</div>
</div>
))}
</div>
)}
{/* Legend */}
<div className="flex flex-wrap gap-3 mt-4 pt-4 border-t border-slate-200 dark:border-slate-700">
{[
{ cls: 'bg-blue-400', label: 'до 40% — низкая' },
{ cls: 'bg-emerald-500', label: '4060% — средняя' },
{ cls: 'bg-amber-400', label: '6075% — хорошая' },
{ cls: 'bg-orange-500', label: '7590% — высокая' },
{ cls: 'bg-red-500', label: '90%+ — пиковая' },
].map(l => (
<div key={l.label} className="flex items-center gap-1.5">
<div className={cn('w-3 h-3 rounded-full', l.cls)} />
<span className="text-xs text-slate-500 dark:text-slate-400">{l.label}</span>
</div>
))}
</div>
</div>
{/* Occupancy-based pricing rules */}
<div className="card p-5">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
Правила цен по нагрузке
</h3>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
Автоматически корректировать цены в зависимости от текущей загрузки отеля
</p>
</div>
</div>
<div className="space-y-2">
{occRules.map(rule => (
<div
key={rule.id}
className={cn(
'flex items-center gap-3 p-3 rounded-xl border border-slate-200 dark:border-slate-700 transition-opacity',
!rule.isActive && 'opacity-50',
)}
>
{/* Toggle */}
<button
onClick={() => toggleOccRule(rule.id)}
className={cn('p-1 rounded-lg transition-colors shrink-0',
rule.isActive ? 'text-brand-600' : 'text-slate-400'
)}
>
{rule.isActive ? <ToggleRight size={20} /> : <ToggleLeft size={20} />}
</button>
{/* Range label */}
<div className="flex items-center gap-1.5 flex-1 min-w-0">
<span className="text-xs font-medium text-slate-600 dark:text-slate-400 shrink-0">
Нагрузка
</span>
{editingRule === rule.id ? (
<>
<input
type="number" min={0} max={100}
className="input text-xs w-14 text-center py-1"
value={rule.thresholdMin}
onChange={e => updateOccRule(rule.id, 'thresholdMin', parseInt(e.target.value) || 0)}
/>
<span className="text-xs text-slate-400"></span>
<input
type="number" min={0} max={100}
className="input text-xs w-14 text-center py-1"
value={rule.thresholdMax}
onChange={e => updateOccRule(rule.id, 'thresholdMax', parseInt(e.target.value) || 100)}
/>
<span className="text-xs text-slate-400 shrink-0">%</span>
</>
) : (
<span
className={cn(
'text-xs font-bold px-2 py-0.5 rounded',
rule.thresholdMin >= 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}%
</span>
)}
</div>
{/* Modifier */}
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-xs text-slate-500 dark:text-slate-400"></span>
{editingRule === rule.id ? (
<div className="flex items-center gap-1">
<input
type="number"
className="input text-xs w-16 text-center py-1"
value={rule.modifierValue}
onChange={e => updateOccRule(rule.id, 'modifierValue', parseInt(e.target.value) || 0)}
/>
<span className="text-xs text-slate-400">%</span>
</div>
) : (
<span className={cn(
'text-sm font-bold',
rule.modifierValue > 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}%
</span>
)}
</div>
{/* Edit / Done */}
<button
onClick={() => setEditingRule(editingRule === rule.id ? null : rule.id)}
className="p-1.5 rounded-lg text-slate-400 hover:text-brand-600 hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors shrink-0"
>
<Edit2 size={13} />
</button>
</div>
))}
</div>
<div className="mt-4 p-3 rounded-xl bg-slate-50 dark:bg-slate-700/40">
<p className="text-xs text-slate-500 dark:text-slate-400">
<strong className="text-slate-700 dark:text-slate-300">Пример:</strong> если сейчас занято 85% номеров, к базовой цене
{' '}{baseRate.toLocaleString('ru-RU')} применяется правило «8095%» +25%
{' '}{Math.round(baseRate * 1.25).toLocaleString('ru-RU')} /ночь.
Нагрузка обновляется в реальном времени.
</p>
</div>
</div>
</div>
)
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export function DynamicPricingPage() {
const [rules, setRules] = useState<PricingRule[]>(MOCK_RULES)
const [modal, setModal] = useState<'create' | PricingRule | null>(null)
const [delTarget, setDel] = useState<PricingRule | null>(null)
const [pageTab, setPageTab] = useState<'rules' | 'occupancy'>('rules')
const BASE_RATE = 5600
const save = (data: Omit<PricingRule, 'id'>) => {
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 (
<div className="p-4 md:p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Динамические цены</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
Правила автоматического изменения цен по дням, сезонам и погоде
</p>
</div>
{pageTab === 'rules' && (
<button onClick={() => setModal('create')} className="btn-primary flex items-center gap-2">
<Plus size={16} />
Новое правило
</button>
)}
</div>
{/* Tabs */}
<div className="flex gap-1 p-1 bg-slate-100 dark:bg-slate-700/50 rounded-xl w-fit">
{([
['rules', <CalendarDays size={13} />, 'Правила цен'],
['occupancy', <BarChart2 size={13} />, 'Нагрузка'],
] as const).map(([key, icon, label]) => (
<button
key={key}
onClick={() => setPageTab(key)}
className={cn(
'flex items-center gap-1.5 px-4 py-1.5 rounded-lg text-sm font-medium transition-colors',
pageTab === key
? 'bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 shadow-sm'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200',
)}
>
{icon}{label}
</button>
))}
</div>
{/* Occupancy tab */}
{pageTab === 'occupancy' && <OccupancyTab baseRate={BASE_RATE} />}
{/* Rules tab */}
{pageTab === 'rules' && <>
{/* Info */}
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-800">
<Flame size={16} className="text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<p className="text-sm text-amber-700 dark:text-amber-400">
Правила применяются к базовой цене номера. При нескольких совпадающих правилах суммируются все надбавки/скидки.
Правило с более высоким приоритетом перекрывает остальные при конфликте.
</p>
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* Rules list */}
<div className="space-y-3">
<h2 className="text-sm font-semibold text-slate-600 dark:text-slate-400 uppercase tracking-wide">
Правила ({rules.filter(r => r.isActive).length} активных)
</h2>
{rules.map(rule => {
const meta = RULE_TYPE_META[rule.type]
return (
<div
key={rule.id}
className={cn(
'card p-4 flex items-center gap-3 transition-opacity',
!rule.isActive && 'opacity-50',
)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-0.5">
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">{rule.name}</span>
<span className={cn('px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase', meta.bg, meta.color)}>
{meta.label}
</span>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
{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}
</p>
</div>
<div className="flex items-center gap-1">
{rule.modifierValue > 0
? <TrendingUp size={14} className="text-red-500" />
: <TrendingDown size={14} className="text-blue-500" />
}
<span className={cn('text-sm font-bold',
rule.modifierValue > 0 ? 'text-red-600 dark:text-red-400' : 'text-blue-600 dark:text-blue-400'
)}>
{rule.modifierValue > 0 ? '+' : ''}{rule.modifierValue}
{rule.modifierType === 'percent' ? '%' : ' ₽'}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => toggleActive(rule.id)}
className={cn('p-1.5 rounded-lg transition-colors',
rule.isActive ? 'text-brand-600' : 'text-slate-400'
)}
>
{rule.isActive ? <ToggleRight size={20} /> : <ToggleLeft size={20} />}
</button>
<button onClick={() => setModal(rule)}
className="p-1.5 rounded-lg text-slate-400 hover:text-brand-600 hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors"
>
<Edit2 size={15} />
</button>
<button onClick={() => setDel(rule)}
className="p-1.5 rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
<Trash2 size={15} />
</button>
</div>
</div>
)
})}
{/* Weather integration note */}
<div className="card p-4 border-dashed">
<div className="flex items-start gap-3">
<Sun size={16} className="text-amber-500 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">Интеграция с погодой</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
Правила типа «Погода» применяются автоматически при подключении OpenWeather API в настройках.
Прогноз загружается на 7 дней вперёд и обновляется каждые 6 часов.
</p>
<button className="mt-2 text-xs text-brand-600 dark:text-brand-400 hover:underline">
Подключить API погоды
</button>
</div>
</div>
</div>
</div>
{/* Price calendar */}
<PriceCalendar rules={rules} baseRate={BASE_RATE} />
</div>
</>}
{modal && (
<RuleModal
rule={typeof modal === 'object' ? modal : undefined}
onSave={save}
onClose={() => setModal(null)}
/>
)}
{delTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-xl p-6 w-full max-w-sm">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">Удалить правило?</h3>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-4">«{delTarget.name}» будет удалён.</p>
<div className="flex justify-end gap-2">
<button onClick={() => setDel(null)} className="btn-secondary">Отмена</button>
<button
onClick={() => { setRules(prev => prev.filter(r => r.id !== delTarget.id)); setDel(null) }}
className="px-4 py-2 rounded-xl bg-red-600 text-white text-sm font-medium hover:bg-red-700"
>Удалить</button>
</div>
</div>
</div>
)}
</div>
)
}