- 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>
952 lines
40 KiB
TypeScript
952 lines
40 KiB
TypeScript
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<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: '+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 => (
|
||
<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: '40–60% — средняя' },
|
||
{ cls: 'bg-amber-400', label: '60–75% — хорошая' },
|
||
{ cls: 'bg-orange-500', label: '75–90% — высокая' },
|
||
{ 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')} ₽ применяется правило «80–95%» → +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>
|
||
)
|
||
}
|