diff --git a/src/App.tsx b/src/App.tsx index 4160ac8..bdec87e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,6 +19,10 @@ import { AvailabilityPage } from './pages/AvailabilityPage' import { FloorMapPage } from './pages/FloorMapPage' import { AdminDashboard } from './pages/AdminDashboard' import { MigrationPage } from './pages/MigrationPage' +import { PosPage } from './pages/PosPage' +import { ReviewsPage } from './pages/ReviewsPage' +import { RoomServicePage } from './pages/RoomServicePage' +import { RentalPage } from './pages/RentalPage' export default function App() { return ( @@ -47,6 +51,10 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> + } /> {/* Admin routes */} diff --git a/src/pages/BookingsPage.tsx b/src/pages/BookingsPage.tsx index cfa87ca..55e2e96 100644 --- a/src/pages/BookingsPage.tsx +++ b/src/pages/BookingsPage.tsx @@ -5,6 +5,7 @@ import { RENTAL_OBJECTS, MOCK_RENTAL_BOOKINGS } from '../data/rentalData' import { useModules } from '../contexts/ModulesContext' import type { Booking, BookingStatus } from '../types' import type { RentalBooking } from '../data/rentalData' +import { RentalBookingModal } from '../components/rental/RentalBookingModal' import { cn, BOOKING_STATUS_BADGE, BOOKING_STATUS_LABELS, SOURCE_COLORS, SOURCE_LABELS, formatCurrency, nightsCount } from '../lib/utils' import { Badge } from '../components/ui/Badge' import { BookingModal } from '../components/bookings/BookingModal' @@ -41,7 +42,11 @@ export function BookingsPage() { const [tab, setTab] = useState('rooms') const [bookings, setBookings] = useState(MOCK_BOOKINGS) - const [rentalBookings] = useState(MOCK_RENTAL_BOOKINGS) + const [rentalBookings, setRentalBookings] = useState(MOCK_RENTAL_BOOKINGS) + const [newRentalStep, setNewRentalStep] = useState<'idle' | 'pick'>('idle') + const [rentalPickObj, setRentalPickObj] = useState(RENTAL_OBJECTS[0]?.id ?? '') + const [rentalPickDate, setRentalPickDate] = useState(format(new Date(), 'yyyy-MM-dd')) + const [showRentalModal, setShowRentalModal] = useState<{ obj: typeof RENTAL_OBJECTS[0]; date: string } | null>(null) const [search, setSearch] = useState('') const [statusFilter, setStatusFilter] = useState('all') const [selected, setSelected] = useState(null) @@ -103,6 +108,12 @@ export function BookingsPage() { Новое )} + {tab === 'rental' && ( + + )} {/* Tabs */} @@ -359,6 +370,57 @@ export function BookingsPage() { }} /> )} + + {/* New rental — step 1: pick object + date */} + {newRentalStep === 'pick' && ( +
+
+

Новая аренда

+
+ + +
+
+ + setRentalPickDate(e.target.value)} + /> +
+
+ + +
+
+
+ )} + + {/* New rental — step 2: booking form */} + {showRentalModal && ( + b.objectId === showRentalModal.obj.id && b.date === showRentalModal.date)} + onClose={() => setShowRentalModal(null)} + onSave={(b) => { + setRentalBookings(prev => [...prev, b]) + setShowRentalModal(null) + }} + /> + )} ) } diff --git a/src/pages/PosPage.tsx b/src/pages/PosPage.tsx new file mode 100644 index 0000000..1b0e359 --- /dev/null +++ b/src/pages/PosPage.tsx @@ -0,0 +1,414 @@ +import { useState, useEffect, useRef } from 'react' +import { + ShoppingCart, Plus, Minus, Trash2, CreditCard, Banknote, X, + Clock, TrendingUp, Receipt, LogIn, LogOut, ChevronRight, CheckCircle2, +} from 'lucide-react' +import { format } from 'date-fns' +import { ru } from 'date-fns/locale' +import { cn, formatCurrency } from '../lib/utils' + +// ── Types ───────────────────────────────────────────────────────────────────── + +interface PosProduct { + id: string + name: string + price: number + category: string + emoji: string +} + +interface CartItem { + product: PosProduct + qty: number +} + +interface PosReceipt { + id: string + items: CartItem[] + total: number + method: 'cash' | 'terminal' + createdAt: Date + shiftId: string +} + +// ── Mock data ───────────────────────────────────────────────────────────────── + +const CATEGORIES = ['Еда', 'Напитки', 'Услуги', 'Прочее'] + +const PRODUCTS: PosProduct[] = [ + // Еда + { id: 'p1', name: 'Завтрак', price: 650, category: 'Еда', emoji: '🍳' }, + { id: 'p2', name: 'Обед', price: 950, category: 'Еда', emoji: '🍽️' }, + { id: 'p3', name: 'Ужин', price: 1100, category: 'Еда', emoji: '🍷' }, + { id: 'p4', name: 'Бизнес-ланч', price: 750, category: 'Еда', emoji: '🥗' }, + // Напитки + { id: 'p5', name: 'Вода 0.5л', price: 120, category: 'Напитки', emoji: '💧' }, + { id: 'p6', name: 'Кофе эспрессо', price: 180, category: 'Напитки', emoji: '☕' }, + { id: 'p7', name: 'Чай', price: 150, category: 'Напитки', emoji: '🍵' }, + { id: 'p8', name: 'Сок апельсиновый',price: 250, category: 'Напитки', emoji: '🍊' }, + { id: 'p9', name: 'Мини-бар пиво', price: 350, category: 'Напитки', emoji: '🍺' }, + // Услуги + { id: 'p10', name: 'Трансфер в/из аэропорта', price: 2500, category: 'Услуги', emoji: '🚖' }, + { id: 'p11', name: 'Прачечная', price: 800, category: 'Услуги', emoji: '👕' }, + { id: 'p12', name: 'Экскурсия', price: 3500, category: 'Услуги', emoji: '🗺️' }, + { id: 'p13', name: 'Парковка/день', price: 500, category: 'Услуги', emoji: '🅿️' }, + { id: 'p14', name: 'Аренда велосипеда',price: 400, category: 'Услуги', emoji: '🚲' }, + // Прочее + { id: 'p15', name: 'Зубная щётка', price: 80, category: 'Прочее', emoji: '🪥' }, + { id: 'p16', name: 'Тапочки', price: 150, category: 'Прочее', emoji: '🩴' }, + { id: 'p17', name: 'Зарядное USB', price: 200, category: 'Прочее', emoji: '🔌' }, + { id: 'p18', name: 'Сувенир', price: 450, category: 'Прочее', emoji: '🎁' }, +] + +const SEED_RECEIPTS: PosReceipt[] = [ + { + id: 'rc1', + items: [ + { product: PRODUCTS[0], qty: 2 }, + { product: PRODUCTS[5], qty: 2 }, + ], + total: 1660, + method: 'terminal', + createdAt: new Date(Date.now() - 3600000 * 2), + shiftId: 'shift-today', + }, + { + id: 'rc2', + items: [{ product: PRODUCTS[9], qty: 1 }], + total: 2500, + method: 'cash', + createdAt: new Date(Date.now() - 3600000), + shiftId: 'shift-today', + }, + { + id: 'rc3', + items: [ + { product: PRODUCTS[1], qty: 1 }, + { product: PRODUCTS[4], qty: 2 }, + ], + total: 1190, + method: 'terminal', + createdAt: new Date(Date.now() - 1800000), + shiftId: 'shift-today', + }, +] + +// ── Component ───────────────────────────────────────────────────────────────── + +export function PosPage() { + const [shiftOpen, setShiftOpen] = useState(true) + const [shiftStartTime] = useState(new Date(Date.now() - 3600000 * 3)) + const [shiftTimer, setShiftTimer] = useState('') + const [category, setCategory] = useState('Еда') + const [cart, setCart] = useState([]) + const [receipts, setReceipts] = useState(SEED_RECEIPTS) + const [payMethod, setPayMethod] = useState<'cash' | 'terminal'>('terminal') + const [lastReceipt, setLastReceipt] = useState(null) + const shiftId = 'shift-today' + + // Shift timer + useEffect(() => { + if (!shiftOpen) return + const tick = () => { + const diff = Math.floor((Date.now() - shiftStartTime.getTime()) / 1000) + const h = Math.floor(diff / 3600) + const m = Math.floor((diff % 3600) / 60) + const s = diff % 60 + setShiftTimer(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`) + } + tick() + const id = setInterval(tick, 1000) + return () => clearInterval(id) + }, [shiftOpen, shiftStartTime]) + + const cartTotal = cart.reduce((s, i) => s + i.product.price * i.qty, 0) + const shiftReceipts = receipts.filter(r => r.shiftId === shiftId) + const shiftRevenue = shiftReceipts.reduce((s, r) => s + r.total, 0) + const cashRevenue = shiftReceipts.filter(r => r.method === 'cash').reduce((s, r) => s + r.total, 0) + const termRevenue = shiftReceipts.filter(r => r.method === 'terminal').reduce((s, r) => s + r.total, 0) + + const addToCart = (p: PosProduct) => { + setCart(prev => { + const ex = prev.find(i => i.product.id === p.id) + if (ex) return prev.map(i => i.product.id === p.id ? { ...i, qty: i.qty + 1 } : i) + return [...prev, { product: p, qty: 1 }] + }) + } + + const changeQty = (id: string, delta: number) => { + setCart(prev => + prev.map(i => i.product.id === id ? { ...i, qty: Math.max(1, i.qty + delta) } : i), + ) + } + + const removeItem = (id: string) => setCart(prev => prev.filter(i => i.product.id !== id)) + + const handlePay = () => { + if (cart.length === 0 || !shiftOpen) return + const receipt: PosReceipt = { + id: `rc-${Date.now()}`, + items: cart, + total: cartTotal, + method: payMethod, + createdAt: new Date(), + shiftId, + } + setReceipts(prev => [receipt, ...prev]) + setLastReceipt(receipt) + setCart([]) + } + + const filteredProducts = PRODUCTS.filter(p => p.category === category) + + return ( +
+ {/* Top bar */} +
+
+
+

