Add Guests page with history/ratings and booking discounts
- New /guests page: searchable guest list with stay history, payment summary, rating (1-5 stars), loyalty status (Базовый/Серебряный/Золотой), tags (VIP, Постоянный, Корпоративный, Медовый месяц, Проблемный), per-guest notes, inline payments tab with debt tracking - Guest detail modal: 3 tabs — Обзор / История проживания / Платежи - Sidebar: added "Гости" link (UsersRound icon) in Основное section - BookingModal: discount block — % or fixed ₽ amount, free-stay checkbox that zeroes room cost; summary shows strikethrough original + discount line; total reflects discount applied before services Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { format } from 'date-fns'
|
||||
import { Star, Banknote, CreditCard, AlertCircle, Map, Plus, Trash2, IdCard, CalendarDays } from 'lucide-react'
|
||||
import { Star, Banknote, CreditCard, AlertCircle, Map, Plus, Trash2, IdCard, CalendarDays, Percent, Tag } from 'lucide-react'
|
||||
import { Modal } from '../ui/Modal'
|
||||
import { cn, BOOKING_STATUS_LABELS } from '../../lib/utils'
|
||||
import type { Booking, DraftBooking, Room, BookingStatus } from '../../types'
|
||||
@@ -82,6 +82,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 setP = <K extends keyof PassportData>(k: K, v: PassportData[K]) =>
|
||||
setPassport(prev => ({ ...prev, [k]: v }))
|
||||
@@ -100,9 +103,14 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
|
||||
: 0
|
||||
|
||||
const hourlyHours = Math.max(0, endHour - startHour)
|
||||
const roomTotal = isHourly && room?.allowHourly
|
||||
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 roomTotal = Math.max(0, roomBaseTotal - discountAmount)
|
||||
const servicesTotal = addedServices.reduce((s, sv) => s + sv.price * sv.qty, 0)
|
||||
const total = roomTotal + servicesTotal
|
||||
|
||||
@@ -479,6 +487,67 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
|
||||
</button>
|
||||
</div>
|
||||
</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' ? '0–100 %' : '0 ₽'}
|
||||
value={discountValue || ''}
|
||||
onChange={e => setDiscountValue(Math.max(0, parseInt(e.target.value) || 0))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(isFreeStay || discountAmount > 0) && (
|
||||
<p className="text-xs text-emerald-600 dark:text-emerald-400 mt-1">
|
||||
Скидка: −{discountAmount.toLocaleString('ru-RU')} ₽
|
||||
{isFreeStay ? ' (бесплатное проживание)' : discountType === 'percent' ? ` (${discountValue}%)` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Additional services */}
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1.5">Дополнительные услуги</label>
|
||||
@@ -547,15 +616,15 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
|
||||
onChange={e => set('notes', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{total > 0 && room && (
|
||||
{(total > 0 || isFreeStay || 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">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-300">
|
||||
{hourlyHours} {hourlyHours === 1 ? 'час' : hourlyHours < 5 ? 'часа' : 'часов'} × {(room.hourlyRate ?? 0).toLocaleString('ru-RU')} ₽
|
||||
</span>
|
||||
<span className="font-semibold text-slate-900 dark:text-slate-100">
|
||||
{roomTotal.toLocaleString('ru-RU')} ₽
|
||||
<span className={cn('font-semibold', discountAmount > 0 ? 'line-through text-slate-400' : 'text-slate-900 dark:text-slate-100')}>
|
||||
{roomBaseTotal.toLocaleString('ru-RU')} ₽
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
@@ -563,8 +632,18 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
|
||||
<span className="text-sm text-slate-600 dark:text-slate-300">
|
||||
{nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'} × {room.baseRate.toLocaleString('ru-RU')} ₽
|
||||
</span>
|
||||
<span className="font-semibold text-slate-900 dark:text-slate-100">
|
||||
{roomTotal.toLocaleString('ru-RU')} ₽
|
||||
<span className={cn('font-semibold', discountAmount > 0 ? 'line-through text-slate-400' : 'text-slate-900 dark:text-slate-100')}>
|
||||
{roomBaseTotal.toLocaleString('ru-RU')} ₽
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{discountAmount > 0 && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-emerald-600 dark:text-emerald-400">
|
||||
{isFreeStay ? 'Бесплатное проживание' : `Скидка${discountType === 'percent' ? ` ${discountValue}%` : ''}`}
|
||||
</span>
|
||||
<span className="font-semibold text-emerald-600 dark:text-emerald-400">
|
||||
−{discountAmount.toLocaleString('ru-RU')} ₽
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -574,13 +653,13 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
|
||||
<span className="font-semibold text-slate-900 dark:text-slate-100">{servicesTotal.toLocaleString('ru-RU')} ₽</span>
|
||||
</div>
|
||||
)}
|
||||
{servicesTotal > 0 && (
|
||||
{(servicesTotal > 0 || discountAmount > 0) && (
|
||||
<div className="flex items-center justify-between border-t border-slate-200 dark:border-slate-600 pt-1.5">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">Итого</span>
|
||||
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">{total.toLocaleString('ru-RU')} ₽</span>
|
||||
</div>
|
||||
)}
|
||||
{servicesTotal === 0 && (
|
||||
{servicesTotal === 0 && discountAmount === 0 && (
|
||||
<div className="text-xl font-bold text-slate-900 dark:text-slate-100 text-right">{total.toLocaleString('ru-RU')} ₽</div>
|
||||
)}
|
||||
{paidAmount > 0 && (
|
||||
|
||||
@@ -1,7 +1,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,
|
||||
FileText, LayoutDashboard, Building2, Users, X, Hotel, Puzzle, CalendarRange, Map, LayoutGrid, UserCog, UsersRound,
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
import { useModules } from '../../contexts/ModulesContext'
|
||||
@@ -120,6 +120,7 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
||||
{!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} />
|
||||
|
||||
Reference in New Issue
Block a user