From acc17387a74a5473a3ed53561fff1363635411cc Mon Sep 17 00:00:00 2001 From: HotelSync Date: Sun, 15 Mar 2026 16:42:39 +0300 Subject: [PATCH] Add Guests page with history/ratings and booking discounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/App.tsx | 2 + src/components/bookings/BookingModal.tsx | 97 +++- src/components/layout/Sidebar.tsx | 3 +- src/pages/GuestsPage.tsx | 646 +++++++++++++++++++++++ 4 files changed, 738 insertions(+), 10 deletions(-) create mode 100644 src/pages/GuestsPage.tsx diff --git a/src/App.tsx b/src/App.tsx index d24875c..ec79cf0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,7 @@ import { RentalPage } from './pages/RentalPage' import { RoomCategoriesPage } from './pages/RoomCategoriesPage' import { DocumentsPage } from './pages/DocumentsPage' import { UsersPage } from './pages/UsersPage' +import { GuestsPage } from './pages/GuestsPage' export default function App() { return ( @@ -61,6 +62,7 @@ export default function App() { } /> } /> } /> + } /> {/* Admin routes */} diff --git a/src/components/bookings/BookingModal.tsx b/src/components/bookings/BookingModal.tsx index bb6158f..95978fd 100644 --- a/src/components/bookings/BookingModal.tsx +++ b/src/components/bookings/BookingModal.tsx @@ -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(EMPTY_PASSPORT) + const [discountType, setDiscountType] = useState<'percent' | 'fixed'>('percent') + const [discountValue, setDiscountValue] = useState(0) + const [isFreeStay, setIsFreeStay] = useState(false) const setP = (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 }: + {/* Discount */} +
+ +
+ +
+ {!isFreeStay && ( +
+ + + setDiscountValue(Math.max(0, parseInt(e.target.value) || 0))} + /> +
+ )} + {(isFreeStay || discountAmount > 0) && ( +

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

+ )} +
+ {/* Additional services */}
@@ -547,15 +616,15 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }: onChange={e => set('notes', e.target.value)} />
- {total > 0 && room && ( + {(total > 0 || isFreeStay || discountAmount > 0) && room && (
{isHourly && room.allowHourly ? (
{hourlyHours} {hourlyHours === 1 ? 'час' : hourlyHours < 5 ? 'часа' : 'часов'} × {(room.hourlyRate ?? 0).toLocaleString('ru-RU')} ₽ - - {roomTotal.toLocaleString('ru-RU')} ₽ + 0 ? 'line-through text-slate-400' : 'text-slate-900 dark:text-slate-100')}> + {roomBaseTotal.toLocaleString('ru-RU')} ₽
) : ( @@ -563,8 +632,18 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }: {nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'} × {room.baseRate.toLocaleString('ru-RU')} ₽ - - {roomTotal.toLocaleString('ru-RU')} ₽ + 0 ? 'line-through text-slate-400' : 'text-slate-900 dark:text-slate-100')}> + {roomBaseTotal.toLocaleString('ru-RU')} ₽ + +
+ )} + {discountAmount > 0 && ( +
+ + {isFreeStay ? 'Бесплатное проживание' : `Скидка${discountType === 'percent' ? ` ${discountValue}%` : ''}`} + + + −{discountAmount.toLocaleString('ru-RU')} ₽
)} @@ -574,13 +653,13 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }: {servicesTotal.toLocaleString('ru-RU')} ₽ )} - {servicesTotal > 0 && ( + {(servicesTotal > 0 || discountAmount > 0) && (
Итого {total.toLocaleString('ru-RU')} ₽
)} - {servicesTotal === 0 && ( + {servicesTotal === 0 && discountAmount === 0 && (
{total.toLocaleString('ru-RU')} ₽
)} {paidAmount > 0 && ( diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index ef3c0b9..f3f531a 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -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 && ( <> + diff --git a/src/pages/GuestsPage.tsx b/src/pages/GuestsPage.tsx new file mode 100644 index 0000000..cd1b8af --- /dev/null +++ b/src/pages/GuestsPage.tsx @@ -0,0 +1,646 @@ +import { useState } from 'react' +import { + Search, Star, Phone, Mail, User, TrendingUp, Award, Users, + Calendar, CreditCard, X, ChevronRight, MessageSquare, Tag, + Repeat2, ShieldCheck, +} from 'lucide-react' +import { cn } from '../lib/utils' + +// ─── Types ─────────────────────────────────────────────────────────────────── + +type GuestTag = 'VIP' | 'Постоянный' | 'Корпоративный' | 'Медовый месяц' | 'Проблемный' + +interface StayRecord { + id: string + checkIn: string + checkOut: string + roomNumber: string + roomType: string + amount: number + paid: number + status: 'completed' | 'cancelled' | 'no_show' +} + +interface Guest { + id: string + name: string + email: string + phone: string + rating: number // 1–5 + tags: GuestTag[] + totalStays: number + totalSpent: number + lastVisit: string + notes: string + history: StayRecord[] +} + +// ─── Mock Data ──────────────────────────────────────────────────────────────── + +const MOCK_GUESTS: Guest[] = [ + { + id: 'g-1', + name: 'Александр Петров', + email: 'a.petrov@gmail.com', + phone: '+7 916 123-45-67', + rating: 5, + tags: ['VIP', 'Постоянный'], + totalStays: 14, + totalSpent: 287400, + lastVisit: '2026-03-01', + notes: 'Предпочитает номера с видом на море. Всегда заказывает завтрак. Любит тихие номера подальше от лифта.', + history: [ + { id: 'b-101', checkIn: '2026-03-01', checkOut: '2026-03-05', roomNumber: '205', roomType: 'Делюкс', amount: 24000, paid: 24000, status: 'completed' }, + { id: 'b-102', checkIn: '2025-12-20', checkOut: '2025-12-26', roomNumber: '205', roomType: 'Делюкс', amount: 36000, paid: 36000, status: 'completed' }, + { id: 'b-103', checkIn: '2025-09-10', checkOut: '2025-09-14', roomNumber: '301', roomType: 'Сьют', amount: 48000, paid: 48000, status: 'completed' }, + { id: 'b-104', checkIn: '2025-06-01', checkOut: '2025-06-07', roomNumber: '205', roomType: 'Делюкс', amount: 42000, paid: 42000, status: 'completed' }, + ], + }, + { + id: 'g-2', + name: 'Мария Сидорова', + email: 'm.sidorova@mail.ru', + phone: '+7 903 456-78-90', + rating: 4, + tags: ['Корпоративный'], + totalStays: 6, + totalSpent: 98200, + lastVisit: '2026-02-15', + notes: 'Корпоративный гость компании ООО "ТехСтрой". Нужен ранний заезд.', + history: [ + { id: 'b-201', checkIn: '2026-02-13', checkOut: '2026-02-15', roomNumber: '102', roomType: 'Стандарт', amount: 11200, paid: 11200, status: 'completed' }, + { id: 'b-202', checkIn: '2025-11-20', checkOut: '2025-11-23', roomNumber: '102', roomType: 'Стандарт', amount: 16800, paid: 16800, status: 'completed' }, + ], + }, + { + id: 'g-3', + name: 'Дмитрий Козлов', + email: 'dkozlov@yandex.ru', + phone: '+7 926 789-01-23', + rating: 3, + tags: [], + totalStays: 2, + totalSpent: 18600, + lastVisit: '2026-01-20', + notes: '', + history: [ + { id: 'b-301', checkIn: '2026-01-18', checkOut: '2026-01-20', roomNumber: '110', roomType: 'Стандарт', amount: 9300, paid: 9300, status: 'completed' }, + { id: 'b-302', checkIn: '2025-08-05', checkOut: '2025-08-07', roomNumber: '108', roomType: 'Стандарт', amount: 9300, paid: 0, status: 'no_show' }, + ], + }, + { + id: 'g-4', + name: 'Елена Новикова', + email: 'e.novikova@gmail.com', + phone: '+7 985 234-56-78', + rating: 5, + tags: ['VIP', 'Медовый месяц'], + totalStays: 3, + totalSpent: 76500, + lastVisit: '2026-02-28', + notes: 'Отмечала медовый месяц. Очень довольна обслуживанием, оставила 5* отзыв на Booking.', + history: [ + { id: 'b-401', checkIn: '2026-02-14', checkOut: '2026-02-21', roomNumber: '401', roomType: 'Люкс', amount: 63000, paid: 63000, status: 'completed' }, + { id: 'b-402', checkIn: '2024-12-30', checkOut: '2025-01-03', roomNumber: '301', roomType: 'Сьют', amount: 13500, paid: 13500, status: 'completed' }, + ], + }, + { + id: 'g-5', + name: 'Игорь Волков', + email: 'i.volkov@corp.ru', + phone: '+7 909 345-67-89', + rating: 2, + tags: ['Проблемный'], + totalStays: 4, + totalSpent: 44000, + lastVisit: '2025-11-10', + notes: 'Были жалобы от соседних номеров на шум. Конфликт с персоналом 11.11.2025. Требует внимания при заселении.', + history: [ + { id: 'b-501', checkIn: '2025-11-08', checkOut: '2025-11-10', roomNumber: '215', roomType: 'Делюкс', amount: 14000, paid: 14000, status: 'completed' }, + { id: 'b-502', checkIn: '2025-07-14', checkOut: '2025-07-17', roomNumber: '108', roomType: 'Стандарт', amount: 13500, paid: 13500, status: 'completed' }, + ], + }, + { + id: 'g-6', + name: 'Анна Лебедева', + email: 'anna.l@mail.ru', + phone: '+7 965 567-89-01', + rating: 4, + tags: ['Постоянный'], + totalStays: 8, + totalSpent: 132000, + lastVisit: '2026-03-10', + notes: 'Регулярно приезжает в командировку. Всегда берёт одноместный стандарт.', + history: [ + { id: 'b-601', checkIn: '2026-03-08', checkOut: '2026-03-10', roomNumber: '104', roomType: 'Стандарт', amount: 11200, paid: 11200, status: 'completed' }, + { id: 'b-602', checkIn: '2026-02-01', checkOut: '2026-02-04', roomNumber: '104', roomType: 'Стандарт', amount: 16800, paid: 16800, status: 'completed' }, + ], + }, + { + id: 'g-7', + name: 'Сергей Морозов', + email: '', + phone: '+7 977 678-90-12', + rating: 4, + tags: [], + totalStays: 1, + totalSpent: 8400, + lastVisit: '2026-03-12', + notes: '', + history: [ + { id: 'b-701', checkIn: '2026-03-11', checkOut: '2026-03-12', roomNumber: '109', roomType: 'Стандарт', amount: 8400, paid: 8400, status: 'completed' }, + ], + }, +] + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const STAY_STATUS: Record = { + completed: { label: 'Выполнено', cls: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' }, + cancelled: { label: 'Отменено', cls: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' }, + no_show: { label: 'Неявка', cls: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' }, +} + +const TAG_META: Record = { + VIP: { cls: 'bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-900/30 dark:text-amber-400' }, + Постоянный: { cls: 'bg-brand-100 text-brand-700 border-brand-200 dark:bg-brand-900/30 dark:text-brand-400' }, + Корпоративный: { cls: 'bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400' }, + 'Медовый месяц':{ cls: 'bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-900/30 dark:text-pink-400' }, + Проблемный: { cls: 'bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-400' }, +} + +function StarRating({ value, onChange }: { value: number; onChange?: (v: number) => void }) { + return ( +
+ {[1, 2, 3, 4, 5].map(i => ( + onChange?.(i)} + /> + ))} +
+ ) +} + +function formatDate(iso: string) { + if (!iso) return '—' + const d = new Date(iso) + return d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' }) +} + +function nights(checkIn: string, checkOut: string) { + const n = Math.round((new Date(checkOut).getTime() - new Date(checkIn).getTime()) / 86400000) + return `${n} ${n === 1 ? 'ночь' : n < 5 ? 'ночи' : 'ночей'}` +} + +// ─── Guest Detail Modal ─────────────────────────────────────────────────────── + +function GuestModal({ guest, onClose }: { guest: Guest; onClose: () => void }) { + const [tab, setTab] = useState<'overview' | 'history' | 'payments'>('overview') + const [editNotes, setEditNotes] = useState(guest.notes) + const [rating, setRating] = useState(guest.rating) + + const completedStays = guest.history.filter(s => s.status === 'completed') + const totalPaid = guest.history.reduce((s, h) => s + h.paid, 0) + const avgBill = completedStays.length ? Math.round(totalPaid / completedStays.length) : 0 + + return ( +
+
+ {/* Header */} +
+
+
+ + {guest.name.split(' ').map(p => p[0]).join('').slice(0, 2)} + +
+
+
+

{guest.name}

+ {guest.tags.includes('VIP') && ( + + VIP + + )} +
+ +
+ {guest.phone && ( + + {guest.phone} + + )} + {guest.email && ( + + {guest.email} + + )} +
+
+
+ +
+ + {/* Stats strip */} +
+ {[ + { label: 'Визитов', value: guest.totalStays }, + { label: 'Потрачено', value: `${guest.totalSpent.toLocaleString('ru-RU')} ₽` }, + { label: 'Средний чек', value: `${avgBill.toLocaleString('ru-RU')} ₽` }, + { label: 'Последний визит', value: formatDate(guest.lastVisit) }, + ].map(s => ( +
+

{s.label}

+

{s.value}

+
+ ))} +
+ + {/* Tabs */} +
+ {([ + { id: 'overview' as const, label: 'Обзор' }, + { id: 'history' as const, label: 'История проживания' }, + { id: 'payments' as const, label: 'Платежи' }, + ]).map(t => ( + + ))} +
+ + {/* Body */} +
+ {tab === 'overview' && ( +
+ {/* Tags */} +
+

Метки

+
+ {guest.tags.length > 0 ? guest.tags.map(tag => ( + + {tag} + + )) : ( + Метки не назначены + )} +
+
+ + {/* Notes */} +
+

+ Заметки о госте +

+