Files
hotelsync/src/pages/TariffsPage.tsx
HotelSync 78788f68b0 Add pricing, tariffs, loyalty, maintenance, discounts features
- TariffsPage (/tariffs): rate plan builder — meal plans (RO/BB/HB/FB/AI),
  inclusions picker, price modifier (% or ₽), min nights, cancellation policy
- DynamicPricingPage (/dynamic-pricing): rule engine for weekends, holidays,
  seasons, custom date ranges, weather; interactive price calendar preview
  with colour-coded effective rates per day
- LoyaltyPage (/loyalty): program settings (points/₽, point value, expiry),
  4-level structure (Bronze/Silver/Gold/Platinum) with editable perks,
  guest portal preview mockup, top-guests leaderboard
- MaintenancePage (/maintenance): scheduled room & service downtime records;
  status flow (scheduled → in_progress → done); time range support for services
- DiscountsPage (/discounts): discount catalogue — % or ₽, category (all /
  loyalty / corporate / promo), min nights, validity dates, active toggle
- BookingModal: discount now selected from dropdown of active discounts only
  (replaces free-form input); summary shows discount name + saved amount
- Sidebar: new "Цены и тарифы" section (Тарифы, Динамические цены, Скидки);
  Управление extended with Лояльность and Тех. перерывы

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

