From 78788f68b067b61c30ecaf33c2a9fc286aeffa90 Mon Sep 17 00:00:00 2001 From: HotelSync Date: Sun, 15 Mar 2026 19:56:17 +0300 Subject: [PATCH] Add pricing, tariffs, loyalty, maintenance, discounts features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/App.tsx | 10 + src/components/bookings/BookingModal.tsx | 97 ++-- src/components/layout/Sidebar.tsx | 25 +- src/pages/DiscountsPage.tsx | 435 ++++++++++++++++ src/pages/DynamicPricingPage.tsx | 637 +++++++++++++++++++++++ src/pages/LoyaltyPage.tsx | 490 +++++++++++++++++ src/pages/MaintenancePage.tsx | 511 ++++++++++++++++++ src/pages/TariffsPage.tsx | 572 ++++++++++++++++++++ 8 files changed, 2705 insertions(+), 72 deletions(-) create mode 100644 src/pages/DiscountsPage.tsx create mode 100644 src/pages/DynamicPricingPage.tsx create mode 100644 src/pages/LoyaltyPage.tsx create mode 100644 src/pages/MaintenancePage.tsx create mode 100644 src/pages/TariffsPage.tsx diff --git a/src/App.tsx b/src/App.tsx index ec79cf0..aee47a1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,6 +27,11 @@ import { RoomCategoriesPage } from './pages/RoomCategoriesPage' import { DocumentsPage } from './pages/DocumentsPage' import { UsersPage } from './pages/UsersPage' import { GuestsPage } from './pages/GuestsPage' +import { TariffsPage } from './pages/TariffsPage' +import { DynamicPricingPage } from './pages/DynamicPricingPage' +import { LoyaltyPage } from './pages/LoyaltyPage' +import { MaintenancePage } from './pages/MaintenancePage' +import { DiscountsPage } from './pages/DiscountsPage' export default function App() { return ( @@ -63,6 +68,11 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> {/* Admin routes */} diff --git a/src/components/bookings/BookingModal.tsx b/src/components/bookings/BookingModal.tsx index 95978fd..8410acc 100644 --- a/src/components/bookings/BookingModal.tsx +++ b/src/components/bookings/BookingModal.tsx @@ -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(EMPTY_PASSPORT) - const [discountType, setDiscountType] = useState<'percent' | 'fixed'>('percent') - const [discountValue, setDiscountValue] = useState(0) - const [isFreeStay, setIsFreeStay] = useState(false) + const [selectedDiscountId, setSelectedDiscountId] = useState('') + const activeDiscounts = MOCK_DISCOUNTS.filter(d => d.isActive) + const selectedDiscount: Discount | undefined = activeDiscounts.find(d => d.id === selectedDiscountId) const setP = (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 }: {/* Discount */}
- -
- -
- {!isFreeStay && ( -
- - - setDiscountValue(Math.max(0, parseInt(e.target.value) || 0))} - /> -
- )} - {(isFreeStay || discountAmount > 0) && ( + + + {selectedDiscount && (

- Скидка: −{discountAmount.toLocaleString('ru-RU')} ₽ - {isFreeStay ? ' (бесплатное проживание)' : discountType === 'percent' ? ` (${discountValue}%)` : ''} + −{discountAmount.toLocaleString('ru-RU')} ₽ + {selectedDiscount.note ? ` · ${selectedDiscount.note}` : ''}

)}
@@ -616,7 +583,7 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }: onChange={e => set('notes', e.target.value)} /> - {(total > 0 || isFreeStay || discountAmount > 0) && room && ( + {(total > 0 || discountAmount > 0) && room && (
{isHourly && room.allowHourly ? (
@@ -637,10 +604,10 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
)} - {discountAmount > 0 && ( + {discountAmount > 0 && selectedDiscount && (
- {isFreeStay ? 'Бесплатное проживание' : `Скидка${discountType === 'percent' ? ` ${discountValue}%` : ''}`} + {selectedDiscount.value === 100 ? 'Бесплатное проживание' : selectedDiscount.name} −{discountAmount.toLocaleString('ru-RU')} ₽ diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index f3f531a..5207f99 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -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) { {!isHousekeeper && ( <> - - - - - + + + + + + + {/* ── Цены ── */} +

+ Цены и тарифы +

+ + + )} @@ -157,8 +166,10 @@ export function Sidebar({ open, onClose }: SidebarProps) {

Управление

- - + + + + {isModuleActive('channel-manager') && ( = { + all: { label: 'Все гости', cls: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300' }, + loyalty: { label: 'Программа лояльности', cls: 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400' }, + corporate: { label: 'Корпоративный', cls: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' }, + promo: { label: 'Промо-акция', cls: 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-400' }, +} + +// ─── Discount Modal ─────────────────────────────────────────────────────────── + +const EMPTY: Omit = { + name: '', code: '', valueType: 'percent', value: 10, + target: 'all', isActive: true, note: '', +} + +function DiscountModal({ + discount, onSave, onClose, +}: { + discount?: Discount + onSave: (d: Omit) => void + onClose: () => void +}) { + const [form, setForm] = useState>(discount ? { ...discount } : { ...EMPTY }) + const set = (k: K, v: typeof form[K]) => + setForm(prev => ({ ...prev, [k]: v })) + + return ( + + + + + } + > +
+
+
+ + set('name', e.target.value)} /> +
+
+ + set('code', e.target.value.toUpperCase())} /> +
+
+ + {/* Value type + value */} +
+ +
+ + + set('value', Math.max(0, parseInt(e.target.value) || 0))} + /> + + {form.valueType === 'percent' ? '%' : '₽'} + +
+ {form.valueType === 'percent' && form.value === 100 && ( +

+ Скидка 100% означает бесплатное проживание +

+ )} +
+ + {/* Target */} +
+ +
+ {(Object.entries(TARGET_META) as [DiscountTarget, typeof TARGET_META[DiscountTarget]][]).map(([k, v]) => ( + + ))} +
+
+ + {/* Optional: minNights + validity */} +
+
+ + set('minNights', parseInt(e.target.value) || undefined)} + /> +
+
+ + set('validFrom', e.target.value || undefined)} /> +
+
+ + set('validTo', e.target.value || undefined)} /> +
+
+ + {/* Note */} +
+ +