Files
hotelsync/src/pages/RentalPage.tsx
HotelSync d661bbbc84 Add role permissions, loyalty accrual rules, guest statuses, and room service guest page
- UsersPage: add "Роли и права" tab with per-module permission editor per role; create/delete custom roles
- LoyaltyPage: add "Правила начисления" tab with togglable accrual rules editor; add "Ручное начисление" tab with guest search by name/phone/email, add/deduct points with reason and history; fix banner text (HotelSync is a PMS, not a hotel chain)
- GuestsPage: add "Статусы гостей" tab (moved from Settings)
- SettingsPage: remove Guests section; add compact calendar toggle in Appearance; toggle persists via localStorage
- BookingCalendar: read compact mode default from localStorage
- App.tsx: add public route /room-service/:slug → GuestRoomServicePage
- GuestRoomServicePage: new public guest-facing page for ordering room service with menu, cart, payment, and order status tracking
- RoomServicePage: QR/link sharing, payment mode toggles, new order notifications
- RentalPage/rentalData: buffer time between bookings (bufferMinutes field)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 20:13:22 +03:00

495 lines
23 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 { 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<string, string> = {
'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 | ''
bufferMinutes: number
description: string
}
const EMPTY_FORM: ObjectFormData = {
name: '', icon: '🎾', color: 'bg-green-500',
pricePerHour: 1000, pricePerDay: 5000,
openHour: 9, closeHour: 21, maxHoursPerSlot: '',
bufferMinutes: 0,
description: '',
}
function ObjectFormModal({
initial, onSave, onClose,
}: {
initial?: RentalObject
onSave: (obj: RentalObject) => void
onClose: () => void
}) {
const [form, setForm] = useState<ObjectFormData>(
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 ?? '',
bufferMinutes: initial.bufferMinutes ?? 0,
description: '',
}
: EMPTY_FORM,
)
const set = <K extends keyof ObjectFormData>(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,
bufferMinutes: form.bufferMinutes > 0 ? form.bufferMinutes : undefined,
})
}
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-2xl w-full max-w-lg flex flex-col">
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-200 dark:border-slate-700">
<h2 className="font-semibold text-slate-900 dark:text-slate-100">
{initial ? 'Редактировать объект' : 'Новый объект аренды'}
</h2>
<button onClick={onClose} className="btn-ghost p-1.5"><X size={16} /></button>
</div>
<div className="p-5 space-y-4 overflow-y-auto">
{/* Name */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Название *</label>
<input
className="input"
placeholder="Теннисный корт"
value={form.name}
onChange={e => set('name', e.target.value)}
/>
</div>
{/* Icon + Color */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Иконка</label>
<div className="flex flex-wrap gap-2">
{ICON_OPTIONS.map(ico => (
<button
key={ico}
type="button"
onClick={() => set('icon', ico)}
className={cn(
'w-9 h-9 rounded-lg text-lg flex items-center justify-center border-2 transition-all',
form.icon === ico ? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20' : 'border-transparent bg-slate-100 dark:bg-slate-700 hover:border-slate-300',
)}
>
{ico}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Цвет</label>
<div className="flex flex-wrap gap-2">
{COLOR_OPTIONS.map(c => (
<button
key={c.value}
type="button"
onClick={() => set('color', c.value)}
className={cn(
'w-7 h-7 rounded-full border-2 transition-all',
c.value,
form.color === c.value ? 'border-slate-900 dark:border-white scale-125' : 'border-transparent',
)}
title={c.label}
/>
))}
</div>
</div>
</div>
{/* Prices */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Цена/час </label>
<input
type="number" min={0} className="input"
value={form.pricePerHour}
onChange={e => set('pricePerHour', parseInt(e.target.value) || 0)}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Цена/день </label>
<input
type="number" min={0} className="input"
value={form.pricePerDay}
onChange={e => set('pricePerDay', parseInt(e.target.value) || 0)}
/>
</div>
</div>
{/* Buffer time */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Технический перерыв между бронями
</label>
<select
className="input"
value={form.bufferMinutes}
onChange={e => set('bufferMinutes', parseInt(e.target.value))}
>
{[
{ value: 0, label: 'Без перерыва' },
{ value: 15, label: '15 минут' },
{ value: 30, label: '30 минут' },
{ value: 45, label: '45 минут' },
{ value: 60, label: '1 час' },
{ value: 90, label: '1,5 часа' },
{ value: 120, label: '2 часа' },
].map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<p className="text-xs text-slate-400 mt-1">Время для уборки СПА, подготовки корта и т.д.</p>
</div>
{/* Hours */}
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Открытие</label>
<select className="input" value={form.openHour} onChange={e => set('openHour', parseInt(e.target.value))}>
{Array.from({ length: 24 }, (_, i) => i).map(h => (
<option key={h} value={h}>{String(h).padStart(2, '0')}:00</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Закрытие</label>
<select className="input" value={form.closeHour} onChange={e => set('closeHour', parseInt(e.target.value))}>
{Array.from({ length: 24 }, (_, i) => i).map(h => (
<option key={h} value={h}>{String(h).padStart(2, '0')}:00</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Макс. часов/слот</label>
<input
type="number" min={1} max={24} className="input"
placeholder="Без лимита"
value={form.maxHoursPerSlot}
onChange={e => set('maxHoursPerSlot', e.target.value === '' ? '' : parseInt(e.target.value))}
/>
</div>
</div>
{/* Preview */}
<div className="rounded-xl bg-slate-50 dark:bg-slate-700/50 p-3 flex items-center gap-3">
<div className={cn('w-10 h-10 rounded-xl flex items-center justify-center text-xl text-white', form.color)}>
{form.icon}
</div>
<div>
<p className="font-semibold text-slate-900 dark:text-slate-100">{form.name || 'Название объекта'}</p>
<p className="text-xs text-slate-500">
{formatCurrency(form.pricePerHour)}/ч · {formatCurrency(form.pricePerDay)}/день · {String(form.openHour).padStart(2, '0')}:00{String(form.closeHour).padStart(2, '0')}:00
</p>
</div>
</div>
</div>
<div className="flex justify-end gap-2 px-5 py-4 border-t border-slate-200 dark:border-slate-700">
<button onClick={onClose} className="btn-secondary">Отмена</button>
<button onClick={handleSave} disabled={!form.name.trim()} className="btn-primary gap-1.5 disabled:opacity-50">
<Save size={14} />
{initial ? 'Сохранить' : 'Создать объект'}
</button>
</div>
</div>
</div>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
export function RentalPage() {
const [objects, setObjects] = useState<RentalObject[]>(RENTAL_OBJECTS)
const [bookings, setBookings] = useState<RentalBooking[]>(MOCK_RENTAL_BOOKINGS)
const [editObj, setEditObj] = useState<RentalObject | null>(null)
const [createModal, setCreateModal] = useState(false)
const [selectedObj, setSelectedObj] = useState<RentalObject | null>(null)
const [rentalModal, setRentalModal] = useState<{ obj: RentalObject; date: string; editBooking?: RentalBooking } | 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 handleBookingSave = (b: RentalBooking) => {
setBookings(prev => {
const exists = prev.find(x => x.id === b.id)
return exists ? prev.map(x => x.id === b.id ? b : x) : [...prev, b]
})
setRentalModal(null)
}
const totalRevenue = bookings
.filter(b => b.status === 'confirmed')
.reduce((s, b) => s + b.totalAmount, 0)
return (
<div className="p-4 md:p-6 space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">Аренда объектов</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">{objects.length} объектов · управление и расписание</p>
</div>
<button onClick={() => setCreateModal(true)} className="btn-primary">
<Plus size={15} />
Новый объект
</button>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[
{ 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 => (
<div key={s.label} className="card p-4">
<p className={cn('text-xl font-bold', s.color)}>{s.value}</p>
<p className="text-sm text-slate-500 dark:text-slate-400">{s.label}</p>
</div>
))}
</div>
<div className="grid md:grid-cols-3 gap-5">
{/* Objects list */}
<div className="space-y-3">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide">Объекты аренды</p>
{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 (
<div
key={obj.id}
onClick={() => setSelectedObj(isSelected ? null : obj)}
className={cn(
'card p-4 cursor-pointer transition-all',
isSelected ? 'ring-2 ring-brand-500' : 'hover:shadow-md',
)}
>
<div className="flex items-center gap-3">
<div className={cn('w-10 h-10 rounded-xl flex items-center justify-center text-xl shrink-0 text-white', obj.color)}>
{obj.icon}
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-slate-900 dark:text-slate-100 truncate">{obj.name}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
{formatCurrency(obj.pricePerHour)}/ч · {String(obj.openHour).padStart(2, '0')}:00{String(obj.closeHour).padStart(2, '0')}:00
</p>
</div>
<div className="flex gap-1 shrink-0">
<button
onClick={e => { e.stopPropagation(); setEditObj(obj) }}
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400 hover:text-slate-700 transition-colors"
>
<Pencil size={13} />
</button>
<button
onClick={e => { e.stopPropagation(); handleDeleteObject(obj.id) }}
className="p-1.5 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 text-slate-400 hover:text-red-600 transition-colors"
>
<Trash2 size={13} />
</button>
</div>
</div>
<div className="mt-2.5 flex gap-2">
{todayB.length > 0 && (
<Badge className="bg-brand-100 text-brand-700 dark:bg-brand-900/30 dark:text-brand-300 text-[10px]">
{todayB.length} сегодня
</Badge>
)}
<Badge className="bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-400 text-[10px]">
{objBookings.length} всего
</Badge>
</div>
</div>
)
})}
{objects.length === 0 && (
<div className="card p-8 text-center text-slate-400">
<p className="text-sm">Нет объектов аренды</p>
<button onClick={() => setCreateModal(true)} className="btn-secondary text-xs mt-3">
Добавить первый
</button>
</div>
)}
</div>
{/* Right: bookings for selected object */}
<div className="md:col-span-2">
{selectedObj ? (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
Бронирования {selectedObj.name}
</p>
<button
onClick={() => setRentalModal({ obj: selectedObj, date: today })}
className="btn-primary text-xs py-1.5"
>
<Plus size={13} />
Добавить бронь
</button>
</div>
<div className="card overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-700/40">
{['Дата', 'Время', 'Гость', 'Телефон', 'Сумма', 'Статус', ''].map((h, i) => (
<th key={i} className="text-left px-4 py-2.5 text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">{h}</th>
))}
</tr>
</thead>
<tbody>
{bookings.filter(b => b.objectId === selectedObj.id)
.sort((a, b) => b.date.localeCompare(a.date))
.map(b => (
<tr key={b.id} className="border-b border-slate-100 dark:border-slate-700/50 hover:bg-slate-50 dark:hover:bg-slate-700/30">
<td className="px-4 py-2.5 text-slate-700 dark:text-slate-300">
{format(new Date(b.date), 'd MMM', { locale: ru })}
{b.date === today && <span className="ml-1 text-[10px] bg-brand-100 text-brand-700 dark:bg-brand-900/30 dark:text-brand-300 px-1 rounded">сегодня</span>}
</td>
<td className="px-4 py-2.5 text-slate-700 dark:text-slate-300">
{b.isFullDay ? (
<Badge className="bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-400">Весь день</Badge>
) : `${b.startHour}:00 ${b.endHour}:00`}
</td>
<td className="px-4 py-2.5 font-medium text-slate-900 dark:text-slate-100">{b.guestName}</td>
<td className="px-4 py-2.5 text-slate-500 dark:text-slate-400">{b.guestPhone || '—'}</td>
<td className="px-4 py-2.5 font-semibold text-slate-900 dark:text-slate-100">{formatCurrency(b.totalAmount)}</td>
<td className="px-4 py-2.5">
<Badge className={b.status === 'confirmed'
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
: 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400'
}>
{b.status === 'confirmed' ? 'Подтверждено' : 'Отменено'}
</Badge>
</td>
<td className="px-4 py-2.5">
<button
onClick={e => { e.stopPropagation(); setRentalModal({ obj: selectedObj, date: b.date, editBooking: b }) }}
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400 hover:text-slate-700 dark:hover:text-slate-300 transition-colors"
title="Редактировать"
>
<Pencil size={13} />
</button>
</td>
</tr>
))}
</tbody>
</table>
{bookings.filter(b => b.objectId === selectedObj.id).length === 0 && (
<div className="text-center py-10 text-slate-500 dark:text-slate-400 text-sm">
Нет бронирований для этого объекта
</div>
)}
</div>
</div>
) : (
<div className="card p-12 text-center text-slate-400 flex flex-col items-center gap-3">
<CalendarDays size={40} className="opacity-20" />
<p>Выберите объект слева для просмотра бронирований</p>
</div>
)}
</div>
</div>
{/* Object form modals */}
{(createModal || editObj) && (
<ObjectFormModal
initial={editObj ?? undefined}
onSave={handleSaveObject}
onClose={() => { setCreateModal(false); setEditObj(null) }}
/>
)}
{/* Rental booking modal */}
{rentalModal && (
<RentalBookingModal
obj={rentalModal.obj}
date={rentalModal.date}
existingBookings={bookings.filter(b => b.objectId === rentalModal.obj.id && b.date === rentalModal.date)}
editBooking={rentalModal.editBooking}
onClose={() => setRentalModal(null)}
onSave={handleBookingSave}
/>
)}
</div>
)
}