Files
hotelsync/src/pages/GuestsPage.tsx
HotelSync de4fb2126b Add Guests module: API integration + passport identification
- Migration 009: add passport_series, passport_number, rating to guests table
- Backend route /api/hotels/:slug/guests: list, get (with history), create/upsert by passport, update, delete
- Frontend GuestsPage: loads from API, passport tab in modal, add guest form, search by passport
- api.ts: add guests API methods and GuestApiType

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 14:03:04 +03:00

983 lines
48 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, useEffect, useCallback } from 'react'
import {
Search, Star, Phone, Mail, User, TrendingUp, Award, Users,
Calendar, CreditCard, X, ChevronRight, MessageSquare, Tag,
Repeat2, ShieldCheck, Plus, ChevronsUpDown, AlertCircle, Loader2,
} from 'lucide-react'
import { cn } from '../lib/utils'
import { api, type GuestApiType } from '../lib/api'
import { useAuth } from '../contexts/AuthContext'
// ─── Types ───────────────────────────────────────────────────────────────────
type Guest = GuestApiType
// ─── Helpers ──────────────────────────────────────────────────────────────────
const STAY_STATUS: Record<string, { label: string; cls: string }> = {
completed: { label: 'Завершено', cls: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' },
checked_out: { label: 'Завершено', cls: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' },
checked_in: { label: 'Проживает', cls: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' },
confirmed: { label: 'Подтверждено', cls: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300' },
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_CLS: Record<string, string> = {
VIP: 'bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-900/30 dark:text-amber-400',
Постоянный: 'bg-brand-100 text-brand-700 border-brand-200 dark:bg-brand-900/30 dark:text-brand-400',
Корпоративный: 'bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400',
'Медовый месяц': 'bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-900/30 dark:text-pink-400',
Проблемный: 'bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-400',
}
const ALL_TAGS = Object.keys(TAG_CLS)
const GENDER_OPTIONS = [
{ value: 'male', label: 'Мужской' },
{ value: 'female', label: 'Женский' },
{ value: 'other', label: 'Другой' },
]
function guestName(g: Guest) {
return [g.firstName, g.lastName].filter(Boolean).join(' ') || '—'
}
function guestInitials(g: Guest) {
return `${g.firstName?.[0] ?? ''}${g.lastName?.[0] ?? ''}`.toUpperCase()
}
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 | null | undefined) {
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 ───────────────────────────────────────────────────────
interface GuestModalProps {
guestId: string
slug: string
onClose: () => void
onUpdated: (g: Guest) => void
}
function GuestModal({ guestId, slug, onClose, onUpdated }: GuestModalProps) {
const [tab, setTab] = useState<'overview' | 'history' | 'payments' | 'passport'>('overview')
const [guest, setGuest] = useState<Guest | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
// Edit state
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [email, setEmail] = useState('')
const [phone, setPhone] = useState('')
const [rating, setRating] = useState(3)
const [notes, setNotes] = useState('')
const [tags, setTags] = useState<string[]>([])
// Passport
const [passportSeries, setPassportSeries] = useState('')
const [passportNumber, setPassportNumber] = useState('')
const [birthDate, setBirthDate] = useState('')
const [nationality, setNationality] = useState('')
const [gender, setGender] = useState('')
const [city, setCity] = useState('')
useEffect(() => {
setLoading(true)
api.guests.get(slug, guestId)
.then(g => {
setGuest(g)
setFirstName(g.firstName ?? '')
setLastName(g.lastName ?? '')
setEmail(g.email ?? '')
setPhone(g.phone ?? '')
setRating(g.rating)
setNotes(g.notes ?? '')
setTags(g.tags ?? [])
setPassportSeries(g.passportSeries ?? '')
setPassportNumber(g.passportNumber ?? '')
setBirthDate(g.birthDate?.slice(0, 10) ?? '')
setNationality(g.nationality ?? '')
setGender(g.gender ?? '')
setCity(g.city ?? '')
})
.catch(() => setError('Не удалось загрузить данные гостя'))
.finally(() => setLoading(false))
}, [slug, guestId])
const handleSave = async () => {
if (!guest) return
setSaving(true)
try {
const updated = await api.guests.update(slug, guest.id, {
first_name: firstName,
last_name: lastName,
email: email || undefined,
phone: phone || undefined,
rating,
notes,
tags,
passport_series: passportSeries || undefined,
passport_number: passportNumber || undefined,
birth_date: birthDate || undefined,
nationality: nationality || undefined,
gender: gender || undefined,
city: city || undefined,
})
onUpdated(updated)
onClose()
} catch {
setError('Ошибка при сохранении')
} finally {
setSaving(false)
}
}
const toggleTag = (tag: string) =>
setTags(prev => prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag])
const history = guest?.history ?? []
const completedStays = history.filter(s => s.status === 'checked_out' || s.status === 'completed')
const totalPaid = history.reduce((s, h) => s + (h.paidAmount ?? 0), 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">
{loading ? (
<div className="flex items-center gap-3">
<div className="w-14 h-14 rounded-full bg-slate-100 dark:bg-slate-700 animate-pulse" />
<div className="space-y-2">
<div className="w-40 h-4 rounded bg-slate-200 dark:bg-slate-600 animate-pulse" />
<div className="w-24 h-3 rounded bg-slate-100 dark:bg-slate-700 animate-pulse" />
</div>
</div>
) : guest ? (
<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">
{guestInitials(guest)}
</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">
{[firstName, lastName].filter(Boolean).join(' ')}
</h2>
{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">
{phone && (
<span className="flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400">
<Phone size={11} />{phone}
</span>
)}
{email && (
<span className="flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400">
<Mail size={11} />{email}
</span>
)}
</div>
</div>
</div>
) : null}
<button onClick={onClose} className="btn-ghost p-1.5 -mr-1 -mt-1">
<X size={18} />
</button>
</div>
{error && (
<div className="mx-6 mt-3 flex items-center gap-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 px-4 py-2 text-sm text-red-700 dark:text-red-400">
<AlertCircle size={14} />{error}
</div>
)}
{/* Stats strip */}
{!loading && guest && (
<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: `${Number(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 overflow-x-auto">
{([
{ id: 'overview' as const, label: 'Обзор' },
{ id: 'history' as const, label: 'История' },
{ id: 'payments' as const, label: 'Платежи' },
{ id: 'passport' 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 whitespace-nowrap',
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">
{loading && (
<div className="flex items-center justify-center py-16 text-slate-400">
<Loader2 size={24} className="animate-spin" />
</div>
)}
{!loading && tab === 'overview' && (
<div className="space-y-5">
{/* Basic info */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Имя</label>
<input className="input" value={firstName} onChange={e => setFirstName(e.target.value)} placeholder="Имя" />
</div>
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Фамилия</label>
<input className="input" value={lastName} onChange={e => setLastName(e.target.value)} placeholder="Фамилия" />
</div>
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Телефон</label>
<input className="input" value={phone} onChange={e => setPhone(e.target.value)} placeholder="+7 900 000-00-00" />
</div>
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Email</label>
<input className="input" type="email" value={email} onChange={e => setEmail(e.target.value)} placeholder="email@example.com" />
</div>
</div>
{/* 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">
{ALL_TAGS.map(tag => (
<button
key={tag}
onClick={() => toggleTag(tag)}
className={cn(
'px-2.5 py-1 rounded-lg text-xs font-medium border transition-colors',
tags.includes(tag)
? TAG_CLS[tag] ?? 'bg-brand-100 text-brand-700 border-brand-200'
: 'bg-white dark:bg-slate-700 text-slate-500 dark:text-slate-400 border-slate-200 dark:border-slate-600',
)}
>
{tag}
</button>
))}
</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={notes}
onChange={e => setNotes(e.target.value)}
/>
</div>
{/* Loyalty status */}
{guest && (
<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 = history.reduce((s, h) => s + ((h.totalAmount ?? 0) - (h.paidAmount ?? 0)), 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>
)}
{!loading && tab === 'history' && (
<div className="space-y-3">
{history.length === 0 && (
<p className="text-sm text-slate-400 text-center py-8">История проживания пуста</p>
)}
{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"
>
<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.roomNumber}` : 'Номер —'}
{stay.roomType ? `${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.totalAmount ?? 0).toLocaleString('ru-RU')}
</p>
<span className={cn(
'inline-block px-2 py-0.5 rounded-full text-xs font-medium',
(STAY_STATUS[stay.status] ?? STAY_STATUS.confirmed).cls,
)}>
{(STAY_STATUS[stay.status] ?? STAY_STATUS.confirmed).label}
</span>
</div>
</div>
))}
</div>
)}
{!loading && tab === 'payments' && (
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3 mb-4">
{[
{ label: 'Всего начислено', value: history.reduce((s, h) => s + (h.totalAmount ?? 0), 0), color: 'text-slate-900 dark:text-slate-100' },
{ label: 'Оплачено', value: history.reduce((s, h) => s + (h.paidAmount ?? 0), 0), color: 'text-emerald-600 dark:text-emerald-400' },
{ label: 'Долг', value: history.reduce((s, h) => s + ((h.totalAmount ?? 0) - (h.paidAmount ?? 0)), 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>
{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" />
<p className="text-sm text-slate-700 dark:text-slate-300">
{formatDate(stay.checkIn)} {stay.roomType ?? '—'} {stay.roomNumber ? `${stay.roomNumber}` : ''}
</p>
</div>
<div className="text-right text-sm">
<span className="font-semibold text-slate-900 dark:text-slate-100">
{(stay.paidAmount ?? 0).toLocaleString('ru-RU')}
</span>
{(stay.paidAmount ?? 0) < (stay.totalAmount ?? 0) && (
<span className="ml-2 text-xs text-red-500">
долг {((stay.totalAmount ?? 0) - (stay.paidAmount ?? 0)).toLocaleString('ru-RU')}
</span>
)}
</div>
</div>
))}
</div>
)}
{!loading && tab === 'passport' && (
<div className="space-y-4">
<p className="text-xs text-slate-500 dark:text-slate-400">
Паспортные данные используются для идентификации гостя в системе. Серия и номер паспорта уникальный ключ.
</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Серия паспорта</label>
<input
className="input font-mono uppercase"
value={passportSeries}
onChange={e => setPassportSeries(e.target.value.toUpperCase())}
placeholder="1234"
maxLength={10}
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Номер паспорта</label>
<input
className="input font-mono"
value={passportNumber}
onChange={e => setPassportNumber(e.target.value)}
placeholder="567890"
maxLength={20}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Дата рождения</label>
<input
className="input"
type="date"
value={birthDate}
onChange={e => setBirthDate(e.target.value)}
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Пол</label>
<select className="input" value={gender} onChange={e => setGender(e.target.value)}>
<option value="">Не указан</option>
{GENDER_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Гражданство</label>
<input
className="input"
value={nationality}
onChange={e => setNationality(e.target.value)}
placeholder="Россия"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Город</label>
<input
className="input"
value={city}
onChange={e => setCity(e.target.value)}
placeholder="Москва"
/>
</div>
</div>
{passportSeries && passportNumber && (
<div className="rounded-xl border border-emerald-200 dark:border-emerald-800 bg-emerald-50 dark:bg-emerald-900/10 p-3">
<p className="text-xs text-emerald-700 dark:text-emerald-400 font-medium">
Уникальный ключ: {passportSeries} {passportNumber}
</p>
<p className="text-xs text-emerald-600 dark:text-emerald-500 mt-0.5">
Гость будет идентифицирован по этим данным при следующем бронировании
</p>
</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 onClick={handleSave} disabled={loading || saving} className="btn-primary">
{saving ? <Loader2 size={14} className="animate-spin" /> : null}
Сохранить
</button>
</div>
</div>
</div>
)
}
// ─── Add Guest Modal ──────────────────────────────────────────────────────────
interface AddGuestModalProps {
slug: string
onClose: () => void
onCreated: (g: Guest) => void
}
function AddGuestModal({ slug, onClose, onCreated }: AddGuestModalProps) {
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [phone, setPhone] = useState('')
const [email, setEmail] = useState('')
const [passportSeries, setPassportSeries] = useState('')
const [passportNumber, setPassportNumber] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleCreate = async () => {
if (!firstName.trim() || !lastName.trim()) {
setError('Имя и фамилия обязательны')
return
}
setSaving(true)
setError(null)
try {
const g = await api.guests.create(slug, {
first_name: firstName.trim(),
last_name: lastName.trim(),
phone: phone || undefined,
email: email || undefined,
passport_series: passportSeries || undefined,
passport_number: passportNumber || undefined,
})
onCreated(g)
onClose()
} catch (err: unknown) {
setError((err as Error).message || 'Ошибка при создании гостя')
} finally {
setSaving(false)
}
}
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-md">
<div className="flex items-center justify-between p-6 border-b border-slate-200 dark:border-slate-700">
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">Добавить гостя</h2>
<button onClick={onClose} className="btn-ghost p-1.5"><X size={18} /></button>
</div>
<div className="p-6 space-y-4">
{error && (
<div className="flex items-center gap-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 px-3 py-2 text-sm text-red-700 dark:text-red-400">
<AlertCircle size={14} />{error}
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Имя *</label>
<input className="input" value={firstName} onChange={e => setFirstName(e.target.value)} placeholder="Иван" />
</div>
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Фамилия *</label>
<input className="input" value={lastName} onChange={e => setLastName(e.target.value)} placeholder="Петров" />
</div>
</div>
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Телефон</label>
<input className="input" value={phone} onChange={e => setPhone(e.target.value)} placeholder="+7 900 000-00-00" />
</div>
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Email</label>
<input className="input" type="email" value={email} onChange={e => setEmail(e.target.value)} placeholder="email@example.com" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Серия паспорта</label>
<input className="input font-mono uppercase" value={passportSeries} onChange={e => setPassportSeries(e.target.value.toUpperCase())} placeholder="1234" />
</div>
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wide mb-1">Номер паспорта</label>
<input className="input font-mono" value={passportNumber} onChange={e => setPassportNumber(e.target.value)} placeholder="567890" />
</div>
</div>
</div>
<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 onClick={handleCreate} disabled={saving} className="btn-primary">
{saving ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />}
Добавить
</button>
</div>
</div>
</div>
)
}
// ─── 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>
<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>
<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']
type FilterTag = typeof ALL_TAGS[number] | 'all'
export function GuestsPage() {
const { user } = useAuth()
const slug = user?.hotelSlug ?? ''
const [pageTab, setPageTab] = useState<PageTab>('list')
const [guests, setGuests] = useState<Guest[]>([])
const [loading, setLoading] = useState(false)
const [search, setSearch] = useState('')
const [filterTag, setFilterTag] = useState<FilterTag>('all')
const [selectedId, setSelectedId] = useState<string | null>(null)
const [showAdd, setShowAdd] = useState(false)
const loadGuests = useCallback(async () => {
if (!slug) return
setLoading(true)
try {
const data = await api.guests.list(slug)
setGuests(data)
} catch {
// silently fail on list
} finally {
setLoading(false)
}
}, [slug])
useEffect(() => { loadGuests() }, [loadGuests])
const filtered = guests.filter(g => {
const q = search.toLowerCase()
const name = guestName(g).toLowerCase()
const matchSearch = !q
|| name.includes(q)
|| (g.email ?? '').toLowerCase().includes(q)
|| (g.phone ?? '').includes(q)
|| (g.passportSeries ?? '').toLowerCase().includes(q)
|| (g.passportNumber ?? '').toLowerCase().includes(q)
const matchTag = filterTag === 'all' || g.tags.includes(filterTag)
return matchSearch && matchTag
})
const vipCount = guests.filter(g => g.tags.includes('VIP')).length
const repeatCount = guests.filter(g => g.totalStays >= 3).length
const avgRating = guests.length
? (guests.reduce((s, g) => s + g.rating, 0) / guests.length).toFixed(1)
: '—'
return (
<div className="p-4 md:p-6 space-y-6">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<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>
<button onClick={() => setShowAdd(true)} className="btn-primary shrink-0">
<Plus size={16} />Добавить гостя
</button>
</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: 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="Поиск по имени, телефону, паспорту..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<div className="flex gap-2 flex-wrap">
{(['all', ...ALL_TAGS.slice(0, 4)] 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">
{loading && (
<tr>
<td colSpan={8} className="px-4 py-10 text-center text-sm text-slate-400">
<Loader2 size={20} className="animate-spin mx-auto" />
</td>
</tr>
)}
{!loading && filtered.length === 0 && (
<tr>
<td colSpan={8} className="px-4 py-10 text-center text-sm text-slate-400">
{guests.length === 0 ? 'Гостей пока нет' : 'Гости не найдены'}
</td>
</tr>
)}
{!loading && filtered.map(guest => (
<tr
key={guest.id}
className="hover:bg-slate-50 dark:hover:bg-slate-700/30 cursor-pointer transition-colors"
onClick={() => setSelectedId(guest.id)}
>
<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">
{guestInitials(guest)}
</span>
</div>
<div>
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">{guestName(guest)}</span>
{(guest.passportSeries || guest.passportNumber) && (
<p className="text-xs text-slate-400 font-mono">
{[guest.passportSeries, guest.passportNumber].filter(Boolean).join(' ')}
</p>
)}
</div>
</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">
{Number(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_CLS[tag] ?? 'bg-slate-100 text-slate-700 border-slate-200 dark:bg-slate-700 dark:text-slate-300 dark:border-slate-600')}
>
{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} из {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 */}
{selectedId && (
<GuestModal
key={selectedId}
guestId={selectedId}
slug={slug}
onClose={() => setSelectedId(null)}
onUpdated={updated => {
setGuests(prev => prev.map(g => g.id === updated.id ? { ...g, ...updated } : g))
}}
/>
)}
{/* Add Guest Modal */}
{showAdd && (
<AddGuestModal
slug={slug}
onClose={() => setShowAdd(false)}
onCreated={g => setGuests(prev => [g, ...prev])}
/>
)}
</div>
)
}