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>
This commit is contained in:
2026-03-15 19:56:17 +03:00
parent acc17387a7
commit 78788f68b0
8 changed files with 2705 additions and 72 deletions

View File

@@ -1,6 +1,8 @@
import { useState } from 'react'
import { format } from 'date-fns'
import { Star, Banknote, CreditCard, AlertCircle, Map, Plus, Trash2, IdCard, CalendarDays, Percent, Tag } from 'lucide-react'
import { Star, Banknote, CreditCard, AlertCircle, Map, Plus, Trash2, IdCard, CalendarDays, Tag } from 'lucide-react'
import { MOCK_DISCOUNTS } from '../../pages/DiscountsPage'
import type { Discount } from '../../pages/DiscountsPage'
import { Modal } from '../ui/Modal'
import { cn, BOOKING_STATUS_LABELS } from '../../lib/utils'
import type { Booking, DraftBooking, Room, BookingStatus } from '../../types'
@@ -82,9 +84,9 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
const [selectedServiceId, setSelectedServiceId] = useState(ADDITIONAL_SERVICES[0].id)
const [activeTab, setActiveTab] = useState<'booking' | 'passport'>('booking')
const [passport, setPassport] = useState<PassportData>(EMPTY_PASSPORT)
const [discountType, setDiscountType] = useState<'percent' | 'fixed'>('percent')
const [discountValue, setDiscountValue] = useState(0)
const [isFreeStay, setIsFreeStay] = useState(false)
const [selectedDiscountId, setSelectedDiscountId] = useState<string>('')
const activeDiscounts = MOCK_DISCOUNTS.filter(d => d.isActive)
const selectedDiscount: Discount | undefined = activeDiscounts.find(d => d.id === selectedDiscountId)
const setP = <K extends keyof PassportData>(k: K, v: PassportData[K]) =>
setPassport(prev => ({ ...prev, [k]: v }))
@@ -106,10 +108,11 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
const roomBaseTotal = isHourly && room?.allowHourly
? (room.hourlyRate ?? 0) * hourlyHours
: (room?.baseRate ?? 0) * nights
const discountAmount = isFreeStay ? roomBaseTotal
: discountType === 'percent'
? Math.round(roomBaseTotal * Math.min(100, Math.max(0, discountValue)) / 100)
: Math.min(roomBaseTotal, Math.max(0, discountValue))
const discountAmount = selectedDiscount
? selectedDiscount.valueType === 'percent'
? Math.round(roomBaseTotal * Math.min(100, selectedDiscount.value) / 100)
: Math.min(roomBaseTotal, selectedDiscount.value)
: 0
const roomTotal = Math.max(0, roomBaseTotal - discountAmount)
const servicesTotal = addedServices.reduce((s, sv) => s + sv.price * sv.qty, 0)
const total = roomTotal + servicesTotal
@@ -489,61 +492,25 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
</div>
{/* Discount */}
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1.5">Скидка</label>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1.5 cursor-pointer select-none">
<input
type="checkbox"
className="w-3.5 h-3.5 rounded accent-brand-600"
checked={isFreeStay}
onChange={e => { setIsFreeStay(e.target.checked); if (e.target.checked) setDiscountValue(0) }}
/>
<span className="text-xs text-slate-600 dark:text-slate-300 flex items-center gap-1">
<Tag size={11} /> Бесплатное проживание
</span>
</label>
</div>
{!isFreeStay && (
<div className="flex gap-1.5 mt-2">
<button
type="button"
onClick={() => setDiscountType('percent')}
className={cn(
'px-2.5 py-1.5 rounded-lg text-xs font-medium border transition-colors flex items-center gap-1',
discountType === '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',
)}
>
<Percent size={11} /> %
</button>
<button
type="button"
onClick={() => setDiscountType('fixed')}
className={cn(
'px-2.5 py-1.5 rounded-lg text-xs font-medium border transition-colors',
discountType === '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"
min={0}
max={discountType === 'percent' ? 100 : roomBaseTotal}
className="input flex-1 text-sm"
placeholder={discountType === 'percent' ? '0100 %' : '0 ₽'}
value={discountValue || ''}
onChange={e => setDiscountValue(Math.max(0, parseInt(e.target.value) || 0))}
/>
</div>
)}
{(isFreeStay || discountAmount > 0) && (
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1.5 flex items-center gap-1">
<Tag size={11} /> Скидка
</label>
<select
className="input text-sm"
value={selectedDiscountId}
onChange={e => setSelectedDiscountId(e.target.value)}
>
<option value=""> Без скидки </option>
{activeDiscounts.map(d => (
<option key={d.id} value={d.id}>
{d.name} ({d.value === 100 ? 'Бесплатно' : `${d.value}${d.valueType === 'percent' ? '%' : ' ₽'}`})
</option>
))}
</select>
{selectedDiscount && (
<p className="text-xs text-emerald-600 dark:text-emerald-400 mt-1">
Скидка: {discountAmount.toLocaleString('ru-RU')}
{isFreeStay ? ' (бесплатное проживание)' : discountType === 'percent' ? ` (${discountValue}%)` : ''}
{discountAmount.toLocaleString('ru-RU')}
{selectedDiscount.note ? ` · ${selectedDiscount.note}` : ''}
</p>
)}
</div>
@@ -616,7 +583,7 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
onChange={e => set('notes', e.target.value)}
/>
</div>
{(total > 0 || isFreeStay || discountAmount > 0) && room && (
{(total > 0 || discountAmount > 0) && room && (
<div className="flex-1 rounded-xl bg-slate-50 dark:bg-slate-700/50 px-4 py-3 space-y-1.5 self-end">
{isHourly && room.allowHourly ? (
<div className="flex items-center justify-between">
@@ -637,10 +604,10 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
</span>
</div>
)}
{discountAmount > 0 && (
{discountAmount > 0 && selectedDiscount && (
<div className="flex items-center justify-between text-sm">
<span className="text-emerald-600 dark:text-emerald-400">
{isFreeStay ? 'Бесплатное проживание' : `Скидка${discountType === 'percent' ? ` ${discountValue}%` : ''}`}
{selectedDiscount.value === 100 ? 'Бесплатное проживание' : selectedDiscount.name}
</span>
<span className="font-semibold text-emerald-600 dark:text-emerald-400">
{discountAmount.toLocaleString('ru-RU')}

View File

@@ -2,6 +2,7 @@ import { NavLink, useNavigate } from 'react-router-dom'
import {
CalendarDays, BookOpen, BedDouble, Globe, Settings,
FileText, LayoutDashboard, Building2, Users, X, Hotel, Puzzle, CalendarRange, Map, LayoutGrid, UserCog, UsersRound,
TrendingUp, Tag, Award, Wrench, Utensils,
} from 'lucide-react'
import { useAuth } from '../../contexts/AuthContext'
import { useModules } from '../../contexts/ModulesContext'
@@ -119,11 +120,19 @@ export function Sidebar({ open, onClose }: SidebarProps) {
<NavItem to="/calendar" icon={CalendarDays} label="Шахматка" onClick={onClose} />
{!isHousekeeper && (
<>
<NavItem to="/bookings" icon={BookOpen} label="Бронирования" onClick={onClose} />
<NavItem to="/guests" icon={UsersRound} label="Гости" onClick={onClose} />
<NavItem to="/rooms" icon={BedDouble} label="Номера" onClick={onClose} />
<NavItem to="/room-categories" icon={LayoutGrid} label="Категории номеров" onClick={onClose} />
<NavItem to="/availability" icon={CalendarRange} label="Доступность" onClick={onClose} />
<NavItem to="/bookings" icon={BookOpen} label="Бронирования" onClick={onClose} />
<NavItem to="/guests" icon={UsersRound} label="Гости" onClick={onClose} />
<NavItem to="/rooms" icon={BedDouble} label="Номера" onClick={onClose} />
<NavItem to="/room-categories" icon={LayoutGrid} label="Категории номеров" onClick={onClose} />
<NavItem to="/availability" icon={CalendarRange} label="Доступность" onClick={onClose} />
{/* ── Цены ── */}
<p className="px-3 pt-3 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
Цены и тарифы
</p>
<NavItem to="/tariffs" icon={Utensils} label="Тарифы" onClick={onClose} />
<NavItem to="/dynamic-pricing" icon={TrendingUp} label="Динамические цены" onClick={onClose} />
<NavItem to="/discounts" icon={Tag} label="Скидки" onClick={onClose} />
</>
)}
@@ -157,8 +166,10 @@ export function Sidebar({ open, onClose }: SidebarProps) {
<p className="px-3 pt-3 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
Управление
</p>
<NavItem to="/users" icon={UserCog} label="Сотрудники" onClick={onClose} />
<NavItem to="/floor-map" icon={Map} label="План этажей" onClick={onClose} />
<NavItem to="/users" icon={UserCog} label="Сотрудники" onClick={onClose} />
<NavItem to="/loyalty" icon={Award} label="Лояльность" onClick={onClose} />
<NavItem to="/maintenance" icon={Wrench} label="Тех. перерывы" onClick={onClose} />
<NavItem to="/floor-map" icon={Map} label="План этажей" onClick={onClose} />
{isModuleActive('channel-manager') && (
<NavItem
to="/channels"