Касса

+

+ {format(new Date(), 'd MMMM yyyy', { locale: ru })} · Елена Смирнова +

+
+ +
+ {/* Shift timer */} + {shiftOpen && ( +
+ + + {shiftTimer} + +
+ )} + +
+
+ + {/* Shift stats */} + {shiftOpen && ( +
+ {[ + { label: 'Выручка за смену', value: formatCurrency(shiftRevenue), color: 'text-slate-900 dark:text-slate-100' }, + { label: 'Наличные', value: formatCurrency(cashRevenue), color: 'text-emerald-600 dark:text-emerald-400' }, + { label: 'Терминал', value: formatCurrency(termRevenue), color: 'text-blue-600 dark:text-blue-400' }, + { label: 'Чеков', value: shiftReceipts.length, color: 'text-slate-900 dark:text-slate-100' }, + ].map(s => ( +
+

{s.value}

+

{s.label}

+
+ ))} +
+ )} +
+ + {!shiftOpen ? ( +
+ +
+

Смена закрыта

+

Откройте смену для начала работы

+
+ +
+ ) : ( +
+ {/* Left — product catalog */} +
+ {/* Category tabs */} +
+ {CATEGORIES.map(cat => ( + + ))} +
+ + {/* Products grid */} +
+
+ {filteredProducts.map(p => ( + + ))} +
+
+ + {/* Recent receipts */} +
+
+

Последние чеки

+ {shiftReceipts.length} за смену +
+
+
+ {shiftReceipts.slice(0, 6).map(r => ( +
+
+ {format(r.createdAt, 'HH:mm')} + {r.method === 'cash' ? : } +
+

{formatCurrency(r.total)}

+

{r.items.length} поз.

+
+ ))} +
+
+
+
+ + {/* Right — cart */} +
+
+

+ + Текущий чек +

+ {cart.length > 0 && ( + + )} +
+ + {/* Cart items */} +
+ {cart.length === 0 ? ( +
+ +

Добавьте товары из каталога

+
+ ) : ( + cart.map(item => ( +
+ {item.product.emoji} +
+

{item.product.name}

+

{formatCurrency(item.product.price * item.qty)}

+
+
+ + {item.qty} + + +
+
+ )) + )} +
+ + {/* Payment */} + {cart.length > 0 && ( +
+
+ Итого + {formatCurrency(cartTotal)} +
+
+ + +
+ +
+ )} +
+
+ )} + + {/* Receipt success overlay */} + {lastReceipt && ( +
+
+ +
+

Оплата принята

+

{formatCurrency(lastReceipt.total)}

+

+ {lastReceipt.method === 'cash' ? 'Наличными' : 'Терминал'} · {format(lastReceipt.createdAt, 'HH:mm')} +

+
+
+ {lastReceipt.items.map(i => ( +
+ {i.product.emoji} {i.product.name} ×{i.qty} + {formatCurrency(i.product.price * i.qty)} +
+ ))} +
+ +
+
+ )} +
+ ) +} diff --git a/src/pages/RentalPage.tsx b/src/pages/RentalPage.tsx new file mode 100644 index 0000000..cb951ca --- /dev/null +++ b/src/pages/RentalPage.tsx @@ -0,0 +1,452 @@ +import { useState } from 'react' +import { Plus, Pencil, Trash2, Clock, CalendarDays, X, Save } from 'lucide-react' +import { format } from 'date-fns' +import { ru } from 'date-fns/locale' +import { cn, formatCurrency } from '../lib/utils' +import { Badge } from '../components/ui/Badge' +import { RENTAL_OBJECTS, MOCK_RENTAL_BOOKINGS } from '../data/rentalData' +import type { RentalObject, RentalBooking } from '../data/rentalData' +import { RentalBookingModal } from '../components/rental/RentalBookingModal' + +// ── Object form ─────────────────────────────────────────────────────────────── + +const ICON_OPTIONS = ['🎾', '🛁', '🏛️', '⛳', '🏊', '🎱', '🎮', '🏋️', '🎤', '🏕️', '🚲', '🛶'] +const COLOR_OPTIONS = [ + { value: 'bg-green-500', label: 'Зелёный' }, + { value: 'bg-orange-500', label: 'Оранжевый' }, + { value: 'bg-violet-500', label: 'Фиолетовый' }, + { value: 'bg-blue-500', label: 'Синий' }, + { value: 'bg-red-500', label: 'Красный' }, + { value: 'bg-teal-500', label: 'Бирюзовый' }, + { value: 'bg-amber-500', label: 'Жёлтый' }, + { value: 'bg-pink-500', label: 'Розовый' }, +] +const TEXT_COLORS: Record = { + 'bg-green-500': 'text-green-700 dark:text-green-400', + 'bg-orange-500': 'text-orange-700 dark:text-orange-400', + 'bg-violet-500': 'text-violet-700 dark:text-violet-400', + 'bg-blue-500': 'text-blue-700 dark:text-blue-400', + 'bg-red-500': 'text-red-700 dark:text-red-400', + 'bg-teal-500': 'text-teal-700 dark:text-teal-400', + 'bg-amber-500': 'text-amber-700 dark:text-amber-400', + 'bg-pink-500': 'text-pink-700 dark:text-pink-400', +} + +interface ObjectFormData { + name: string + icon: string + color: string + pricePerHour: number + pricePerDay: number + openHour: number + closeHour: number + maxHoursPerSlot: number | '' + description: string +} + +const EMPTY_FORM: ObjectFormData = { + name: '', icon: '🎾', color: 'bg-green-500', + pricePerHour: 1000, pricePerDay: 5000, + openHour: 9, closeHour: 21, maxHoursPerSlot: '', + description: '', +} + +function ObjectFormModal({ + initial, onSave, onClose, +}: { + initial?: RentalObject + onSave: (obj: RentalObject) => void + onClose: () => void +}) { + const [form, setForm] = useState( + initial + ? { + name: initial.name, icon: initial.icon, color: initial.color, + pricePerHour: initial.pricePerHour, pricePerDay: initial.pricePerDay, + openHour: initial.openHour, closeHour: initial.closeHour, + maxHoursPerSlot: initial.maxHoursPerSlot ?? '', + description: '', + } + : EMPTY_FORM, + ) + + const set = (k: K, v: ObjectFormData[K]) => + setForm(prev => ({ ...prev, [k]: v })) + + const handleSave = () => { + if (!form.name.trim()) return + onSave({ + id: initial?.id ?? `obj-${Date.now()}`, + name: form.name.trim(), + icon: form.icon, + color: form.color, + textColor: TEXT_COLORS[form.color] ?? 'text-slate-700', + pricePerHour: form.pricePerHour, + pricePerDay: form.pricePerDay, + openHour: form.openHour, + closeHour: form.closeHour, + maxHoursPerSlot: form.maxHoursPerSlot !== '' ? Number(form.maxHoursPerSlot) : undefined, + }) + } + + return ( +
+
+
+

