Add floor map, rental module wiring, VIP tags and payment tracking in BookingModal
- New: Поэтажный план (/floor-map) — room status visualization per floor with color coding (occupied/arriving/departing/available/dirty/maintenance), click free room to create booking - New: FloorMapModal — embeddable in BookingModal via "Поэтажный план" button near room selector - New: RentalBookingModal — full-day toggle, hour start/end selects, conflict detection, price summary - CalendarPage: rental objects/bookings shown in шахматка when rental module is active - BookingsPage: "Аренда" tab with rental bookings table when rental module is active - BookingModal: guest tags (VIP, Постоянный гость, etc.), payment method (cash/terminal), paidAmount input, debt/задолженность display, floor map quick-access button - Sidebar: "План этажей" nav link added under Управление Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,8 +4,10 @@ import { ru } from 'date-fns/locale'
|
||||
import { ChevronLeft, ChevronRight, Plus, CalendarDays, ChevronDown } from 'lucide-react'
|
||||
import { cn, BOOKING_STATUS_COLORS, BOOKING_STATUS_LABELS, SOURCE_LABELS } from '../../lib/utils'
|
||||
import type { Room, Booking, DraftBooking } from '../../types'
|
||||
import type { RentalObject, RentalBooking } from '../../data/rentalData'
|
||||
import { BookingModal } from '../bookings/BookingModal'
|
||||
import { BookingDetailPanel } from '../bookings/BookingDetailPanel'
|
||||
import { RentalBookingModal } from '../rental/RentalBookingModal'
|
||||
|
||||
const CELL_WIDTH = 52
|
||||
const ROW_HEIGHT = 56
|
||||
@@ -18,6 +20,9 @@ interface BookingCalendarProps {
|
||||
onBookingCreate: (b: Partial<Booking>) => void
|
||||
onBookingUpdate: (id: string, b: Partial<Booking>) => void
|
||||
fadingBookingIds?: Set<string>
|
||||
rentalObjects?: RentalObject[]
|
||||
rentalBookings?: RentalBooking[]
|
||||
onRentalBookingCreate?: (b: RentalBooking) => void
|
||||
}
|
||||
|
||||
function getRoomTypeColor(type: string): string {
|
||||
@@ -32,7 +37,7 @@ function getRoomTypeColor(type: string): string {
|
||||
return map[type] ?? 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300'
|
||||
}
|
||||
|
||||
export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpdate, fadingBookingIds }: BookingCalendarProps) {
|
||||
export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpdate, fadingBookingIds, rentalObjects, rentalBookings, onRentalBookingCreate }: BookingCalendarProps) {
|
||||
const [startDate, setStartDate] = useState(() => startOfDay(new Date()))
|
||||
const [visibleDays, setVisibleDays] = useState(DAYS_VISIBLE)
|
||||
|
||||
@@ -60,6 +65,7 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
|
||||
// Modals
|
||||
const [bookingModalDraft, setBookingModalDraft] = useState<DraftBooking | null>(null)
|
||||
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null)
|
||||
const [rentalModal, setRentalModal] = useState<{ obj: RentalObject; date: string } | null>(null)
|
||||
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -370,6 +376,124 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* ── Rental section ── */}
|
||||
{rentalObjects && rentalObjects.length > 0 && (
|
||||
<>
|
||||
{/* Separator */}
|
||||
<div className="flex sticky left-0 bg-slate-100 dark:bg-slate-700/60 border-b border-t border-slate-200 dark:border-slate-600"
|
||||
style={{ height: 32 }}>
|
||||
<div
|
||||
className="shrink-0 sticky left-0 z-10 bg-slate-100 dark:bg-slate-700/60 border-r border-slate-200 dark:border-slate-600 flex items-center px-4"
|
||||
style={{ width: LABEL_WIDTH }}
|
||||
>
|
||||
<span className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">
|
||||
Аренда объектов
|
||||
</span>
|
||||
</div>
|
||||
{dates.map((_, i) => (
|
||||
<div key={i} className="shrink-0 border-r border-slate-200 dark:border-slate-600"
|
||||
style={{ width: CELL_WIDTH }} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Rental object rows */}
|
||||
{rentalObjects.map(obj => {
|
||||
const totalSpan = obj.closeHour - obj.openHour
|
||||
return (
|
||||
<div key={obj.id}
|
||||
className="flex border-b border-slate-200 dark:border-slate-700"
|
||||
style={{ height: ROW_HEIGHT }}>
|
||||
{/* Label */}
|
||||
<div
|
||||
className="shrink-0 sticky left-0 z-10 bg-white dark:bg-slate-800 border-r border-slate-200 dark:border-slate-700 flex items-center gap-2 px-3"
|
||||
style={{ width: LABEL_WIDTH }}
|
||||
>
|
||||
<span className="text-lg">{obj.icon}</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{obj.name}</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
{obj.pricePerHour.toLocaleString('ru-RU')} ₽/ч · {obj.pricePerDay.toLocaleString('ru-RU')} ₽/день
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Day cells */}
|
||||
{dates.map((date, i) => {
|
||||
const dateStr = format(date, 'yyyy-MM-dd')
|
||||
const isWe = date.getDay() === 0 || date.getDay() === 6
|
||||
const isTod = isToday(date)
|
||||
const dayBookings = (rentalBookings ?? []).filter(
|
||||
b => b.objectId === obj.id && b.date === dateStr && b.status !== 'cancelled',
|
||||
)
|
||||
const hasFullDay = dayBookings.some(b => b.isFullDay)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => setRentalModal({ obj, date: dateStr })}
|
||||
className={cn(
|
||||
'shrink-0 border-r border-slate-200 dark:border-slate-700 cursor-pointer relative',
|
||||
'hover:bg-slate-50 dark:hover:bg-slate-700/40 transition-colors',
|
||||
isWe && 'bg-amber-50/30 dark:bg-amber-900/10',
|
||||
isTod && 'bg-brand-50/40 dark:bg-brand-900/10',
|
||||
)}
|
||||
style={{ width: CELL_WIDTH, height: ROW_HEIGHT }}
|
||||
>
|
||||
{hasFullDay ? (
|
||||
/* Full day booking */
|
||||
<div className={cn(
|
||||
'absolute inset-1.5 rounded flex items-center justify-center text-white text-[10px] font-medium',
|
||||
obj.color,
|
||||
)}>
|
||||
Весь день
|
||||
</div>
|
||||
) : dayBookings.length > 0 ? (
|
||||
/* Hourly booking bars */
|
||||
<div className="absolute inset-x-1 bottom-1" style={{ top: 6 }}>
|
||||
{/* Time scale bar (background) */}
|
||||
<div className="w-full h-2.5 rounded-sm bg-slate-100 dark:bg-slate-600 relative overflow-hidden">
|
||||
{dayBookings.map(b => {
|
||||
const leftPct = ((b.startHour - obj.openHour) / totalSpan) * 100
|
||||
const widthPct = ((b.endHour - b.startHour) / totalSpan) * 100
|
||||
return (
|
||||
<div
|
||||
key={b.id}
|
||||
className={cn('absolute h-full rounded-sm', obj.color)}
|
||||
style={{ left: `${leftPct}%`, width: `${widthPct}%` }}
|
||||
title={`${b.guestName} · ${b.startHour}:00–${b.endHour}:00`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* Hour labels */}
|
||||
<div className="flex justify-between mt-0.5 px-0.5">
|
||||
<span className="text-[9px] text-slate-400">{obj.openHour}:00</span>
|
||||
<span className="text-[9px] text-slate-400">{obj.closeHour}:00</span>
|
||||
</div>
|
||||
{/* Booking count */}
|
||||
<div className="flex flex-wrap gap-0.5 mt-1">
|
||||
{dayBookings.map(b => (
|
||||
<span key={b.id} className="text-[9px] text-slate-500 dark:text-slate-400 bg-slate-100 dark:bg-slate-700 px-1 rounded truncate max-w-full">
|
||||
{b.startHour}–{b.endHour}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Empty — show "+" hint on hover */
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity">
|
||||
<span className="text-xs text-slate-400">+</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -399,6 +523,22 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Rental booking modal */}
|
||||
{rentalModal && (
|
||||
<RentalBookingModal
|
||||
obj={rentalModal.obj}
|
||||
date={rentalModal.date}
|
||||
existingBookings={(rentalBookings ?? []).filter(
|
||||
b => b.objectId === rentalModal.obj.id && b.date === rentalModal.date,
|
||||
)}
|
||||
onClose={() => setRentalModal(null)}
|
||||
onSave={(b) => {
|
||||
onRentalBookingCreate?.(b)
|
||||
setRentalModal(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user