Files
hotelsync/src/pages/RentalPage.tsx
HotelSync db05898c08 feat: 15-minute rental slots + compact calendar cell display
- Rental bookings now use minutes since midnight (start_hour/end_hour
  store e.g. 495 for 8:15, 570 for 9:30) — DB migration 017
- Buffer applied in exact minutes (no hour ceiling): 15min buffer after
  9:00 booking → next slot available at 9:15
- Calendar cell: removed working-hours line (8:00–22:00), now shows
  colored dot + compact time per booking (8–10, 8:15–9:15)
- Time selects show HH:MM format for 15-min intervals

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 23:02:24 +03:00

562 lines
25 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 } from 'react'
import { Plus, Pencil, Trash2, 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 type { RentalObject, RentalBooking } from '../data/rentalData'
import { RentalBookingModal } from '../components/rental/RentalBookingModal'
import { api } from '../lib/api'
import type { RentalObjectApi, RentalBookingApi } from '../lib/api'
import { useAuth } from '../contexts/AuthContext'
// ── 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 ─────────────────────────────────────────────────────────────────
// Convert API response to local type
function fromObjApi(o: RentalObjectApi): RentalObject {
return {
id: o.id, name: o.name, icon: o.icon, color: o.color,
textColor: o.textColor, pricePerHour: o.pricePerHour, pricePerDay: o.pricePerDay,
openHour: o.openHour, closeHour: o.closeHour,
maxHoursPerSlot: o.maxHoursPerSlot ?? undefined,
bufferMinutes: o.bufferMinutes ?? undefined,
}
}
function fromBookingApi(b: RentalBookingApi): RentalBooking {
return {
id: b.id, objectId: b.objectId, date: (b.date as string).slice(0, 10),
isFullDay: b.isFullDay, startHour: b.startHour, endHour: b.endHour,
guestName: b.guestName, guestPhone: b.guestPhone ?? '',
linkedRoomId: b.linkedRoomId ?? undefined,
totalAmount: b.totalAmount, paidAmount: b.paidAmount,
status: b.status, notes: b.notes ?? undefined,
}
}
export function RentalPage() {
const { user } = useAuth()
const slug = user?.hotelSlug ?? ''
const [objects, setObjects] = useState<RentalObject[]>([])
const [bookings, setBookings] = useState<RentalBooking[]>([])
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')
// Load from API on mount
useEffect(() => {
if (!slug) return
Promise.all([
api.rental.listObjects(slug),
api.rental.listBookings(slug),
]).then(([objs, bks]) => {
setObjects(objs.map(fromObjApi))
setBookings(bks.map(fromBookingApi))
}).catch(console.error)
}, [slug])
const handleSaveObject = async (obj: RentalObject) => {
if (!slug) return
const payload = {
name: obj.name, icon: obj.icon, color: obj.color, text_color: obj.textColor,
price_per_hour: obj.pricePerHour, price_per_day: obj.pricePerDay,
open_hour: obj.openHour, close_hour: obj.closeHour,
max_hours_per_slot: obj.maxHoursPerSlot ?? null,
buffer_minutes: obj.bufferMinutes ?? 0,
}
const isNew = !objects.find(o => o.id === obj.id)
const saved = isNew
? await api.rental.createObject(slug, payload)
: await api.rental.updateObject(slug, obj.id, payload)
const converted = fromObjApi(saved)
setObjects(prev => isNew ? [...prev, converted] : prev.map(o => o.id === obj.id ? converted : o))
// Update selectedObj if it was the edited one
setSelectedObj(prev => prev?.id === obj.id ? converted : prev)
setEditObj(null)
setCreateModal(false)
}
const handleDeleteObject = async (id: string) => {
if (!slug) return
await api.rental.deleteObject(slug, id)
setObjects(prev => prev.filter(o => o.id !== id))
if (selectedObj?.id === id) setSelectedObj(null)
}
const handleBookingSave = async (b: RentalBooking) => {
if (!slug) return
const payload = {
object_id: b.objectId, date: b.date, is_full_day: b.isFullDay,
start_hour: b.startHour, end_hour: b.endHour,
guest_name: b.guestName, guest_phone: b.guestPhone || undefined,
linked_room_id: b.linkedRoomId || undefined,
total_amount: b.totalAmount, paid_amount: b.paidAmount ?? 0,
status: b.status, notes: b.notes || undefined,
}
const isNew = !bookings.find(x => x.id === b.id)
const saved = isNew
? await api.rental.createBooking(slug, payload)
: await api.rental.updateBooking(slug, b.id, payload)
const converted = fromBookingApi(saved)
setBookings(prev => isNew ? [...prev, converted] : prev.map(x => x.id === b.id ? converted : x))
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>
) : (() => {
const fmt = (m: number) => { const h = Math.floor(m/60), mn = m%60; return mn === 0 ? `${h}:00` : `${h}:${String(mn).padStart(2,'0')}` }
return `${fmt(b.startHour)} ${fmt(b.endHour)}`
})()}
</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>
)
}