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:
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
import { addDays, format, startOfDay, differenceInDays, parseISO, isToday } from 'date-fns'
|
import { addDays, format, startOfDay, differenceInDays, parseISO, isToday } from 'date-fns'
|
||||||
import { ru } from 'date-fns/locale'
|
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 { cn, BOOKING_STATUS_COLORS, BOOKING_STATUS_LABELS, SOURCE_LABELS } from '../../lib/utils'
|
||||||
import type { Room, Booking, DraftBooking } from '../../types'
|
import type { Room, Booking, DraftBooking } from '../../types'
|
||||||
import type { RentalObject, RentalBooking } from '../../data/rentalData'
|
import type { RentalObject, RentalBooking } from '../../data/rentalData'
|
||||||
@@ -11,6 +12,7 @@ import { RentalBookingModal } from '../rental/RentalBookingModal'
|
|||||||
|
|
||||||
const CELL_WIDTH = 52
|
const CELL_WIDTH = 52
|
||||||
const ROW_HEIGHT = 56
|
const ROW_HEIGHT = 56
|
||||||
|
const ROW_HEIGHT_COMPACT = 34
|
||||||
const LABEL_WIDTH = 160
|
const LABEL_WIDTH = 160
|
||||||
const DAYS_VISIBLE = 30
|
const DAYS_VISIBLE = 30
|
||||||
|
|
||||||
@@ -25,6 +27,15 @@ interface BookingCalendarProps {
|
|||||||
onRentalBookingCreate?: (b: RentalBooking) => void
|
onRentalBookingCreate?: (b: RentalBooking) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CATEGORY_ORDER: Record<string, number> = {
|
||||||
|
'Стандарт': 0,
|
||||||
|
'Делюкс': 1,
|
||||||
|
'Полулюкс': 2,
|
||||||
|
'Люкс': 3,
|
||||||
|
'Пентхаус': 4,
|
||||||
|
'Апартаменты': 5,
|
||||||
|
}
|
||||||
|
|
||||||
function getRoomTypeColor(type: string): string {
|
function getRoomTypeColor(type: string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
'Стандарт': 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300',
|
'Стандарт': '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 [dragStart, setDragStart] = useState<{ roomId: string; dayIdx: number } | null>(null)
|
||||||
const [dragEnd, setDragEnd] = useState<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
|
// Modals
|
||||||
const [bookingModalDraft, setBookingModalDraft] = useState<DraftBooking | null>(null)
|
const [bookingModalDraft, setBookingModalDraft] = useState<DraftBooking | null>(null)
|
||||||
const [selectedBooking, setSelectedBooking] = useState<Booking | 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))
|
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 shiftDays = (n: number) => setStartDate(d => addDays(d, n))
|
||||||
|
|
||||||
const getBlockStyle = (booking: Booking) => {
|
const getBlockStyle = (booking: Booking) => {
|
||||||
@@ -219,6 +242,14 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const today = format(new Date(), 'yyyy-MM-dd')
|
const today = format(new Date(), 'yyyy-MM-dd')
|
||||||
@@ -277,116 +308,147 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Room rows */}
|
{/* Room rows grouped by category */}
|
||||||
{rooms.map(room => {
|
{(() => {
|
||||||
const roomBookings = bookings.filter(b => b.roomId === room.id)
|
const rows: ReactNode[] = []
|
||||||
const draftStyle = getDraftStyle(room.id)
|
let lastCategory = ''
|
||||||
|
sortedRooms.forEach(room => {
|
||||||
return (
|
// Category header
|
||||||
<div
|
if (room.type !== lastCategory) {
|
||||||
key={room.id}
|
lastCategory = room.type
|
||||||
className="flex border-b border-slate-200 dark:border-slate-700 group hover:bg-slate-50/50 dark:hover:bg-slate-800/30"
|
rows.push(
|
||||||
style={{ height: ROW_HEIGHT }}
|
<div
|
||||||
>
|
key={`cat-${room.type}`}
|
||||||
{/* Room label */}
|
className="flex sticky left-0 bg-slate-50 dark:bg-slate-700/40 border-b border-t border-slate-200 dark:border-slate-600"
|
||||||
<div
|
style={{ height: 28 }}
|
||||||
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
|
||||||
>
|
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"
|
||||||
<div>
|
style={{ width: LABEL_WIDTH }}
|
||||||
<div className="flex items-center gap-1.5">
|
>
|
||||||
<span className="text-sm font-bold text-slate-900 dark:text-slate-100">{room.number}</span>
|
<span className={cn('text-xs font-semibold uppercase tracking-wide', getRoomTypeColor(room.type).split(' ').filter(c => c.startsWith('text-')).join(' '))}>
|
||||||
{room.name && <span className="text-xs text-slate-500 dark:text-slate-400">{room.name}</span>}
|
{room.type}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={cn('text-xs px-1.5 py-0.5 rounded-md font-medium', getRoomTypeColor(room.type))}>
|
{dates.map((_, i) => (
|
||||||
{room.type}
|
<div key={i} className="shrink-0 border-r border-slate-200 dark:border-slate-600" style={{ width: CELL_WIDTH }} />
|
||||||
</span>
|
))}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Day cells + booking blocks */}
|
{/* Day cells + booking blocks */}
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
{dates.map((date, i) => {
|
{dates.map((date, i) => {
|
||||||
const isWe = date.getDay() === 0 || date.getDay() === 6
|
const isWe = date.getDay() === 0 || date.getDay() === 6
|
||||||
const isTod = isToday(date)
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={booking.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'shrink-0 h-full border-r border-slate-200 dark:border-slate-700 cursor-crosshair',
|
'booking-block border-l-4 transition-all duration-700',
|
||||||
isWe && 'bg-slate-50 dark:bg-slate-800/60',
|
BOOKING_STATUS_COLORS[booking.status],
|
||||||
isTod && 'bg-brand-50/50 dark:bg-brand-900/10',
|
isFading && 'opacity-0 scale-y-0',
|
||||||
)}
|
)}
|
||||||
style={{ width: CELL_WIDTH }}
|
style={{
|
||||||
onMouseDown={(e) => handleCellMouseDown(room.id, i, e)}
|
left: style.left + 2,
|
||||||
onMouseEnter={() => handleCellMouseEnter(i)}
|
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 */}
|
{/* Draft overlay */}
|
||||||
{roomBookings.map(booking => {
|
{draftStyle && (
|
||||||
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
|
<div
|
||||||
key={booking.id}
|
className="absolute top-2 bottom-2 bg-brand-400/40 border-2 border-brand-500 border-dashed rounded-md pointer-events-none"
|
||||||
className={cn(
|
style={{ left: draftStyle.left + 2, width: draftStyle.width }}
|
||||||
'booking-block border-l-4 transition-all duration-700',
|
/>
|
||||||
BOOKING_STATUS_COLORS[booking.status],
|
)}
|
||||||
isFading && 'opacity-0 scale-y-0',
|
</div>
|
||||||
)}
|
|
||||||
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 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
)
|
})
|
||||||
})}
|
return rows
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* ── Rental section ── */}
|
{/* ── Rental section ── */}
|
||||||
{rentalObjects && rentalObjects.length > 0 && (
|
{rentalObjects && rentalObjects.length > 0 && (
|
||||||
@@ -414,7 +476,7 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
|
|||||||
return (
|
return (
|
||||||
<div key={obj.id}
|
<div key={obj.id}
|
||||||
className="flex border-b border-slate-200 dark:border-slate-700"
|
className="flex border-b border-slate-200 dark:border-slate-700"
|
||||||
style={{ height: ROW_HEIGHT }}>
|
style={{ height: rowHeight }}>
|
||||||
{/* Label */}
|
{/* Label */}
|
||||||
<div
|
<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"
|
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',
|
isWe && 'bg-amber-50/30 dark:bg-amber-900/10',
|
||||||
isTod && 'bg-brand-50/40 dark:bg-brand-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 ? (
|
{hasFullDay ? (
|
||||||
/* Full day booking */
|
/* Full day booking */
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import { Navigate, useNavigate } from 'react-router-dom'
|
import { Navigate, useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Hotel, Eye, EyeOff, Sun, Moon, AlertCircle,
|
Hotel, Eye, EyeOff, Sun, Moon, AlertCircle,
|
||||||
@@ -14,6 +14,151 @@ const DEMO_EMAILS = [
|
|||||||
'admin@hotelsync.io',
|
'admin@hotelsync.io',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// ── Tetris overlay ─────────────────────────────────────────────────────────────
|
||||||
|
const TETRIS_LABELS = [
|
||||||
|
'Шахматка', 'Бронирования', 'Гости', 'Номера', 'Уборка',
|
||||||
|
'Каналы', 'Отчёты', 'Веб-сайт', 'Виджет', 'Касса',
|
||||||
|
'Отзывы', 'Сервис', 'Аренда', 'Тарифы', 'Скидки',
|
||||||
|
'Лояльность', 'Тех.перерывы', 'Цены', 'Документы', 'Модули',
|
||||||
|
]
|
||||||
|
const TETRIS_COLORS = [
|
||||||
|
'#1e3a8a', '#14532d', '#713f12', '#3b0764', '#0f172a',
|
||||||
|
'#134e4a', '#4a044e', '#1c1917', '#7f1d1d', '#0c4a6e',
|
||||||
|
]
|
||||||
|
|
||||||
|
function roundRectPath(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
x: number, y: number, w: number, h: number, r: number,
|
||||||
|
) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(x + r, y)
|
||||||
|
ctx.lineTo(x + w - r, y)
|
||||||
|
ctx.arcTo(x + w, y, x + w, y + r, r)
|
||||||
|
ctx.lineTo(x + w, y + h - r)
|
||||||
|
ctx.arcTo(x + w, y + h, x + w - r, y + h, r)
|
||||||
|
ctx.lineTo(x + r, y + h)
|
||||||
|
ctx.arcTo(x, y + h, x, y + h - r, r)
|
||||||
|
ctx.lineTo(x, y + r)
|
||||||
|
ctx.arcTo(x, y, x + r, y, r)
|
||||||
|
ctx.closePath()
|
||||||
|
}
|
||||||
|
|
||||||
|
function TetrisOverlay() {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let rafId = 0
|
||||||
|
|
||||||
|
const delay = setTimeout(() => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
console.log('[Tetris] canvas ref:', canvas)
|
||||||
|
if (!canvas) return
|
||||||
|
const parent = canvas.parentElement
|
||||||
|
console.log('[Tetris] parent:', parent, 'size:', parent?.clientWidth, 'x', parent?.clientHeight)
|
||||||
|
if (!parent) return
|
||||||
|
|
||||||
|
const W = parent.clientWidth
|
||||||
|
const H = parent.clientHeight
|
||||||
|
canvas.width = W
|
||||||
|
canvas.height = H
|
||||||
|
console.log('[Tetris] started, canvas size:', W, 'x', H)
|
||||||
|
|
||||||
|
const TILE_W = 84
|
||||||
|
const TILE_H = 252 // 3× ratio
|
||||||
|
const GAP = 6
|
||||||
|
const cols = Math.max(1, Math.floor(W / (TILE_W + GAP)))
|
||||||
|
const offsetX = (W - cols * (TILE_W + GAP) + GAP) / 2
|
||||||
|
const colX = (i: number) => offsetX + i * (TILE_W + GAP)
|
||||||
|
|
||||||
|
// Tracks bottom y of the topmost settled tile per column (starts at canvas bottom)
|
||||||
|
const colFloor = new Array<number>(cols).fill(H)
|
||||||
|
|
||||||
|
interface Tile { col: number; y: number; vy: number; label: string; color: string }
|
||||||
|
const falling: Tile[] = []
|
||||||
|
const settled: Tile[] = []
|
||||||
|
|
||||||
|
let spawnIn = 300
|
||||||
|
let last = performance.now()
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')!
|
||||||
|
|
||||||
|
const drawTile = (x: number, y: number, label: string, color: string) => {
|
||||||
|
roundRectPath(ctx, x, y, TILE_W, TILE_H, 10)
|
||||||
|
ctx.fillStyle = color
|
||||||
|
ctx.fill()
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.28)'
|
||||||
|
ctx.lineWidth = 1.5
|
||||||
|
ctx.stroke()
|
||||||
|
|
||||||
|
// Text rotated 90° so it reads along the long axis
|
||||||
|
ctx.save()
|
||||||
|
ctx.translate(x + TILE_W / 2, y + TILE_H / 2)
|
||||||
|
ctx.rotate(-Math.PI / 2)
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.92)'
|
||||||
|
ctx.font = 'bold 13px system-ui,sans-serif'
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.textBaseline = 'middle'
|
||||||
|
ctx.fillText(label, 0, 0)
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const dt = Math.min(now - last, 50)
|
||||||
|
last = now
|
||||||
|
spawnIn -= dt
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, W, H)
|
||||||
|
|
||||||
|
// Spawn
|
||||||
|
if (spawnIn <= 0 && falling.length < 4) {
|
||||||
|
const free = Array.from({ length: cols }, (_, i) => i).filter(i => colFloor[i] > TILE_H)
|
||||||
|
if (free.length > 0) {
|
||||||
|
const col = free[Math.floor(Math.random() * free.length)]
|
||||||
|
const label = TETRIS_LABELS[Math.floor(Math.random() * TETRIS_LABELS.length)]
|
||||||
|
const color = TETRIS_COLORS[Math.floor(Math.random() * TETRIS_COLORS.length)]
|
||||||
|
falling.push({ col, y: -TILE_H, vy: 1.4 + Math.random() * 0.8, label, color })
|
||||||
|
}
|
||||||
|
spawnIn = 500 + Math.random() * 500
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw settled
|
||||||
|
for (const t of settled) drawTile(colX(t.col), t.y, t.label, t.color)
|
||||||
|
|
||||||
|
// Update + draw falling
|
||||||
|
for (let i = falling.length - 1; i >= 0; i--) {
|
||||||
|
const t = falling[i]
|
||||||
|
t.y += t.vy * dt / 16
|
||||||
|
const land = colFloor[t.col] - TILE_H
|
||||||
|
if (t.y >= land) {
|
||||||
|
t.y = land
|
||||||
|
colFloor[t.col] = land
|
||||||
|
settled.push({ ...t })
|
||||||
|
falling.splice(i, 1)
|
||||||
|
} else {
|
||||||
|
drawTile(colX(t.col), t.y, t.label, t.color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rafId = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
|
||||||
|
rafId = requestAnimationFrame(tick)
|
||||||
|
}, 500)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(delay)
|
||||||
|
cancelAnimationFrame(rafId)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="absolute inset-0 pointer-events-none z-10"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
type LegalType = 'ooo' | 'ip' | 'ao' | 'pao' | 'other'
|
type LegalType = 'ooo' | 'ip' | 'ao' | 'pao' | 'other'
|
||||||
|
|
||||||
const LEGAL_TYPES: { id: LegalType; label: string }[] = [
|
const LEGAL_TYPES: { id: LegalType; label: string }[] = [
|
||||||
@@ -476,15 +621,16 @@ export function LoginPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-brand-50 via-white to-slate-100 dark:from-slate-900 dark:via-slate-900 dark:to-brand-950 flex">
|
<div className="min-h-screen bg-gradient-to-br from-brand-50 via-white to-slate-100 dark:from-slate-900 dark:via-slate-900 dark:to-brand-950 flex">
|
||||||
{/* Left panel — branding */}
|
{/* Left panel — branding */}
|
||||||
<div className="hidden lg:flex flex-col justify-between w-1/2 bg-brand-600 dark:bg-brand-800 p-12">
|
<div className="hidden lg:flex flex-col justify-between w-1/2 bg-brand-600 dark:bg-brand-800 p-12 relative overflow-hidden">
|
||||||
<div className="flex items-center gap-3">
|
<TetrisOverlay />
|
||||||
|
<div className="relative z-20 flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-xl bg-white/20 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-xl bg-white/20 flex items-center justify-center">
|
||||||
<Hotel size={22} className="text-white" />
|
<Hotel size={22} className="text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-2xl font-bold text-white">HotelSync</span>
|
<span className="text-2xl font-bold text-white">HotelSync</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="relative z-20">
|
||||||
<h1 className="text-4xl font-bold text-white leading-tight mb-4">
|
<h1 className="text-4xl font-bold text-white leading-tight mb-4">
|
||||||
Современная PMS система<br />для вашего отеля
|
Современная PMS система<br />для вашего отеля
|
||||||
</h1>
|
</h1>
|
||||||
@@ -523,7 +669,7 @@ export function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-brand-200 text-sm">© 2026 HotelSync · SaaS PMS Platform</p>
|
<p className="relative z-20 text-brand-200 text-sm">© 2026 HotelSync · SaaS PMS Platform</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right panel */}
|
{/* Right panel */}
|
||||||
|
|||||||
@@ -2,16 +2,17 @@ import { useState } from 'react'
|
|||||||
import {
|
import {
|
||||||
Plus, Edit2, Trash2, Wrench, AlertTriangle, CheckCircle2,
|
Plus, Edit2, Trash2, Wrench, AlertTriangle, CheckCircle2,
|
||||||
Clock, BedDouble, Dumbbell, Waves, UtensilsCrossed, Wifi,
|
Clock, BedDouble, Dumbbell, Waves, UtensilsCrossed, Wifi,
|
||||||
Car, Sparkles, Package, X,
|
Car, Sparkles, Package, X, CalendarClock,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Modal } from '../components/ui/Modal'
|
import { Modal } from '../components/ui/Modal'
|
||||||
import { cn } from '../lib/utils'
|
import { cn } from '../lib/utils'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { ru } from 'date-fns/locale'
|
import { ru } from 'date-fns/locale'
|
||||||
|
import { RENTAL_OBJECTS } from '../data/rentalData'
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type MaintenanceTarget = 'room' | 'service'
|
type MaintenanceTarget = 'room' | 'service' | 'rental'
|
||||||
type MaintenanceStatus = 'scheduled' | 'in_progress' | 'done' | 'cancelled'
|
type MaintenanceStatus = 'scheduled' | 'in_progress' | 'done' | 'cancelled'
|
||||||
|
|
||||||
interface MaintenanceRecord {
|
interface MaintenanceRecord {
|
||||||
@@ -118,6 +119,32 @@ const MOCK_RECORDS: MaintenanceRecord[] = [
|
|||||||
notes: 'Полный ремонт: полы, потолки, мебель',
|
notes: 'Полный ремонт: полы, потолки, мебель',
|
||||||
status: 'scheduled',
|
status: 'scheduled',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'm-6',
|
||||||
|
type: 'rental',
|
||||||
|
targetId: 'sauna',
|
||||||
|
targetLabel: '🛁 Баня',
|
||||||
|
startDate: '2026-03-16',
|
||||||
|
endDate: '2026-03-16',
|
||||||
|
startTime: '14:00',
|
||||||
|
endTime: '16:00',
|
||||||
|
reason: 'Подготовка между гостями — проветривание и уборка',
|
||||||
|
notes: 'Плановый тех. перерыв 2 часа после каждого заезда. Температурное охлаждение + мытьё полков.',
|
||||||
|
status: 'scheduled',
|
||||||
|
assignedTo: 'Горничная',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'm-7',
|
||||||
|
type: 'rental',
|
||||||
|
targetId: 'court',
|
||||||
|
targetLabel: '🎾 Теннисный корт',
|
||||||
|
startDate: '2026-03-18',
|
||||||
|
endDate: '2026-03-19',
|
||||||
|
reason: 'Замена покрытия',
|
||||||
|
notes: 'Полная замена резинового покрытия. Корт закрыт на 2 дня.',
|
||||||
|
status: 'scheduled',
|
||||||
|
assignedTo: 'Хозяйственная служба',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
// ─── Status helpers ───────────────────────────────────────────────────────────
|
// ─── Status helpers ───────────────────────────────────────────────────────────
|
||||||
@@ -170,7 +197,11 @@ function MaintenanceModal({
|
|||||||
const set = <K extends keyof typeof form>(k: K, v: typeof form[K]) =>
|
const set = <K extends keyof typeof form>(k: K, v: typeof form[K]) =>
|
||||||
setForm(prev => ({ ...prev, [k]: v }))
|
setForm(prev => ({ ...prev, [k]: v }))
|
||||||
|
|
||||||
const targets = form.type === 'room' ? MOCK_ROOMS : SERVICES.map(s => ({ id: s.id, label: s.label }))
|
const targets = form.type === 'room'
|
||||||
|
? MOCK_ROOMS
|
||||||
|
: form.type === 'rental'
|
||||||
|
? RENTAL_OBJECTS.map(o => ({ id: o.id, label: `${o.icon} ${o.name}` }))
|
||||||
|
: SERVICES.map(s => ({ id: s.id, label: s.label }))
|
||||||
|
|
||||||
const handleTargetChange = (id: string) => {
|
const handleTargetChange = (id: string) => {
|
||||||
const t = targets.find(t => t.id === id)
|
const t = targets.find(t => t.id === id)
|
||||||
@@ -200,17 +231,21 @@ function MaintenanceModal({
|
|||||||
{/* Type selector */}
|
{/* Type selector */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Тип</label>
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Тип</label>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{([
|
{([
|
||||||
{ id: 'room' as const, label: 'Номер', icon: BedDouble },
|
{ id: 'room' as const, label: 'Номер', icon: BedDouble },
|
||||||
{ id: 'service' as const, label: 'Услуга', icon: Wrench },
|
{ id: 'service' as const, label: 'Услуга', icon: Wrench },
|
||||||
|
{ id: 'rental' as const, label: 'Аренда', icon: CalendarClock },
|
||||||
]).map(t => (
|
]).map(t => (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={t.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
set('type', t.id)
|
set('type', t.id)
|
||||||
const def = t.id === 'room' ? MOCK_ROOMS[0] : { id: SERVICES[0].id, label: SERVICES[0].label }
|
let def: { id: string; label: string }
|
||||||
|
if (t.id === 'room') def = MOCK_ROOMS[0]
|
||||||
|
else if (t.id === 'rental') def = { id: RENTAL_OBJECTS[0].id, label: `${RENTAL_OBJECTS[0].icon} ${RENTAL_OBJECTS[0].name}` }
|
||||||
|
else def = { id: SERVICES[0].id, label: SERVICES[0].label }
|
||||||
set('targetId', def.id); set('targetLabel', def.label)
|
set('targetId', def.id); set('targetLabel', def.label)
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -250,8 +285,8 @@ function MaintenanceModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Time (for services) */}
|
{/* Time (for services and rental) */}
|
||||||
{form.type === 'service' && (
|
{(form.type === 'service' || form.type === 'rental') && (
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Начало (время)</label>
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Начало (время)</label>
|
||||||
@@ -309,7 +344,7 @@ export function MaintenancePage() {
|
|||||||
const [records, setRecords] = useState<MaintenanceRecord[]>(MOCK_RECORDS)
|
const [records, setRecords] = useState<MaintenanceRecord[]>(MOCK_RECORDS)
|
||||||
const [modal, setModal] = useState<'create' | MaintenanceRecord | null>(null)
|
const [modal, setModal] = useState<'create' | MaintenanceRecord | null>(null)
|
||||||
const [delTarget, setDel] = useState<MaintenanceRecord | null>(null)
|
const [delTarget, setDel] = useState<MaintenanceRecord | null>(null)
|
||||||
const [activeTab, setActiveTab] = useState<'all' | 'room' | 'service'>('all')
|
const [activeTab, setActiveTab] = useState<'all' | 'room' | 'service' | 'rental'>('all')
|
||||||
|
|
||||||
const save = (data: Omit<MaintenanceRecord, 'id'>) => {
|
const save = (data: Omit<MaintenanceRecord, 'id'>) => {
|
||||||
if (typeof modal === 'object' && modal !== null) {
|
if (typeof modal === 'object' && modal !== null) {
|
||||||
@@ -345,10 +380,10 @@ export function MaintenancePage() {
|
|||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-4 gap-4">
|
<div className="grid grid-cols-4 gap-4">
|
||||||
{[
|
{[
|
||||||
{ label: 'Всего записей', value: records.length, cls: '' },
|
{ label: 'Всего записей', value: records.length, cls: '' },
|
||||||
{ label: 'Активных', value: active.length, cls: 'text-amber-600 dark:text-amber-400' },
|
{ label: 'Активных', value: active.length, cls: 'text-amber-600 dark:text-amber-400' },
|
||||||
{ label: 'Номеров на ремонте', value: records.filter(r => r.type === 'room' && r.status !== 'done' && r.status !== 'cancelled').length, cls: 'text-red-600 dark:text-red-400' },
|
{ label: 'Номеров на ремонте', value: records.filter(r => r.type === 'room' && r.status !== 'done' && r.status !== 'cancelled').length, cls: 'text-red-600 dark:text-red-400' },
|
||||||
{ label: 'Услуг недоступно', value: records.filter(r => r.type === 'service' && r.status !== 'done' && r.status !== 'cancelled').length, cls: 'text-blue-600 dark:text-blue-400' },
|
{ label: 'Услуг / аренды', value: records.filter(r => r.type !== 'room' && r.status !== 'done' && r.status !== 'cancelled').length, cls: 'text-blue-600 dark:text-blue-400' },
|
||||||
].map(s => (
|
].map(s => (
|
||||||
<div key={s.label} className="card p-4 text-center">
|
<div key={s.label} className="card p-4 text-center">
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400">{s.label}</p>
|
<p className="text-xs text-slate-500 dark:text-slate-400">{s.label}</p>
|
||||||
@@ -378,6 +413,7 @@ export function MaintenancePage() {
|
|||||||
{ id: 'all' as const, label: `Все (${records.length})` },
|
{ id: 'all' as const, label: `Все (${records.length})` },
|
||||||
{ id: 'room' as const, label: `Номера (${records.filter(r => r.type === 'room').length})` },
|
{ id: 'room' as const, label: `Номера (${records.filter(r => r.type === 'room').length})` },
|
||||||
{ id: 'service' as const, label: `Услуги (${records.filter(r => r.type === 'service').length})` },
|
{ id: 'service' as const, label: `Услуги (${records.filter(r => r.type === 'service').length})` },
|
||||||
|
{ id: 'rental' as const, label: `Аренда (${records.filter(r => r.type === 'rental').length})` },
|
||||||
]).map(t => (
|
]).map(t => (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={t.id}
|
||||||
@@ -416,11 +452,15 @@ export function MaintenancePage() {
|
|||||||
'w-10 h-10 rounded-xl flex items-center justify-center shrink-0',
|
'w-10 h-10 rounded-xl flex items-center justify-center shrink-0',
|
||||||
record.type === 'room'
|
record.type === 'room'
|
||||||
? 'bg-slate-100 dark:bg-slate-700'
|
? 'bg-slate-100 dark:bg-slate-700'
|
||||||
: 'bg-blue-50 dark:bg-blue-900/20',
|
: record.type === 'rental'
|
||||||
|
? 'bg-emerald-50 dark:bg-emerald-900/20'
|
||||||
|
: 'bg-blue-50 dark:bg-blue-900/20',
|
||||||
)}>
|
)}>
|
||||||
{record.type === 'room'
|
{record.type === 'room'
|
||||||
? <BedDouble size={18} className="text-slate-500 dark:text-slate-400" />
|
? <BedDouble size={18} className="text-slate-500 dark:text-slate-400" />
|
||||||
: svc ? <svc.icon size={18} className="text-blue-500" /> : <Wrench size={18} className="text-blue-500" />
|
: record.type === 'rental'
|
||||||
|
? <CalendarClock size={18} className="text-emerald-500" />
|
||||||
|
: svc ? <svc.icon size={18} className="text-blue-500" /> : <Wrench size={18} className="text-blue-500" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user