+ {initial ? 'Редактировать объект' : 'Новый объект аренды'} +

+ +
+ +
+ {/* Name */} +
+ + set('name', e.target.value)} + /> +
+ + {/* Icon + Color */} +
+
+ +
+ {ICON_OPTIONS.map(ico => ( + + ))} +
+
+
+ +
+ {COLOR_OPTIONS.map(c => ( +
+
+
+ + {/* Prices */} +
+
+ + set('pricePerHour', parseInt(e.target.value) || 0)} + /> +
+
+ + set('pricePerDay', parseInt(e.target.value) || 0)} + /> +
+
+ + {/* Hours */} +
+
+ + +
+
+ + +
+
+ + set('maxHoursPerSlot', e.target.value === '' ? '' : parseInt(e.target.value))} + /> +
+
+ + {/* Preview */} +
+
+ {form.icon} +
+
+

{form.name || 'Название объекта'}

+

+ {formatCurrency(form.pricePerHour)}/ч · {formatCurrency(form.pricePerDay)}/день · {String(form.openHour).padStart(2, '0')}:00–{String(form.closeHour).padStart(2, '0')}:00 +

+
+
+
+ +
+ + +
+
+
+ ) +} + +// ── Main page ───────────────────────────────────────────────────────────────── + +export function RentalPage() { + const [objects, setObjects] = useState(RENTAL_OBJECTS) + const [bookings, setBookings] = useState(MOCK_RENTAL_BOOKINGS) + const [editObj, setEditObj] = useState(null) + const [createModal, setCreateModal] = useState(false) + const [selectedObj, setSelectedObj] = useState(null) + const [rentalModal, setRentalModal] = useState<{ obj: RentalObject; date: string } | null>(null) + + const today = format(new Date(), 'yyyy-MM-dd') + + const handleSaveObject = (obj: RentalObject) => { + setObjects(prev => { + const exists = prev.find(o => o.id === obj.id) + return exists ? prev.map(o => o.id === obj.id ? obj : o) : [...prev, obj] + }) + setEditObj(null) + setCreateModal(false) + } + + const handleDeleteObject = (id: string) => { + setObjects(prev => prev.filter(o => o.id !== id)) + if (selectedObj?.id === id) setSelectedObj(null) + } + + const handleBookingCreate = (b: RentalBooking) => { + setBookings(prev => [...prev, b]) + setRentalModal(null) + } + + const totalRevenue = bookings + .filter(b => b.status === 'confirmed') + .reduce((s, b) => s + b.totalAmount, 0) + + return ( +
+ {/* Header */} +
+
+

