Add occupancy analytics, housekeeping maintenance notes, and booking widget form flow
- DynamicPricingPage: add 'Нагрузка' tab with monthly occupancy bar chart (2025/2026 data), year selector, summary stats, and editable occupancy-based pricing rules (threshold ranges → price modifiers) - HousekeepingPage: add maintenance note button (wrench icon) on task cards — housekeeper can report broken items with a description that gets sent to technical service; notes displayed in orange badge - BookingWidgetPage: fix 'Забронировать' button with full form flow (browse → form → success), add configurable form fields (required/optional/custom), add additional services toggle in settings and at checkout, add extra beds + children count in guest selector with auto-pricing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Plus, Edit2, Trash2, ToggleLeft, ToggleRight, Sun, Cloud, CloudRain,
|
||||
Snowflake, CalendarDays, Calendar, TrendingUp, TrendingDown, Info,
|
||||
ChevronLeft, ChevronRight, Flame,
|
||||
Snowflake, CalendarDays, TrendingUp, TrendingDown,
|
||||
ChevronLeft, ChevronRight, Flame, BarChart2,
|
||||
} from 'lucide-react'
|
||||
import { Modal } from '../components/ui/Modal'
|
||||
import { cn } from '../lib/utils'
|
||||
import { format, addDays, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, getDay, isWeekend } from 'date-fns'
|
||||
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, getDay } from 'date-fns'
|
||||
import { ru } from 'date-fns/locale'
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
@@ -119,6 +119,55 @@ const MOCK_RULES: PricingRule[] = [
|
||||
},
|
||||
]
|
||||
|
||||
// ─── 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 }> = {
|
||||
@@ -468,12 +517,246 @@ function PriceCalendar({ rules, baseRate }: { rules: PricingRule[]; baseRate: nu
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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'>) => {
|
||||
@@ -498,12 +781,41 @@ export function DynamicPricingPage() {
|
||||
Правила автоматического изменения цен по дням, сезонам и погоде
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setModal('create')} className="btn-primary flex items-center gap-2">
|
||||
<Plus size={16} />
|
||||
Новое правило
|
||||
</button>
|
||||
{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" />
|
||||
@@ -609,6 +921,8 @@ export function DynamicPricingPage() {
|
||||
<PriceCalendar rules={rules} baseRate={BASE_RATE} />
|
||||
</div>
|
||||
|
||||
</>}
|
||||
|
||||
{modal && (
|
||||
<RuleModal
|
||||
rule={typeof modal === 'object' ? modal : undefined}
|
||||
|
||||
Reference in New Issue
Block a user