573 lines
25 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, Coffee, Utensils,
Wifi, Car, Waves, Sparkles, Plane, Package, Check, X, Info, Tag,
} from 'lucide-react'
import { Modal } from '../components/ui/Modal'
import { cn } from '../lib/utils'
// ─── Types ────────────────────────────────────────────────────────────────────
type MealPlan = 'no_meals' | 'breakfast' | 'hb' | 'fb' | 'ai'
interface TariffInclusion {
id: string
label: string
icon: React.ElementType
}
const ALL_INCLUSIONS: TariffInclusion[] = [
{ id: 'breakfast', label: 'Завтрак', icon: Coffee },
{ id: 'dinner', label: 'Ужин', icon: Utensils },
{ id: 'wifi', label: 'Wi-Fi', icon: Wifi },
{ id: 'parking', label: 'Парковка', icon: Car },
{ id: 'pool', label: 'Бассейн', icon: Waves },
{ id: 'spa', label: 'СПА-доступ', icon: Sparkles },
{ id: 'transfer', label: 'Трансфер', icon: Plane },
{ id: 'minibar', label: 'Мини-бар', icon: Package },
]
const MEAL_PLANS: { id: MealPlan; label: string; description: string; inclusions: string[] }[] = [
{ id: 'no_meals', label: 'Без питания', description: 'BB — Bed only', inclusions: [] },
{ id: 'breakfast', label: 'Завтрак', description: 'BB — Bed & Breakfast', inclusions: ['breakfast'] },
{ id: 'hb', label: 'Полупансион', description: 'HB — Half Board (завтрак + ужин)', inclusions: ['breakfast', 'dinner'] },
{ id: 'fb', label: 'Полный пансион', description: 'FB — Full Board (3 разовое питание)', inclusions: ['breakfast', 'dinner'] },
{ id: 'ai', label: 'Всё включено', description: 'AI — All Inclusive', inclusions: ['breakfast', 'dinner', 'pool', 'wifi', 'minibar'] },
]
interface Tariff {
id: string
name: string
code: string
mealPlan: MealPlan
inclusions: string[]
modifierType: 'percent' | 'fixed'
modifierValue: number // + means surcharge, - means discount
minNights: number
cancellationPolicy: 'flexible' | 'moderate' | 'strict' | 'nonrefundable'
description: string
isActive: boolean
}
// ─── Mock ─────────────────────────────────────────────────────────────────────
const MOCK_TARIFFS: Tariff[] = [
{
id: 't-1',
name: 'Стандарт без питания',
code: 'STD-RO',
mealPlan: 'no_meals',
inclusions: ['wifi'],
modifierType: 'percent',
modifierValue: 0,
minNights: 1,
cancellationPolicy: 'moderate',
description: 'Базовый тариф. Только проживание, Wi-Fi включён.',
isActive: true,
},
{
id: 't-2',
name: 'С завтраком',
code: 'STD-BB',
mealPlan: 'breakfast',
inclusions: ['wifi', 'breakfast'],
modifierType: 'fixed',
modifierValue: 800,
minNights: 1,
cancellationPolicy: 'flexible',
description: 'Проживание + шведский стол с 7:00 до 10:30. Отмена без штрафа за 24 ч.',
isActive: true,
},
{
id: 't-3',
name: 'Полупансион',
code: 'STD-HB',
mealPlan: 'hb',
inclusions: ['wifi', 'breakfast', 'dinner'],
modifierType: 'fixed',
modifierValue: 2200,
minNights: 2,
cancellationPolicy: 'moderate',
description: 'Завтрак + ужин по меню. Минимальный срок — 2 ночи.',
isActive: true,
},
{
id: 't-4',
name: 'Всё включено',
code: 'AI',
mealPlan: 'ai',
inclusions: ['wifi', 'breakfast', 'dinner', 'pool', 'minibar', 'spa'],
modifierType: 'percent',
modifierValue: 35,
minNights: 3,
cancellationPolicy: 'strict',
description: 'Питание, напитки, бассейн, СПА. Отмена — не позднее 5 дней до заезда.',
isActive: true,
},
{
id: 't-5',
name: 'Невозвратный',
code: 'NR',
mealPlan: 'breakfast',
inclusions: ['wifi', 'breakfast'],
modifierType: 'percent',
modifierValue: -15,
minNights: 1,
cancellationPolicy: 'nonrefundable',
description: 'Скидка 15% за невозвратное бронирование. Без изменений и отмены.',
isActive: false,
},
]
const CANCEL_POLICY: Record<Tariff['cancellationPolicy'], { label: string; cls: string }> = {
flexible: { label: 'Гибкая', cls: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' },
moderate: { label: 'Умеренная', cls: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' },
strict: { label: 'Строгая', cls: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' },
nonrefundable: { label: 'Невозвратный', cls: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' },
}
// ─── Tariff Modal ─────────────────────────────────────────────────────────────
const EMPTY_TARIFF: Omit<Tariff, 'id'> = {
name: '', code: '', mealPlan: 'no_meals', inclusions: ['wifi'],
modifierType: 'fixed', modifierValue: 0, minNights: 1,
cancellationPolicy: 'flexible', description: '', isActive: true,
}
function TariffModal({
tariff, onSave, onClose,
}: {
tariff?: Tariff
onSave: (t: Omit<Tariff, 'id'>) => void
onClose: () => void
}) {
const [form, setForm] = useState<Omit<Tariff, 'id'>>(
tariff ? { ...tariff } : { ...EMPTY_TARIFF }
)
const set = <K extends keyof typeof form>(k: K, v: typeof form[K]) =>
setForm(prev => ({ ...prev, [k]: v }))
const toggleInclusion = (id: string) =>
setForm(prev => ({
...prev,
inclusions: prev.inclusions.includes(id)
? prev.inclusions.filter(i => i !== id)
: [...prev.inclusions, id],
}))
const applyMealPlan = (plan: MealPlan) => {
const mp = MEAL_PLANS.find(m => m.id === plan)!
setForm(prev => ({
...prev,
mealPlan: plan,
inclusions: [
...new Set([
...prev.inclusions.filter(i => !['breakfast', 'dinner'].includes(i)),
...mp.inclusions,
]),
],
}))
}
const sampleBase = 5600
const modifier = form.modifierType === 'percent'
? Math.round(sampleBase * form.modifierValue / 100)
: form.modifierValue
const effectivePrice = sampleBase + modifier
return (
<Modal
open
onClose={onClose}
title={tariff ? 'Редактировать тариф' : 'Новый тариф'}
size="2xl"
footer={
<>
<button onClick={onClose} className="btn-secondary">Отмена</button>
<button
onClick={() => { if (form.name && form.code) onSave(form) }}
className="btn-primary"
disabled={!form.name || !form.code}
>
{tariff ? 'Сохранить' : 'Создать тариф'}
</button>
</>
}
>
<div className="space-y-5">
{/* Name + Code */}
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<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>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Код *
</label>
<input type="text" className="input font-mono uppercase" placeholder="STD-BB"
value={form.code}
onChange={e => set('code', e.target.value.toUpperCase())} />
</div>
</div>
{/* Meal Plan */}
<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">
{MEAL_PLANS.map(mp => (
<button
key={mp.id}
type="button"
onClick={() => applyMealPlan(mp.id)}
className={cn(
'p-2.5 rounded-xl border text-center transition-colors',
form.mealPlan === mp.id
? 'bg-brand-600 border-brand-600 text-white'
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 hover:border-brand-400',
)}
>
<p className="text-xs font-semibold leading-tight">{mp.label}</p>
<p className={cn('text-[10px] mt-0.5 leading-tight',
form.mealPlan === mp.id ? 'text-white/70' : 'text-slate-400'
)}>{mp.description.split(' — ')[0]}</p>
</button>
))}
</div>
</div>
{/* Inclusions */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
Что включено
</label>
<div className="grid grid-cols-4 gap-2">
{ALL_INCLUSIONS.map(inc => {
const active = form.inclusions.includes(inc.id)
return (
<button
key={inc.id}
type="button"
onClick={() => toggleInclusion(inc.id)}
className={cn(
'flex items-center gap-2 px-3 py-2.5 rounded-xl border text-sm font-medium transition-colors',
active
? 'bg-emerald-50 border-emerald-400 text-emerald-700 dark:bg-emerald-900/20 dark:border-emerald-600 dark:text-emerald-400'
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-500 hover:border-slate-300',
)}
>
{active
? <Check size={13} className="text-emerald-500 shrink-0" />
: <X size={13} className="text-slate-300 shrink-0" />
}
<inc.icon size={13} className="shrink-0" />
<span className="truncate text-xs">{inc.label}</span>
</button>
)
})}
</div>
</div>
{/* Price modifier + min nights + cancellation */}
<div className="grid grid-cols-3 gap-4">
<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', '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>
<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>
<input
type="number"
className="input flex-1 text-sm"
placeholder={form.modifierType === 'fixed' ? '+800' : '+15'}
value={form.modifierValue || ''}
onChange={e => set('modifierValue', parseInt(e.target.value) || 0)}
/>
</div>
{form.modifierValue !== 0 && (
<p className="text-xs mt-1 text-slate-500">
Пример: {sampleBase.toLocaleString('ru-RU')} + {modifier > 0 ? '+' : ''}{modifier.toLocaleString('ru-RU')} = <strong>{effectivePrice.toLocaleString('ru-RU')} /ночь</strong>
</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={1} max={30} className="input"
value={form.minNights}
onChange={e => set('minNights', Math.max(1, parseInt(e.target.value) || 1))}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Политика отмены
</label>
<select className="input" value={form.cancellationPolicy}
onChange={e => set('cancellationPolicy', e.target.value as Tariff['cancellationPolicy'])}>
{(Object.entries(CANCEL_POLICY) as [Tariff['cancellationPolicy'], { label: string }][]).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
</div>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Описание (для гостей и виджета)
</label>
<textarea
className="input resize-none" rows={2}
placeholder="Что входит в тариф, особые условия..."
value={form.description}
onChange={e => set('description', e.target.value)}
/>
</div>
{/* Active */}
<div className="flex items-center justify-between p-3 rounded-xl bg-slate-50 dark:bg-slate-700/40 border border-slate-200 dark:border-slate-600">
<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">Гости смогут выбрать этот тариф при бронировании</p>
</div>
<button
type="button"
onClick={() => set('isActive', !form.isActive)}
className={cn('transition-colors', form.isActive ? 'text-brand-600' : 'text-slate-400')}
>
{form.isActive ? <ToggleRight size={28} /> : <ToggleLeft size={28} />}
</button>
</div>
</div>
</Modal>
)
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export function TariffsPage() {
const [tariffs, setTariffs] = useState<Tariff[]>(MOCK_TARIFFS)
const [modal, setModal] = useState<'create' | Tariff | null>(null)
const [deleteTarget, setDeleteTarget] = useState<Tariff | null>(null)
const save = (data: Omit<Tariff, 'id'>) => {
if (typeof modal === 'object' && modal !== null) {
setTariffs(prev => prev.map(t => t.id === modal.id ? { ...data, id: modal.id } : t))
} else {
setTariffs(prev => [...prev, { ...data, id: `t-${Date.now()}` }])
}
setModal(null)
}
const toggleActive = (id: string) =>
setTariffs(prev => prev.map(t => t.id === id ? { ...t, isActive: !t.isActive } : t))
const activeTariffs = tariffs.filter(t => t.isActive)
const inactiveTariffs = tariffs.filter(t => !t.isActive)
return (
<div className="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>
<button onClick={() => setModal('create')} className="btn-primary flex items-center gap-2">
<Plus size={16} />
Новый тариф
</button>
</div>
{/* Stats */}
<div className="grid grid-cols-4 gap-4">
{[
{ label: 'Всего тарифов', value: tariffs.length, color: 'text-slate-900 dark:text-slate-100' },
{ label: 'Активных', value: activeTariffs.length, color: 'text-emerald-600 dark:text-emerald-400' },
{ label: 'Неактивных', value: inactiveTariffs.length, color: 'text-slate-400' },
{ label: 'С питанием', value: tariffs.filter(t => t.mealPlan !== 'no_meals').length, color: 'text-amber-600 dark:text-amber-400' },
].map(s => (
<div key={s.label} className="card p-4 text-center">
<p className="text-xs text-slate-500 dark:text-slate-400">{s.label}</p>
<p className={cn('text-3xl font-bold mt-1', s.color)}>{s.value}</p>
</div>
))}
</div>
{/* Info banner */}
<div className="flex items-start gap-3 p-4 rounded-xl bg-brand-50 dark:bg-brand-900/10 border border-brand-200 dark:border-brand-800">
<Info size={16} className="text-brand-600 dark:text-brand-400 mt-0.5 shrink-0" />
<p className="text-sm text-brand-700 dark:text-brand-400">
Тарифы применяются при создании бронирования гость или менеджер выбирает подходящий план.
Надбавка или скидка рассчитывается относительно базовой цены номера автоматически.
</p>
</div>
{/* Tariff list */}
<div className="space-y-3">
{tariffs.map(tariff => {
const mp = MEAL_PLANS.find(m => m.id === tariff.mealPlan)!
const modLabel = tariff.modifierValue === 0
? 'Без надбавки'
: `${tariff.modifierValue > 0 ? '+' : ''}${tariff.modifierValue}${tariff.modifierType === 'percent' ? '%' : ' ₽'}`
return (
<div
key={tariff.id}
className={cn(
'card p-4 flex items-start gap-4 transition-opacity',
!tariff.isActive && 'opacity-60',
)}
>
{/* Meal plan badge */}
<div className="w-14 shrink-0 text-center">
<div className="text-xs font-bold text-brand-600 dark:text-brand-400 bg-brand-50 dark:bg-brand-900/20 rounded-lg px-2 py-1.5 font-mono">
{tariff.code}
</div>
</div>
{/* Main info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1">
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">{tariff.name}</span>
{!tariff.isActive && (
<span className="px-2 py-0.5 rounded-full text-xs bg-slate-100 text-slate-500 dark:bg-slate-700">Неактивен</span>
)}
<span className={cn('px-2 py-0.5 rounded-full text-xs font-medium', CANCEL_POLICY[tariff.cancellationPolicy].cls)}>
{CANCEL_POLICY[tariff.cancellationPolicy].label}
</span>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-2">{tariff.description || mp.description}</p>
{/* Inclusions */}
<div className="flex flex-wrap gap-1.5">
{tariff.inclusions.map(incId => {
const inc = ALL_INCLUSIONS.find(i => i.id === incId)
if (!inc) return null
return (
<span key={incId} className="flex items-center gap-1 px-2 py-0.5 rounded-lg text-xs bg-emerald-50 text-emerald-700 dark:bg-emerald-900/20 dark:text-emerald-400 border border-emerald-200 dark:border-emerald-800">
<inc.icon size={10} />
{inc.label}
</span>
)
})}
{tariff.minNights > 1 && (
<span className="flex items-center gap-1 px-2 py-0.5 rounded-lg text-xs bg-amber-50 text-amber-700 dark:bg-amber-900/20 dark:text-amber-400 border border-amber-200 dark:border-amber-800">
Мин. {tariff.minNights} ночи
</span>
)}
</div>
</div>
{/* Price modifier */}
<div className="text-right shrink-0">
<p className={cn(
'text-sm font-semibold',
tariff.modifierValue > 0 ? 'text-amber-600 dark:text-amber-400'
: tariff.modifierValue < 0 ? 'text-emerald-600 dark:text-emerald-400'
: 'text-slate-500',
)}>
{modLabel}
</p>
<p className="text-xs text-slate-400">к базовой цене</p>
</div>
{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => toggleActive(tariff.id)}
className={cn('p-1.5 rounded-lg transition-colors', tariff.isActive ? 'text-brand-600 hover:bg-brand-50' : 'text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700')}
title={tariff.isActive ? 'Деактивировать' : 'Активировать'}
>
{tariff.isActive ? <ToggleRight size={20} /> : <ToggleLeft size={20} />}
</button>
<button
onClick={() => setModal(tariff)}
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={() => setDeleteTarget(tariff)}
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>
)
})}
</div>
{/* Tariff Modal */}
{modal && (
<TariffModal
tariff={typeof modal === 'object' ? modal : undefined}
onSave={save}
onClose={() => setModal(null)}
/>
)}
{/* Delete confirmation */}
{deleteTarget && (
<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">
«{deleteTarget.name}» будет удалён. Это не затронет уже созданные бронирования.
</p>
<div className="flex justify-end gap-2">
<button onClick={() => setDeleteTarget(null)} className="btn-secondary">Отмена</button>
<button
onClick={() => {
setTariffs(prev => prev.filter(t => t.id !== deleteTarget.id))
setDeleteTarget(null)
}}
className="px-4 py-2 rounded-xl bg-red-600 text-white text-sm font-medium hover:bg-red-700 transition-colors"
>
Удалить
</button>
</div>
</div>
</div>
)}
{/* Builder tip */}
<div className="flex items-start gap-3 p-4 rounded-xl bg-slate-50 dark:bg-slate-700/30 border border-dashed border-slate-300 dark:border-slate-600">
<Tag size={16} className="text-slate-400 mt-0.5 shrink-0" />
<div className="text-sm text-slate-500 dark:text-slate-400">
<strong className="text-slate-600 dark:text-slate-300">Подсказка:</strong> В будущих версиях тарифы будут отображаться
в виджете онлайн-бронирования на сайте и синхронизироваться с менеджером каналов для каждой OTA-площадки отдельно.
</div>
</div>
</div>
)
}