Аренда объектов

+

{objects.length} объектов · управление и расписание

+
+ +
+ + {/* Stats */} +
+ {[ + { label: 'Объектов', value: objects.length, color: 'text-slate-900 dark:text-slate-100' }, + { label: 'Броней сегодня', value: bookings.filter(b => b.date === today).length, color: 'text-brand-600 dark:text-brand-400' }, + { label: 'Всего броней', value: bookings.filter(b => b.status === 'confirmed').length, color: 'text-emerald-600 dark:text-emerald-400' }, + { label: 'Общая выручка', value: formatCurrency(totalRevenue), color: 'text-emerald-600 dark:text-emerald-400' }, + ].map(s => ( +
+

{s.value}

+

{s.label}

+
+ ))} +
+ +
+ {/* Objects list */} +
+

Объекты аренды

+ {objects.map(obj => { + const objBookings = bookings.filter(b => b.objectId === obj.id && b.status === 'confirmed') + const todayB = objBookings.filter(b => b.date === today) + const isSelected = selectedObj?.id === obj.id + return ( +
setSelectedObj(isSelected ? null : obj)} + className={cn( + 'card p-4 cursor-pointer transition-all', + isSelected ? 'ring-2 ring-brand-500' : 'hover:shadow-md', + )} + > +
+
+ {obj.icon} +
+
+

{obj.name}

+

+ {formatCurrency(obj.pricePerHour)}/ч · {String(obj.openHour).padStart(2, '0')}:00–{String(obj.closeHour).padStart(2, '0')}:00 +

+
+
+ + +
+
+ +
+ {todayB.length > 0 && ( + + {todayB.length} сегодня + + )} + + {objBookings.length} всего + +
+
+ ) + })} + + {objects.length === 0 && ( +
+

Нет объектов аренды

+ +
+ )} +
+ + {/* Right: bookings for selected object */} +
+ {selectedObj ? ( +
+
+

+ Бронирования — {selectedObj.name} +

+ +
+ +
+ + + + {['Дата', 'Время', 'Гость', 'Телефон', 'Сумма', 'Статус'].map(h => ( + + ))} + + + + {bookings.filter(b => b.objectId === selectedObj.id) + .sort((a, b) => b.date.localeCompare(a.date)) + .map(b => ( + + + + + + + + + ))} + +
{h}
+ {format(new Date(b.date), 'd MMM', { locale: ru })} + {b.date === today && сегодня} + + {b.isFullDay ? ( + Весь день + ) : `${b.startHour}:00 – ${b.endHour}:00`} + {b.guestName}{b.guestPhone || '—'}{formatCurrency(b.totalAmount)} + + {b.status === 'confirmed' ? 'Подтверждено' : 'Отменено'} + +
+ {bookings.filter(b => b.objectId === selectedObj.id).length === 0 && ( +
+ Нет бронирований для этого объекта +
+ )} +
+
+ ) : ( +
+ +

Выберите объект слева для просмотра бронирований

+
+ )} +
+
+ + {/* Object form modals */} + {(createModal || editObj) && ( + { setCreateModal(false); setEditObj(null) }} + /> + )} + + {/* Rental booking modal */} + {rentalModal && ( + b.objectId === rentalModal.obj.id && b.date === rentalModal.date)} + onClose={() => setRentalModal(null)} + onSave={handleBookingCreate} + /> + )} +
+ ) +} diff --git a/src/pages/ReviewsPage.tsx b/src/pages/ReviewsPage.tsx new file mode 100644 index 0000000..1534c87 --- /dev/null +++ b/src/pages/ReviewsPage.tsx @@ -0,0 +1,376 @@ +import { useState } from 'react' +import { + Star, ThumbsUp, ThumbsDown, ExternalLink, Copy, CheckCheck, + MessageSquare, TrendingUp, QrCode, Filter, +} from 'lucide-react' +import { format, subDays } from 'date-fns' +import { ru } from 'date-fns/locale' +import { cn } from '../lib/utils' +import { Badge } from '../components/ui/Badge' + +// ── Types ───────────────────────────────────────────────────────────────────── + +type ReviewStatus = 'pending' | 'published' | 'rejected' + +interface Review { + id: string + guestName: string + roomNumber: string + checkOut: string + rating: number // 1–10 + text: string + status: ReviewStatus + source: 'qr' | 'email' | 'sms' + createdAt: Date + reply?: string +} + +// ── Mock data ───────────────────────────────────────────────────────────────── + +const MOCK_REVIEWS: Review[] = [ + { + id: 'rv1', + guestName: 'Дмитрий Волков', + roomNumber: '101', + checkOut: format(subDays(new Date(), 1), 'yyyy-MM-dd'), + rating: 9, + text: 'Замечательный отель! Персонал очень вежливый и отзывчивый. Номер чистый, завтрак вкусный. Обязательно вернёмся.', + status: 'pending', + source: 'email', + createdAt: new Date(Date.now() - 3600000 * 5), + }, + { + id: 'rv2', + guestName: 'Анна Козлова', + roomNumber: '202', + checkOut: format(subDays(new Date(), 2), 'yyyy-MM-dd'), + rating: 4, + text: 'Шум из соседнего номера мешал спать. Кондиционер плохо работал. Персонал помог, но проблему до конца не решили.', + status: 'pending', + source: 'qr', + createdAt: new Date(Date.now() - 3600000 * 12), + }, + { + id: 'rv3', + guestName: 'Наталья Александрова', + roomNumber: '301', + checkOut: format(subDays(new Date(), 3), 'yyyy-MM-dd'), + rating: 10, + text: 'Лучший отель в котором я останавливалась! Вид из окна потрясающий, кровать удобная, всё очень стильно. Пентхаус — это что-то особенное.', + status: 'published', + source: 'sms', + createdAt: new Date(Date.now() - 3600000 * 36), + reply: 'Наталья, большое спасибо за такой тёплый отзыв! Будем рады видеть вас снова!', + }, + { + id: 'rv4', + guestName: 'Игорь Соколов', + roomNumber: '201', + checkOut: format(subDays(new Date(), 5), 'yyyy-MM-dd'), + rating: 7, + text: 'В целом хорошо. Немного дорого для такого номера. Но расположение отличное, добираться удобно.', + status: 'published', + source: 'email', + createdAt: new Date(Date.now() - 3600000 * 60), + }, + { + id: 'rv5', + guestName: 'Тест Тестов', + roomNumber: '102', + checkOut: format(subDays(new Date(), 7), 'yyyy-MM-dd'), + rating: 2, + text: 'asdfghjkl qwerty', + status: 'rejected', + source: 'qr', + createdAt: new Date(Date.now() - 3600000 * 90), + }, + { + id: 'rv6', + guestName: 'Михаил Орлов', + roomNumber: '401', + checkOut: format(subDays(new Date(), 8), 'yyyy-MM-dd'), + rating: 8, + text: 'Очень доволен пребыванием. Всё было организованно на высшем уровне. Спасибо персоналу за внимание к деталям.', + status: 'published', + source: 'email', + createdAt: new Date(Date.now() - 3600000 * 100), + }, +] + +const REVIEW_LINK = 'https://app.hotelsync.ru/review/grand-palace' + +function StarRating({ rating, max = 10, size = 14 }: { rating: number; max?: number; size?: number }) { + const stars = Math.round((rating / max) * 5) + return ( +
+ {Array.from({ length: 5 }, (_, i) => ( + + ))} + {rating}/10 +
+ ) +} + +function ratingColor(r: number): string { + if (r >= 9) return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300' + if (r >= 7) return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' + if (r >= 5) return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300' + return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300' +} + +const SOURCE_LABEL: Record = { + qr: 'QR-код', + email: 'Email', + sms: 'SMS', +} + +type Tab = 'pending' | 'published' | 'rejected' | 'all' + +export function ReviewsPage() { + const [reviews, setReviews] = useState(MOCK_REVIEWS) + const [tab, setTab] = useState('pending') + const [replyId, setReplyId] = useState(null) + const [replyText, setReplyText] = useState('') + const [copied, setCopied] = useState(false) + + const published = reviews.filter(r => r.status === 'published') + const pending = reviews.filter(r => r.status === 'pending') + + const avgRating = published.length + ? (published.reduce((s, r) => s + r.rating, 0) / published.length).toFixed(1) + : '—' + + const nps = published.length + ? Math.round( + (published.filter(r => r.rating >= 9).length / published.length * 100) - + (published.filter(r => r.rating <= 6).length / published.length * 100), + ) + : 0 + + const filtered = tab === 'all' ? reviews : reviews.filter(r => r.status === tab) + + const approve = (id: string) => + setReviews(prev => prev.map(r => r.id === id ? { ...r, status: 'published' } : r)) + const reject = (id: string) => + setReviews(prev => prev.map(r => r.id === id ? { ...r, status: 'rejected' } : r)) + const sendReply = (id: string) => { + if (!replyText.trim()) return + setReviews(prev => prev.map(r => r.id === id ? { ...r, reply: replyText.trim(), status: 'published' } : r)) + setReplyId(null) + setReplyText('') + } + + const copyLink = () => { + navigator.clipboard.writeText(REVIEW_LINK).catch(() => {}) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + const tabs: { key: Tab; label: string; count?: number }[] = [ + { key: 'pending', label: 'На модерации', count: pending.length }, + { key: 'published', label: 'Опубликованы', count: published.length }, + { key: 'rejected', label: 'Отклонены' }, + { key: 'all', label: 'Все' }, + ] + + return ( +
+ {/* Header */} +
+

Отзывы гостей

+

Сбор и модерация обратной связи

+
+ + {/* Stats */} +
+ {[ + { label: 'Средняя оценка', value: avgRating, sub: 'из 10', color: 'text-amber-600 dark:text-amber-400' }, + { label: 'NPS', value: `+${nps}`, sub: 'индекс лояльности', color: 'text-emerald-600 dark:text-emerald-400' }, + { label: 'Опубликовано', value: published.length, sub: 'отзывов', color: 'text-brand-600 dark:text-brand-400' }, + { label: 'Ожидают', value: pending.length, sub: 'на модерации', color: pending.length > 0 ? 'text-red-600 dark:text-red-400' : 'text-slate-500' }, + ].map(s => ( +
+

{s.value}

+

{s.label}

+

{s.sub}

+
+ ))} +
+ + {/* Public review link */} +
+
+
+ +
+
+

Ссылка для сбора отзывов

+

+ Отправьте гостю при выезде или разместите QR-код в номере и на стойке +

+
+ {REVIEW_LINK} + + + + Открыть + +
+
+
+

Перенаправление довольных (≥8) на:

+ {['Booking.com', 'Яндекс', 'Google'].map(s => ( + + ))} +
+
+
+ + {/* Tabs */} +
+ {tabs.map(t => ( + + ))} +
+ + {/* Reviews list */} +
+ {filtered.length === 0 && ( +
+ +

Отзывов нет

+
+ )} + {filtered.map(review => ( +
+
+ {/* Rating circle */} +
+ {review.rating} +
+
+
+
+

{review.guestName}

+ + №{review.roomNumber} + + + {SOURCE_LABEL[review.source]} + +
+ + {format(review.createdAt, 'd MMM, HH:mm', { locale: ru })} + +
+ +

{review.text}

+ + {/* Reply */} + {review.reply && ( +
+

Ответ отеля:

+

{review.reply}

+
+ )} + + {/* Reply input */} + {replyId === review.id && ( +
+