Files
hotelsync/src/pages/GuestsPage.tsx
HotelSync 2fc66d1e84 Fix mobile responsiveness across all pages
- PosPage: rooms list becomes horizontal scroll row on mobile instead of w-56 sidebar
- AvailabilityPage: edit panel stacks below grid on mobile (was fixed w-80 side panel)
- UsersPage roles tab: sidebar stacks above permissions on mobile
- All page containers: p-6 → p-4 md:p-6 (TariffsPage, DiscountsPage, MaintenancePage, ReportsPage, DynamicPricingPage, GuestsPage, LoyaltyPage)
- Stats grids: lg:grid-cols-4 → md:grid-cols-4 (GuestsPage, TariffsPage, DiscountsPage, MaintenancePage, ReportsPage, LoyaltyPage)
- Modal form grids: grid-cols-3 → grid-cols-1 sm:grid-cols-3, grid-cols-5 → grid-cols-3 sm:grid-cols-5, grid-cols-4 → grid-cols-2 sm:grid-cols-4
- PosPage modals: fixed w-80/w-96 → responsive w-[calc(100vw-2rem)] sm:w-80/96

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 00:27:43 +03:00

763 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react'
import {
Search, Star, Phone, Mail, User, TrendingUp, Award, Users,
Calendar, CreditCard, X, ChevronRight, MessageSquare, Tag,
Repeat2, ShieldCheck, Plus,
} 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 // 15
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-2 sm: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-4 sm: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 ────────────────────────────────────────────────────────────────
// ─── Guest Statuses Tab ───────────────────────────────────────────────────────
function GuestStatusesTab() {
const [tags, setTags] = useState([
{ id: 'vip', label: 'VIP', color: '#F59E0B' },
{ id: 'regular', label: 'Постоянный гость', color: '#3B82F6' },
{ id: 'corp', label: 'Корпоративный', color: '#8B5CF6' },
{ id: 'honey', label: 'Медовый месяц', color: '#EC4899' },
{ id: 'bday', label: 'День рождения', color: '#10B981' },
{ id: 'special', label: 'Особые пожелания', color: '#64748B' },
{ id: 'problem', label: 'Проблемный', color: '#EF4444' },
])
const [newLabel, setNewLabel] = useState('')
const [newColor, setNewColor] = useState('#4F46E5')
const addTag = () => {
if (!newLabel.trim()) return
setTags(prev => [...prev, { id: `tag-${Date.now()}`, label: newLabel.trim(), color: newColor }])
setNewLabel('')
}
return (
<div className="space-y-4 max-w-lg">
<p className="text-sm text-slate-500 dark:text-slate-400">
Статусы отображаются в карточках гостей и при создании бронирования. Они помогают быстро идентифицировать тип гостя.
</p>
{/* Tag list */}
<div className="card overflow-hidden divide-y divide-slate-100 dark:divide-slate-700">
{tags.map(tag => (
<div key={tag.id} className="flex items-center gap-3 px-4 py-3">
<input
type="color"
value={tag.color}
onChange={e => setTags(prev => prev.map(t => t.id === tag.id ? { ...t, color: e.target.value } : t))}
className="w-7 h-7 rounded cursor-pointer border-0 bg-transparent shrink-0"
/>
<input
type="text"
className="input flex-1"
value={tag.label}
onChange={e => setTags(prev => prev.map(t => t.id === tag.id ? { ...t, label: e.target.value } : t))}
/>
<span
className="px-2.5 py-1 rounded-lg text-xs font-medium text-white shrink-0"
style={{ backgroundColor: tag.color }}
>
{tag.label}
</span>
<button
onClick={() => setTags(prev => prev.filter(t => t.id !== tag.id))}
className="p-1.5 rounded-lg text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors shrink-0"
>
<X size={14} />
</button>
</div>
))}
</div>
{/* Add new tag */}
<div className="flex items-center gap-2">
<input
type="color"
value={newColor}
onChange={e => setNewColor(e.target.value)}
className="w-7 h-7 rounded cursor-pointer border-0 bg-transparent shrink-0"
/>
<input
type="text"
className="input flex-1"
placeholder="Название нового статуса..."
value={newLabel}
onChange={e => setNewLabel(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') addTag() }}
/>
<button onClick={addTag} className="btn-primary shrink-0">
<Plus size={14} />
Добавить
</button>
</div>
</div>
)
}
// ─── Page ─────────────────────────────────────────────────────────────────────
const PAGE_TABS = [
{ id: 'list', label: 'База гостей' },
{ id: 'statuses', label: 'Статусы гостей' },
] as const
type PageTab = typeof PAGE_TABS[number]['id']
export function GuestsPage() {
const [pageTab, setPageTab] = useState<PageTab>('list')
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-4 md: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>
{/* Tabs */}
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-700 -mt-2">
{PAGE_TABS.map(t => (
<button
key={t.id}
onClick={() => setPageTab(t.id)}
className={cn(
'px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors',
pageTab === 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>
{pageTab === 'statuses' && <GuestStatusesTab />}
{pageTab === 'list' && <>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[
{ 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-3">
<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 hidden md:table-cell">Контакты</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide hidden sm:table-cell">Визиты</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide hidden lg:table-cell">Потрачено</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 hidden lg:table-cell">Последний визит</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide hidden sm:table-cell">Метки</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 hidden md:table-cell">
<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 hidden sm:table-cell">
<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 hidden lg:table-cell">
<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 hidden lg:table-cell">
<span className="text-sm text-slate-600 dark:text-slate-400">{formatDate(guest.lastVisit)}</span>
</td>
<td className="px-4 py-3 hidden sm:table-cell">
<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>
)
}