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:
@@ -26,6 +26,7 @@ import { RentalPage } from './pages/RentalPage'
|
|||||||
import { RoomCategoriesPage } from './pages/RoomCategoriesPage'
|
import { RoomCategoriesPage } from './pages/RoomCategoriesPage'
|
||||||
import { DocumentsPage } from './pages/DocumentsPage'
|
import { DocumentsPage } from './pages/DocumentsPage'
|
||||||
import { UsersPage } from './pages/UsersPage'
|
import { UsersPage } from './pages/UsersPage'
|
||||||
|
import { GuestsPage } from './pages/GuestsPage'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -61,6 +62,7 @@ export default function App() {
|
|||||||
<Route path="/room-categories" element={<RoomCategoriesPage />} />
|
<Route path="/room-categories" element={<RoomCategoriesPage />} />
|
||||||
<Route path="/documents" element={<DocumentsPage />} />
|
<Route path="/documents" element={<DocumentsPage />} />
|
||||||
<Route path="/users" element={<UsersPage />} />
|
<Route path="/users" element={<UsersPage />} />
|
||||||
|
<Route path="/guests" element={<GuestsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Admin routes */}
|
{/* Admin routes */}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { format } from 'date-fns'
|
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 { Modal } from '../ui/Modal'
|
||||||
import { cn, BOOKING_STATUS_LABELS } from '../../lib/utils'
|
import { cn, BOOKING_STATUS_LABELS } from '../../lib/utils'
|
||||||
import type { Booking, DraftBooking, Room, BookingStatus } from '../../types'
|
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 [selectedServiceId, setSelectedServiceId] = useState(ADDITIONAL_SERVICES[0].id)
|
||||||
const [activeTab, setActiveTab] = useState<'booking' | 'passport'>('booking')
|
const [activeTab, setActiveTab] = useState<'booking' | 'passport'>('booking')
|
||||||
const [passport, setPassport] = useState<PassportData>(EMPTY_PASSPORT)
|
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]) =>
|
const setP = <K extends keyof PassportData>(k: K, v: PassportData[K]) =>
|
||||||
setPassport(prev => ({ ...prev, [k]: v }))
|
setPassport(prev => ({ ...prev, [k]: v }))
|
||||||
@@ -100,9 +103,14 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
|
|||||||
: 0
|
: 0
|
||||||
|
|
||||||
const hourlyHours = Math.max(0, endHour - startHour)
|
const hourlyHours = Math.max(0, endHour - startHour)
|
||||||
const roomTotal = isHourly && room?.allowHourly
|
const roomBaseTotal = isHourly && room?.allowHourly
|
||||||
? (room.hourlyRate ?? 0) * hourlyHours
|
? (room.hourlyRate ?? 0) * hourlyHours
|
||||||
: (room?.baseRate ?? 0) * nights
|
: (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 servicesTotal = addedServices.reduce((s, sv) => s + sv.price * sv.qty, 0)
|
||||||
const total = roomTotal + servicesTotal
|
const total = roomTotal + servicesTotal
|
||||||
|
|
||||||
@@ -479,6 +487,67 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Additional services */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1.5">Дополнительные услуги</label>
|
<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)}
|
onChange={e => set('notes', e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<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 ? (
|
{isHourly && room.allowHourly ? (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-slate-600 dark:text-slate-300">
|
<span className="text-sm text-slate-600 dark:text-slate-300">
|
||||||
{hourlyHours} {hourlyHours === 1 ? 'час' : hourlyHours < 5 ? 'часа' : 'часов'} × {(room.hourlyRate ?? 0).toLocaleString('ru-RU')} ₽
|
{hourlyHours} {hourlyHours === 1 ? 'час' : hourlyHours < 5 ? 'часа' : 'часов'} × {(room.hourlyRate ?? 0).toLocaleString('ru-RU')} ₽
|
||||||
</span>
|
</span>
|
||||||
<span className="font-semibold text-slate-900 dark:text-slate-100">
|
<span className={cn('font-semibold', discountAmount > 0 ? 'line-through text-slate-400' : 'text-slate-900 dark:text-slate-100')}>
|
||||||
{roomTotal.toLocaleString('ru-RU')} ₽
|
{roomBaseTotal.toLocaleString('ru-RU')} ₽
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<span className="text-sm text-slate-600 dark:text-slate-300">
|
||||||
{nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'} × {room.baseRate.toLocaleString('ru-RU')} ₽
|
{nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'} × {room.baseRate.toLocaleString('ru-RU')} ₽
|
||||||
</span>
|
</span>
|
||||||
<span className="font-semibold text-slate-900 dark:text-slate-100">
|
<span className={cn('font-semibold', discountAmount > 0 ? 'line-through text-slate-400' : 'text-slate-900 dark:text-slate-100')}>
|
||||||
{roomTotal.toLocaleString('ru-RU')} ₽
|
{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>
|
</span>
|
||||||
</div>
|
</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>
|
<span className="font-semibold text-slate-900 dark:text-slate-100">{servicesTotal.toLocaleString('ru-RU')} ₽</span>
|
||||||
</div>
|
</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">
|
<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-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>
|
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">{total.toLocaleString('ru-RU')} ₽</span>
|
||||||
</div>
|
</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>
|
<div className="text-xl font-bold text-slate-900 dark:text-slate-100 text-right">{total.toLocaleString('ru-RU')} ₽</div>
|
||||||
)}
|
)}
|
||||||
{paidAmount > 0 && (
|
{paidAmount > 0 && (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NavLink, useNavigate } from 'react-router-dom'
|
import { NavLink, useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
CalendarDays, BookOpen, BedDouble, Globe, Settings,
|
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'
|
} from 'lucide-react'
|
||||||
import { useAuth } from '../../contexts/AuthContext'
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
import { useModules } from '../../contexts/ModulesContext'
|
import { useModules } from '../../contexts/ModulesContext'
|
||||||
@@ -120,6 +120,7 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
|||||||
{!isHousekeeper && (
|
{!isHousekeeper && (
|
||||||
<>
|
<>
|
||||||
<NavItem to="/bookings" icon={BookOpen} 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="/rooms" icon={BedDouble} label="Номера" onClick={onClose} />
|
||||||
<NavItem to="/room-categories" icon={LayoutGrid} label="Категории номеров" onClick={onClose} />
|
<NavItem to="/room-categories" icon={LayoutGrid} label="Категории номеров" onClick={onClose} />
|
||||||
<NavItem to="/availability" icon={CalendarRange} label="Доступность" onClick={onClose} />
|
<NavItem to="/availability" icon={CalendarRange} label="Доступность" onClick={onClose} />
|
||||||
|
|||||||
646
src/pages/GuestsPage.tsx
Normal file
646
src/pages/GuestsPage.tsx
Normal file
@@ -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<StayRecord['status'], { label: string; cls: string }> = {
|
||||||
|
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<GuestTag, { cls: string }> = {
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
{[1, 2, 3, 4, 5].map(i => (
|
||||||
|
<Star
|
||||||
|
key={i}
|
||||||
|
size={14}
|
||||||
|
className={cn(
|
||||||
|
i <= value ? 'text-amber-400 fill-amber-400' : 'text-slate-300 dark:text-slate-600',
|
||||||
|
onChange && 'cursor-pointer hover:text-amber-300',
|
||||||
|
)}
|
||||||
|
onClick={() => onChange?.(i)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<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 w-full max-w-2xl max-h-[90vh] flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between p-6 border-b border-slate-200 dark:border-slate-700">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-14 h-14 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center shrink-0">
|
||||||
|
<span className="text-xl font-bold text-brand-600 dark:text-brand-400">
|
||||||
|
{guest.name.split(' ').map(p => p[0]).join('').slice(0, 2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">{guest.name}</h2>
|
||||||
|
{guest.tags.includes('VIP') && (
|
||||||
|
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
|
||||||
|
<Star size={10} className="fill-current" /> VIP
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<StarRating value={rating} onChange={setRating} />
|
||||||
|
<div className="flex items-center gap-3 mt-1">
|
||||||
|
{guest.phone && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
<Phone size={11} />{guest.phone}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{guest.email && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
<Mail size={11} />{guest.email}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="btn-ghost p-1.5 -mr-1 -mt-1">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats strip */}
|
||||||
|
<div className="grid grid-cols-4 divide-x divide-slate-200 dark:divide-slate-700 border-b border-slate-200 dark:border-slate-700">
|
||||||
|
{[
|
||||||
|
{ label: 'Визитов', value: guest.totalStays },
|
||||||
|
{ label: 'Потрачено', value: `${guest.totalSpent.toLocaleString('ru-RU')} ₽` },
|
||||||
|
{ label: 'Средний чек', value: `${avgBill.toLocaleString('ru-RU')} ₽` },
|
||||||
|
{ label: 'Последний визит', value: formatDate(guest.lastVisit) },
|
||||||
|
].map(s => (
|
||||||
|
<div key={s.label} className="px-4 py-3 text-center">
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">{s.label}</p>
|
||||||
|
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100 mt-0.5">{s.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex border-b border-slate-200 dark:border-slate-700 px-6">
|
||||||
|
{([
|
||||||
|
{ id: 'overview' as const, label: 'Обзор' },
|
||||||
|
{ id: 'history' as const, label: 'История проживания' },
|
||||||
|
{ id: 'payments' as const, label: 'Платежи' },
|
||||||
|
]).map(t => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => setTab(t.id)}
|
||||||
|
className={cn(
|
||||||
|
'py-3 px-4 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||||
|
tab === t.id
|
||||||
|
? 'border-brand-600 text-brand-600 dark:text-brand-400 dark:border-brand-400'
|
||||||
|
: 'border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
{tab === 'overview' && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Tags */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Метки</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{guest.tags.length > 0 ? guest.tags.map(tag => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className={cn('px-2.5 py-1 rounded-lg text-xs font-medium border', TAG_META[tag].cls)}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
)) : (
|
||||||
|
<span className="text-sm text-slate-400">Метки не назначены</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||||||
|
<span className="flex items-center gap-1.5"><MessageSquare size={12} />Заметки о госте</span>
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
className="input resize-none text-sm"
|
||||||
|
rows={4}
|
||||||
|
placeholder="Особые предпочтения, пожелания, замечания..."
|
||||||
|
value={editNotes}
|
||||||
|
onChange={e => setEditNotes(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick stats */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Статус лояльности</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{guest.totalStays >= 10 ? (
|
||||||
|
<><Award size={16} className="text-amber-500" /><span className="text-sm font-semibold text-slate-900 dark:text-slate-100">Золотой</span></>
|
||||||
|
) : guest.totalStays >= 5 ? (
|
||||||
|
<><ShieldCheck size={16} className="text-blue-500" /><span className="text-sm font-semibold text-slate-900 dark:text-slate-100">Серебряный</span></>
|
||||||
|
) : (
|
||||||
|
<><User size={16} className="text-slate-400" /><span className="text-sm font-semibold text-slate-900 dark:text-slate-100">Базовый</span></>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
{guest.totalStays >= 10 ? '10+ визитов' : guest.totalStays >= 5 ? '5+ визитов' : 'До 5 визитов'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Оплата долгов</p>
|
||||||
|
{(() => {
|
||||||
|
const debt = guest.history.reduce((s, h) => s + (h.amount - h.paid), 0)
|
||||||
|
return debt > 0 ? (
|
||||||
|
<p className="text-sm font-semibold text-red-600 dark:text-red-400">{debt.toLocaleString('ru-RU')} ₽</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm font-semibold text-emerald-600 dark:text-emerald-400">Нет задолженности</p>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'history' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{guest.history.length === 0 && (
|
||||||
|
<p className="text-sm text-slate-400 text-center py-8">История проживания пуста</p>
|
||||||
|
)}
|
||||||
|
{guest.history.map(stay => (
|
||||||
|
<div
|
||||||
|
key={stay.id}
|
||||||
|
className="flex items-center justify-between p-4 rounded-xl border border-slate-200 dark:border-slate-700 hover:border-brand-300 dark:hover:border-brand-600 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center shrink-0">
|
||||||
|
<Calendar size={16} className="text-slate-500 dark:text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
|
||||||
|
Номер {stay.roomNumber} — {stay.roomType}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{formatDate(stay.checkIn)} → {formatDate(stay.checkOut)} · {nights(stay.checkIn, stay.checkOut)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||||
|
{stay.amount.toLocaleString('ru-RU')} ₽
|
||||||
|
</p>
|
||||||
|
<span className={cn('inline-block px-2 py-0.5 rounded-full text-xs font-medium', STAY_STATUS[stay.status].cls)}>
|
||||||
|
{STAY_STATUS[stay.status].label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'payments' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||||
|
{[
|
||||||
|
{ label: 'Всего начислено', value: guest.history.reduce((s, h) => s + h.amount, 0), color: 'text-slate-900 dark:text-slate-100' },
|
||||||
|
{ label: 'Оплачено', value: guest.history.reduce((s, h) => s + h.paid, 0), color: 'text-emerald-600 dark:text-emerald-400' },
|
||||||
|
{ label: 'Долг', value: guest.history.reduce((s, h) => s + (h.amount - h.paid), 0), color: 'text-red-600 dark:text-red-400' },
|
||||||
|
].map(item => (
|
||||||
|
<div key={item.label} className="rounded-xl border border-slate-200 dark:border-slate-700 p-3 text-center">
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">{item.label}</p>
|
||||||
|
<p className={cn('text-base font-bold mt-0.5', item.color)}>
|
||||||
|
{item.value.toLocaleString('ru-RU')} ₽
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Per-stay payments */}
|
||||||
|
{guest.history.map(stay => (
|
||||||
|
<div key={stay.id} className="flex items-center justify-between p-3 rounded-xl border border-slate-200 dark:border-slate-700">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CreditCard size={14} className="text-slate-400 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
{formatDate(stay.checkIn)} — {stay.roomType} №{stay.roomNumber}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-sm">
|
||||||
|
<span className="font-semibold text-slate-900 dark:text-slate-100">{stay.paid.toLocaleString('ru-RU')} ₽</span>
|
||||||
|
{stay.paid < stay.amount && (
|
||||||
|
<span className="ml-2 text-xs text-red-500">
|
||||||
|
долг {(stay.amount - stay.paid).toLocaleString('ru-RU')} ₽
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex justify-end gap-2 p-4 border-t border-slate-200 dark:border-slate-700">
|
||||||
|
<button onClick={onClose} className="btn-secondary">Закрыть</button>
|
||||||
|
<button className="btn-primary">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function GuestsPage() {
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [filterTag, setFilterTag] = useState<GuestTag | 'all'>('all')
|
||||||
|
const [selectedGuest, setSelectedGuest] = useState<Guest | null>(null)
|
||||||
|
|
||||||
|
const filtered = MOCK_GUESTS.filter(g => {
|
||||||
|
const q = search.toLowerCase()
|
||||||
|
const matchSearch = !q
|
||||||
|
|| g.name.toLowerCase().includes(q)
|
||||||
|
|| g.email.toLowerCase().includes(q)
|
||||||
|
|| g.phone.includes(q)
|
||||||
|
const matchTag = filterTag === 'all' || g.tags.includes(filterTag)
|
||||||
|
return matchSearch && matchTag
|
||||||
|
})
|
||||||
|
|
||||||
|
const vipCount = MOCK_GUESTS.filter(g => g.tags.includes('VIP')).length
|
||||||
|
const repeatCount = MOCK_GUESTS.filter(g => g.totalStays >= 3).length
|
||||||
|
const avgRating = (MOCK_GUESTS.reduce((s, g) => s + g.rating, 0) / MOCK_GUESTS.length).toFixed(1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
{[
|
||||||
|
{ icon: Users, label: 'Всего гостей', value: MOCK_GUESTS.length, color: 'text-brand-600 dark:text-brand-400', bg: 'bg-brand-50 dark:bg-brand-900/20' },
|
||||||
|
{ icon: Award, label: 'VIP гостей', value: vipCount, color: 'text-amber-600 dark:text-amber-400', bg: 'bg-amber-50 dark:bg-amber-900/20' },
|
||||||
|
{ icon: Repeat2, label: 'Постоянных', value: repeatCount, color: 'text-blue-600 dark:text-blue-400', bg: 'bg-blue-50 dark:bg-blue-900/20' },
|
||||||
|
{ icon: Star, label: 'Средний рейтинг',value: avgRating, color: 'text-emerald-600 dark:text-emerald-400', bg: 'bg-emerald-50 dark:bg-emerald-900/20' },
|
||||||
|
].map(s => (
|
||||||
|
<div key={s.label} className="card p-4 flex items-center gap-4">
|
||||||
|
<div className={cn('w-10 h-10 rounded-xl flex items-center justify-center shrink-0', s.bg)}>
|
||||||
|
<s.icon size={20} className={s.color} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">{s.label}</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{s.value}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search + Filters */}
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input pl-9"
|
||||||
|
placeholder="Поиск по имени, email, телефону..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{(['all', 'VIP', 'Постоянный', 'Корпоративный', 'Проблемный'] as const).map(tag => (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
onClick={() => setFilterTag(tag)}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors',
|
||||||
|
filterTag === tag
|
||||||
|
? '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 hover:border-brand-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tag === 'all' ? 'Все' : tag}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-700/50">
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">Гость</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">Контакты</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">Визиты</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">Потрачено</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">Рейтинг</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">Последний визит</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">Метки</th>
|
||||||
|
<th className="px-4 py-3" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100 dark:divide-slate-700/50">
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-4 py-10 text-center text-sm text-slate-400">
|
||||||
|
Гости не найдены
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{filtered.map(guest => (
|
||||||
|
<tr
|
||||||
|
key={guest.id}
|
||||||
|
className="hover:bg-slate-50 dark:hover:bg-slate-700/30 cursor-pointer transition-colors"
|
||||||
|
onClick={() => setSelectedGuest(guest)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center shrink-0">
|
||||||
|
<span className="text-sm font-semibold text-brand-600 dark:text-brand-400">
|
||||||
|
{guest.name.split(' ').map(p => p[0]).join('').slice(0, 2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">{guest.name}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{guest.phone && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
<Phone size={11} />{guest.phone}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{guest.email && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
<Mail size={11} />{guest.email}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<TrendingUp size={14} className="text-slate-400" />
|
||||||
|
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">{guest.totalStays}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">
|
||||||
|
{guest.totalSpent.toLocaleString('ru-RU')} ₽
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<StarRating value={guest.rating} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">{formatDate(guest.lastVisit)}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{guest.tags.map(tag => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className={cn('px-2 py-0.5 rounded-md text-xs font-medium border', TAG_META[tag].cls)}
|
||||||
|
>
|
||||||
|
{tag === 'VIP' && <span className="mr-0.5">★</span>}{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<ChevronRight size={16} className="text-slate-400" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 py-3 border-t border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-700/30">
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Показано {filtered.length} из {MOCK_GUESTS.length} гостей
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Future note */}
|
||||||
|
<div className="rounded-xl border border-dashed border-brand-300 dark:border-brand-700 bg-brand-50 dark:bg-brand-900/10 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Tag size={16} className="text-brand-600 dark:text-brand-400 mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-brand-700 dark:text-brand-400">Единая база гостей HotelSync</p>
|
||||||
|
<p className="text-xs text-brand-600 dark:text-brand-500 mt-0.5">
|
||||||
|
В будущих версиях рейтинг гостей будет доступен всем отелям сети — гость, уже имеющий историю в другом отеле,
|
||||||
|
автоматически подгрузит свой рейтинг и теги при бронировании.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Guest Detail Modal */}
|
||||||
|
{selectedGuest && (
|
||||||
|
<GuestModal guest={selectedGuest} onClose={() => setSelectedGuest(null)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user