Add calendar compact mode, Tetris login animation, maintenance rental support

- BookingCalendar: sort rooms by category, compact row height toggle
- LoginPage: Tetris tile animation after delay (module names fall & stack)
- MaintenancePage: rental objects support with filter tab and icons

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 20:31:36 +03:00
parent 78788f68b0
commit 6e5b042f57
3 changed files with 367 additions and 119 deletions

View File

@@ -1,7 +1,8 @@
import { useState, useRef, useCallback, useEffect } from 'react'
import type { ReactNode } from 'react'
import { addDays, format, startOfDay, differenceInDays, parseISO, isToday } from 'date-fns'
import { ru } from 'date-fns/locale'
import { ChevronLeft, ChevronRight, Plus, CalendarDays, ChevronDown } from 'lucide-react'
import { ChevronLeft, ChevronRight, Plus, CalendarDays, ChevronDown, AlignJustify } 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'
@@ -11,6 +12,7 @@ import { RentalBookingModal } from '../rental/RentalBookingModal'
const CELL_WIDTH = 52
const ROW_HEIGHT = 56
const ROW_HEIGHT_COMPACT = 34
const LABEL_WIDTH = 160
const DAYS_VISIBLE = 30
@@ -25,6 +27,15 @@ interface BookingCalendarProps {
onRentalBookingCreate?: (b: RentalBooking) => void
}
const CATEGORY_ORDER: Record<string, number> = {
'Стандарт': 0,
'Делюкс': 1,
'Полулюкс': 2,
'Люкс': 3,
'Пентхаус': 4,
'Апартаменты': 5,
}
function getRoomTypeColor(type: string): string {
const map: Record<string, string> = {
'Стандарт': 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300',
@@ -62,6 +73,10 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
const [dragStart, setDragStart] = useState<{ roomId: string; dayIdx: number } | null>(null)
const [dragEnd, setDragEnd] = useState<number | null>(null)
// Compact mode
const [compact, setCompact] = useState(false)
const rowHeight = compact ? ROW_HEIGHT_COMPACT : ROW_HEIGHT
// Modals
const [bookingModalDraft, setBookingModalDraft] = useState<DraftBooking | null>(null)
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null)
@@ -71,6 +86,14 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
const dates = Array.from({ length: visibleDays }, (_, i) => addDays(startDate, i))
// Sorted+grouped rooms
const sortedRooms = [...rooms].sort((a, b) => {
const ao = CATEGORY_ORDER[a.type] ?? 99
const bo = CATEGORY_ORDER[b.type] ?? 99
if (ao !== bo) return ao - bo
return a.number.localeCompare(b.number, 'ru', { numeric: true })
})
const shiftDays = (n: number) => setStartDate(d => addDays(d, n))
const getBlockStyle = (booking: Booking) => {
@@ -219,6 +242,14 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
))}
</div>
<button
onClick={() => setCompact(v => !v)}
title={compact ? 'Обычный вид' : 'Компактный вид'}
className={cn('btn-ghost p-2', compact && 'bg-brand-50 dark:bg-brand-900/20 text-brand-600 dark:text-brand-400')}
>
<AlignJustify size={16} />
</button>
<button
onClick={() => {
const today = format(new Date(), 'yyyy-MM-dd')
@@ -277,116 +308,147 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
})}
</div>
{/* Room rows */}
{rooms.map(room => {
const roomBookings = bookings.filter(b => b.roomId === room.id)
const draftStyle = getDraftStyle(room.id)
return (
<div
key={room.id}
className="flex border-b border-slate-200 dark:border-slate-700 group hover:bg-slate-50/50 dark:hover:bg-slate-800/30"
style={{ height: ROW_HEIGHT }}
>
{/* Room 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 group-hover:bg-slate-50 dark:group-hover:bg-slate-750"
style={{ width: LABEL_WIDTH }}
>
<div>
<div className="flex items-center gap-1.5">
<span className="text-sm font-bold text-slate-900 dark:text-slate-100">{room.number}</span>
{room.name && <span className="text-xs text-slate-500 dark:text-slate-400">{room.name}</span>}
{/* Room rows grouped by category */}
{(() => {
const rows: ReactNode[] = []
let lastCategory = ''
sortedRooms.forEach(room => {
// Category header
if (room.type !== lastCategory) {
lastCategory = room.type
rows.push(
<div
key={`cat-${room.type}`}
className="flex sticky left-0 bg-slate-50 dark:bg-slate-700/40 border-b border-t border-slate-200 dark:border-slate-600"
style={{ height: 28 }}
>
<div
className="shrink-0 sticky left-0 z-10 bg-slate-50 dark:bg-slate-700/40 border-r border-slate-200 dark:border-slate-600 flex items-center px-4"
style={{ width: LABEL_WIDTH }}
>
<span className={cn('text-xs font-semibold uppercase tracking-wide', getRoomTypeColor(room.type).split(' ').filter(c => c.startsWith('text-')).join(' '))}>
{room.type}
</span>
</div>
<span className={cn('text-xs px-1.5 py-0.5 rounded-md font-medium', getRoomTypeColor(room.type))}>
{room.type}
</span>
{dates.map((_, i) => (
<div key={i} className="shrink-0 border-r border-slate-200 dark:border-slate-600" style={{ width: CELL_WIDTH }} />
))}
</div>
<div className="ml-auto text-xs text-slate-400 dark:text-slate-500">
{room.baseRate.toLocaleString('ru-RU')}
)
}
// Room row
const roomBookings = bookings.filter(b => b.roomId === room.id)
const draftStyle = getDraftStyle(room.id)
rows.push(
<div
key={room.id}
className="flex border-b border-slate-200 dark:border-slate-700 group hover:bg-slate-50/50 dark:hover:bg-slate-800/30"
style={{ height: rowHeight }}
>
{/* Room 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 group-hover:bg-slate-50 dark:group-hover:bg-slate-750"
style={{ width: LABEL_WIDTH }}
>
<div>
<div className="flex items-center gap-1.5">
<span className="text-sm font-bold text-slate-900 dark:text-slate-100">{room.number}</span>
{room.name && <span className="text-xs text-slate-500 dark:text-slate-400">{room.name}</span>}
</div>
{!compact && (
<span className={cn('text-xs px-1.5 py-0.5 rounded-md font-medium', getRoomTypeColor(room.type))}>
{room.type}
</span>
)}
</div>
{!compact && (
<div className="ml-auto text-xs text-slate-400 dark:text-slate-500">
{room.baseRate.toLocaleString('ru-RU')}
</div>
)}
</div>
</div>
{/* Day cells + booking blocks */}
<div className="relative flex-1">
<div className="flex h-full">
{dates.map((date, i) => {
const isWe = date.getDay() === 0 || date.getDay() === 6
const isTod = isToday(date)
{/* Day cells + booking blocks */}
<div className="relative flex-1">
<div className="flex h-full">
{dates.map((date, i) => {
const isWe = date.getDay() === 0 || date.getDay() === 6
const isTod = isToday(date)
return (
<div
key={i}
className={cn(
'shrink-0 h-full border-r border-slate-200 dark:border-slate-700 cursor-crosshair',
isWe && 'bg-slate-50 dark:bg-slate-800/60',
isTod && 'bg-brand-50/50 dark:bg-brand-900/10',
)}
style={{ width: CELL_WIDTH }}
onMouseDown={(e) => handleCellMouseDown(room.id, i, e)}
onMouseEnter={() => handleCellMouseEnter(i)}
/>
)
})}
</div>
{/* Booking blocks */}
{roomBookings.map(booking => {
const style = getBlockStyle(booking)
if (!style) return null
const nights = differenceInDays(parseISO(booking.checkOut), parseISO(booking.checkIn))
const isFading = fadingBookingIds?.has(booking.id)
const isUnpaid = booking.paidAmount < booking.totalAmount
const guestCount = (booking.adults ?? 0) + (booking.children ?? 0)
return (
<div
key={i}
key={booking.id}
className={cn(
'shrink-0 h-full border-r border-slate-200 dark:border-slate-700 cursor-crosshair',
isWe && 'bg-slate-50 dark:bg-slate-800/60',
isTod && 'bg-brand-50/50 dark:bg-brand-900/10',
'booking-block border-l-4 transition-all duration-700',
BOOKING_STATUS_COLORS[booking.status],
isFading && 'opacity-0 scale-y-0',
)}
style={{ width: CELL_WIDTH }}
onMouseDown={(e) => handleCellMouseDown(room.id, i, e)}
onMouseEnter={() => handleCellMouseEnter(i)}
/>
style={{
left: style.left + 2,
width: style.width,
top: compact ? 2 : 4,
bottom: compact ? 2 : 4,
position: 'absolute',
}}
onClick={(e) => { e.stopPropagation(); setSelectedBooking(booking) }}
title={`${booking.guestName}${booking.checkIn} ${booking.checkOut}${isUnpaid ? ' • Не оплачено' : ''}`}
>
{isUnpaid && (
<span className="shrink-0 w-1.5 h-1.5 rounded-full bg-red-500 shadow-sm mr-1 mt-0.5" />
)}
<span className="truncate text-xs font-semibold opacity-95 drop-shadow-sm">
{booking.guestName}
</span>
{!compact && style.width > 90 && guestCount > 0 && (
<span className="ml-1.5 opacity-75 text-[10px] shrink-0">
{guestCount}г
</span>
)}
{!compact && style.width > 130 && (
<span className="ml-1.5 opacity-75 text-[10px] shrink-0">
{nights}н
</span>
)}
</div>
)
})}
</div>
{/* Booking blocks */}
{roomBookings.map(booking => {
const style = getBlockStyle(booking)
if (!style) return null
const nights = differenceInDays(parseISO(booking.checkOut), parseISO(booking.checkIn))
const isFading = fadingBookingIds?.has(booking.id)
const isUnpaid = booking.paidAmount < booking.totalAmount
const guestCount = (booking.adults ?? 0) + (booking.children ?? 0)
return (
{/* Draft overlay */}
{draftStyle && (
<div
key={booking.id}
className={cn(
'booking-block border-l-4 transition-all duration-700',
BOOKING_STATUS_COLORS[booking.status],
isFading && 'opacity-0 scale-y-0',
)}
style={{
left: style.left + 2,
width: style.width,
top: 4,
bottom: 4,
position: 'absolute',
}}
onClick={(e) => { e.stopPropagation(); setSelectedBooking(booking) }}
title={`${booking.guestName}${booking.checkIn} ${booking.checkOut}${isUnpaid ? ' • Не оплачено' : ''}`}
>
{/* Unpaid indicator */}
{isUnpaid && (
<span className="shrink-0 w-1.5 h-1.5 rounded-full bg-red-500 shadow-sm mr-1 mt-0.5" />
)}
<span className="truncate text-xs font-semibold opacity-95 drop-shadow-sm">
{booking.guestName}
</span>
{style.width > 90 && guestCount > 0 && (
<span className="ml-1.5 opacity-75 text-[10px] shrink-0">
{guestCount}г
</span>
)}
{style.width > 130 && (
<span className="ml-1.5 opacity-75 text-[10px] shrink-0">
{nights}н
</span>
)}
</div>
)
})}
{/* Draft overlay */}
{draftStyle && (
<div
className="absolute top-2 bottom-2 bg-brand-400/40 border-2 border-brand-500 border-dashed rounded-md pointer-events-none"
style={{ left: draftStyle.left + 2, width: draftStyle.width }}
/>
)}
className="absolute top-2 bottom-2 bg-brand-400/40 border-2 border-brand-500 border-dashed rounded-md pointer-events-none"
style={{ left: draftStyle.left + 2, width: draftStyle.width }}
/>
)}
</div>
</div>
</div>
)
})}
)
})
return rows
})()}
{/* ── Rental section ── */}
{rentalObjects && rentalObjects.length > 0 && (
@@ -414,7 +476,7 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
return (
<div key={obj.id}
className="flex border-b border-slate-200 dark:border-slate-700"
style={{ height: ROW_HEIGHT }}>
style={{ height: rowHeight }}>
{/* 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"
@@ -449,7 +511,7 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
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 }}
style={{ width: CELL_WIDTH, height: rowHeight }}
>
{hasFullDay ? (
/* Full day booking */