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,4 +1,4 @@
import { useState } from 'react'
import { useState, useRef, useEffect } from 'react'
import { Navigate, useNavigate } from 'react-router-dom'
import {
Hotel, Eye, EyeOff, Sun, Moon, AlertCircle,
@@ -14,6 +14,151 @@ const DEMO_EMAILS = [
'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'
const LEGAL_TYPES: { id: LegalType; label: string }[] = [
@@ -476,15 +621,16 @@ export function LoginPage() {
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">
{/* 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="flex items-center gap-3">
<div className="hidden lg:flex flex-col justify-between w-1/2 bg-brand-600 dark:bg-brand-800 p-12 relative overflow-hidden">
<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">
<Hotel size={22} className="text-white" />
</div>
<span className="text-2xl font-bold text-white">HotelSync</span>
</div>
<div>
<div className="relative z-20">
<h1 className="text-4xl font-bold text-white leading-tight mb-4">
Современная PMS система<br />для вашего отеля
</h1>
@@ -523,7 +669,7 @@ export function LoginPage() {
)}
</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>
{/* Right panel */}

View File

@@ -2,16 +2,17 @@ import { useState } from 'react'
import {
Plus, Edit2, Trash2, Wrench, AlertTriangle, CheckCircle2,
Clock, BedDouble, Dumbbell, Waves, UtensilsCrossed, Wifi,
Car, Sparkles, Package, X,
Car, Sparkles, Package, X, CalendarClock,
} from 'lucide-react'
import { Modal } from '../components/ui/Modal'
import { cn } from '../lib/utils'
import { format } from 'date-fns'
import { ru } from 'date-fns/locale'
import { RENTAL_OBJECTS } from '../data/rentalData'
// ─── Types ────────────────────────────────────────────────────────────────────
type MaintenanceTarget = 'room' | 'service'
type MaintenanceTarget = 'room' | 'service' | 'rental'
type MaintenanceStatus = 'scheduled' | 'in_progress' | 'done' | 'cancelled'
interface MaintenanceRecord {
@@ -118,6 +119,32 @@ const MOCK_RECORDS: MaintenanceRecord[] = [
notes: 'Полный ремонт: полы, потолки, мебель',
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 ───────────────────────────────────────────────────────────
@@ -170,7 +197,11 @@ function MaintenanceModal({
const set = <K extends keyof typeof form>(k: K, v: typeof form[K]) =>
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 t = targets.find(t => t.id === id)
@@ -200,17 +231,21 @@ function MaintenanceModal({
{/* Type selector */}
<div>
<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: 'service' as const, label: 'Услуга', icon: Wrench },
{ id: 'rental' as const, label: 'Аренда', icon: CalendarClock },
]).map(t => (
<button
key={t.id}
type="button"
onClick={() => {
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)
}}
className={cn(
@@ -250,8 +285,8 @@ function MaintenanceModal({
</div>
</div>
{/* Time (for services) */}
{form.type === 'service' && (
{/* Time (for services and rental) */}
{(form.type === 'service' || form.type === 'rental') && (
<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>
@@ -309,7 +344,7 @@ export function MaintenancePage() {
const [records, setRecords] = useState<MaintenanceRecord[]>(MOCK_RECORDS)
const [modal, setModal] = useState<'create' | 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'>) => {
if (typeof modal === 'object' && modal !== null) {
@@ -345,10 +380,10 @@ export function MaintenancePage() {
{/* Stats */}
<div className="grid grid-cols-4 gap-4">
{[
{ label: 'Всего записей', value: records.length, cls: '' },
{ 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 === 'service' && r.status !== 'done' && r.status !== 'cancelled').length, cls: 'text-blue-600 dark:text-blue-400' },
{ label: 'Всего записей', value: records.length, cls: '' },
{ 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-blue-600 dark:text-blue-400' },
].map(s => (
<div key={s.label} className="card p-4 text-center">
<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: 'room' as const, label: `Номера (${records.filter(r => r.type === 'room').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 => (
<button
key={t.id}
@@ -416,11 +452,15 @@ export function MaintenancePage() {
'w-10 h-10 rounded-xl flex items-center justify-center shrink-0',
record.type === 'room'
? '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'
? <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>