Initial commit: HotelSync PMS v0.1.0
- React 18 + TypeScript + Vite + Tailwind CSS - Шахматка бронирований (drag-to-book) - Страницы: Calendar, Bookings, Rooms, Housekeeping, Channels, API Docs, Settings - Роли: super_admin, hotel_manager, housekeeper - Светлая/тёмная тема - Docker + Nginx конфигурация - Лендинг hotelsync.ru
This commit is contained in:
190
src/components/bookings/BookingDetailPanel.tsx
Normal file
190
src/components/bookings/BookingDetailPanel.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { X, Mail, Phone, Calendar, Users, CreditCard, Tag, Edit2, CheckCircle, XCircle } from 'lucide-react'
|
||||
import { cn, BOOKING_STATUS_BADGE, BOOKING_STATUS_LABELS, SOURCE_COLORS, SOURCE_LABELS, formatCurrency, nightsCount } from '../../lib/utils'
|
||||
import type { Booking, Room } from '../../types'
|
||||
import { Badge } from '../ui/Badge'
|
||||
|
||||
interface BookingDetailPanelProps {
|
||||
booking: Booking
|
||||
room?: Room
|
||||
onClose: () => void
|
||||
onUpdate: (id: string, data: Partial<Booking>) => void
|
||||
}
|
||||
|
||||
export function BookingDetailPanel({ booking, room, onClose, onUpdate }: BookingDetailPanelProps) {
|
||||
const nights = nightsCount(booking.checkIn, booking.checkOut)
|
||||
const balance = booking.totalAmount - booking.paidAmount
|
||||
|
||||
const setStatus = (status: typeof booking.status) => onUpdate(booking.id, { status })
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 z-40" onClick={onClose} />
|
||||
|
||||
{/* Panel */}
|
||||
<div className="fixed right-0 top-0 bottom-0 z-50 w-96 bg-white dark:bg-slate-800 border-l border-slate-200 dark:border-slate-700 shadow-2xl flex flex-col animate-slide-in">
|
||||
{/* Header */}
|
||||
<div className={cn(
|
||||
'px-5 py-4 border-b border-slate-200 dark:border-slate-700',
|
||||
'flex items-center justify-between shrink-0',
|
||||
)}>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-slate-900 dark:text-slate-100">
|
||||
{booking.guestName}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge className={BOOKING_STATUS_BADGE[booking.status]}>
|
||||
{BOOKING_STATUS_LABELS[booking.status]}
|
||||
</Badge>
|
||||
<Badge className={SOURCE_COLORS[booking.source]}>
|
||||
{SOURCE_LABELS[booking.source]}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="btn-ghost p-1.5">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
|
||||
{/* Room */}
|
||||
{room && (
|
||||
<div className="rounded-xl bg-slate-50 dark:bg-slate-700/40 p-3">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium mb-1">Номер</p>
|
||||
<p className="font-semibold text-slate-900 dark:text-slate-100">
|
||||
№{room.number} — {room.type}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Этаж {room.floor} · {room.bedType} bed</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dates */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium flex items-center gap-1 mb-1">
|
||||
<Calendar size={12} /> Заезд
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||
{new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long' }).format(new Date(booking.checkIn))}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium flex items-center gap-1 mb-1">
|
||||
<Calendar size={12} /> Выезд
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||
{new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long' }).format(new Date(booking.checkOut))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 -mt-3">
|
||||
{nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'}
|
||||
</p>
|
||||
|
||||
{/* Guests */}
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium flex items-center gap-1 mb-1">
|
||||
<Users size={12} /> Гости
|
||||
</p>
|
||||
<p className="text-sm text-slate-900 dark:text-slate-100">
|
||||
{booking.adults} взр.{booking.children > 0 ? ` · ${booking.children} дет.` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium">Контакты</p>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<Mail size={13} className="text-slate-400" />
|
||||
{booking.guestEmail || '—'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment */}
|
||||
<div className="rounded-xl border border-slate-200 dark:border-slate-600 p-3 space-y-2">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium flex items-center gap-1">
|
||||
<CreditCard size={12} /> Оплата
|
||||
</p>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-600 dark:text-slate-400">Стоимость</span>
|
||||
<span className="font-semibold text-slate-900 dark:text-slate-100">
|
||||
{formatCurrency(booking.totalAmount)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-600 dark:text-slate-400">Оплачено</span>
|
||||
<span className="font-semibold text-emerald-600 dark:text-emerald-400">
|
||||
{formatCurrency(booking.paidAmount)}
|
||||
</span>
|
||||
</div>
|
||||
{balance > 0 && (
|
||||
<div className="flex justify-between text-sm border-t border-slate-200 dark:border-slate-600 pt-2">
|
||||
<span className="text-slate-600 dark:text-slate-400">Остаток</span>
|
||||
<span className="font-bold text-red-600 dark:text-red-400">
|
||||
{formatCurrency(balance)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{booking.notes && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium mb-1 flex items-center gap-1">
|
||||
<Tag size={12} /> Примечания
|
||||
</p>
|
||||
<p className="text-sm text-slate-700 dark:text-slate-300 bg-slate-50 dark:bg-slate-700/40 rounded-lg p-2.5">
|
||||
{booking.notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Channel ID */}
|
||||
{booking.channelBookingId && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-0.5">ID канала</p>
|
||||
<code className="text-xs text-brand-600 dark:text-brand-400 bg-brand-50 dark:bg-brand-900/20 px-2 py-1 rounded">
|
||||
{booking.channelBookingId}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="shrink-0 px-5 py-4 border-t border-slate-200 dark:border-slate-700 space-y-2">
|
||||
{booking.status === 'confirmed' && (
|
||||
<button
|
||||
onClick={() => setStatus('checked_in')}
|
||||
className="w-full btn-primary justify-center"
|
||||
>
|
||||
<CheckCircle size={15} />
|
||||
Заселить
|
||||
</button>
|
||||
)}
|
||||
{booking.status === 'checked_in' && (
|
||||
<button
|
||||
onClick={() => setStatus('checked_out')}
|
||||
className="w-full btn-primary justify-center bg-emerald-600 hover:bg-emerald-700"
|
||||
>
|
||||
<CheckCircle size={15} />
|
||||
Выселить
|
||||
</button>
|
||||
)}
|
||||
{(booking.status === 'confirmed' || booking.status === 'inquiry') && (
|
||||
<button
|
||||
onClick={() => setStatus('cancelled')}
|
||||
className="w-full btn-secondary justify-center text-red-600 dark:text-red-400 border-red-200 dark:border-red-800 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<XCircle size={15} />
|
||||
Отменить
|
||||
</button>
|
||||
)}
|
||||
<p className="text-xs text-center text-slate-400 dark:text-slate-500">
|
||||
ID: {booking.id} · Создано: {booking.createdAt}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
232
src/components/bookings/BookingModal.tsx
Normal file
232
src/components/bookings/BookingModal.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import { useState } from 'react'
|
||||
import { format } from 'date-fns'
|
||||
import { Modal } from '../ui/Modal'
|
||||
import { cn, BOOKING_STATUS_LABELS, SOURCE_LABELS } from '../../lib/utils'
|
||||
import type { Booking, DraftBooking, Room, BookingStatus, BookingSource } from '../../types'
|
||||
|
||||
interface BookingModalProps {
|
||||
open: boolean
|
||||
draft: DraftBooking
|
||||
rooms: Room[]
|
||||
onClose: () => void
|
||||
onSave: (data: Partial<Booking>) => void
|
||||
existing?: Booking
|
||||
}
|
||||
|
||||
export function BookingModal({ open, draft, rooms, onClose, onSave, existing }: BookingModalProps) {
|
||||
const [form, setForm] = useState({
|
||||
roomId: existing?.roomId ?? draft.roomId,
|
||||
guestName: existing?.guestName ?? '',
|
||||
guestEmail: existing?.guestEmail ?? '',
|
||||
checkIn: existing?.checkIn ?? draft.checkIn,
|
||||
checkOut: existing?.checkOut ?? draft.checkOut,
|
||||
adults: existing?.adults ?? 2,
|
||||
children: existing?.children ?? 0,
|
||||
source: (existing?.source ?? 'direct') as BookingSource,
|
||||
status: (existing?.status ?? 'confirmed') as BookingStatus,
|
||||
notes: existing?.notes ?? '',
|
||||
})
|
||||
|
||||
const room = rooms.find(r => r.id === form.roomId)
|
||||
const nights = form.checkIn && form.checkOut
|
||||
? Math.max(0, (new Date(form.checkOut).getTime() - new Date(form.checkIn).getTime()) / 86400000)
|
||||
: 0
|
||||
const total = (room?.baseRate ?? 0) * nights
|
||||
|
||||
const set = <K extends keyof typeof form>(k: K, v: typeof form[K]) =>
|
||||
setForm(prev => ({ ...prev, [k]: v }))
|
||||
|
||||
const handleSave = () => {
|
||||
if (!form.guestName || !form.checkIn || !form.checkOut) return
|
||||
onSave({
|
||||
...form,
|
||||
totalAmount: total,
|
||||
paidAmount: existing?.paidAmount ?? 0,
|
||||
id: existing?.id ?? `b-${Date.now()}`,
|
||||
hotelId: 'hotel-1',
|
||||
guestId: existing?.guestId ?? `g-${Date.now()}`,
|
||||
createdAt: existing?.createdAt ?? format(new Date(), 'yyyy-MM-dd'),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={existing ? 'Редактировать бронирование' : 'Новое бронирование'}
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<button onClick={onClose} className="btn-secondary">Отмена</button>
|
||||
<button onClick={handleSave} className="btn-primary" disabled={!form.guestName}>
|
||||
{existing ? 'Сохранить' : 'Создать бронирование'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Room */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Номер
|
||||
</label>
|
||||
<select
|
||||
value={form.roomId}
|
||||
onChange={e => set('roomId', e.target.value)}
|
||||
className="input"
|
||||
>
|
||||
{rooms.map(r => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.number} — {r.type} ({r.baseRate.toLocaleString('ru-RU')} ₽/ночь)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Guest */}
|
||||
<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="text"
|
||||
className="input"
|
||||
placeholder="Иван Иванов"
|
||||
value={form.guestName}
|
||||
onChange={e => set('guestName', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
className="input"
|
||||
placeholder="guest@example.com"
|
||||
value={form.guestEmail}
|
||||
onChange={e => set('guestEmail', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dates */}
|
||||
<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="date"
|
||||
className="input"
|
||||
value={form.checkIn}
|
||||
onChange={e => set('checkIn', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Выезд *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={form.checkOut}
|
||||
onChange={e => set('checkOut', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Guests count */}
|
||||
<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>
|
||||
<input
|
||||
type="number" min={1} max={room?.maxGuests ?? 6}
|
||||
className="input"
|
||||
value={form.adults}
|
||||
onChange={e => set('adults', parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
</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} max={4}
|
||||
className="input"
|
||||
value={form.children}
|
||||
onChange={e => set('children', 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>
|
||||
<select
|
||||
className="input"
|
||||
value={form.status}
|
||||
onChange={e => set('status', e.target.value as BookingStatus)}
|
||||
>
|
||||
{(Object.entries(BOOKING_STATUS_LABELS) as [BookingStatus, string][]).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source */}
|
||||
<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">
|
||||
{(Object.entries(SOURCE_LABELS) as [BookingSource, string][]).map(([k, v]) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
onClick={() => set('source', k)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors',
|
||||
form.source === k
|
||||
? 'bg-brand-600 text-white border-brand-600'
|
||||
: 'bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-brand-400',
|
||||
)}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Примечания
|
||||
</label>
|
||||
<textarea
|
||||
className="input resize-none"
|
||||
rows={2}
|
||||
placeholder="Дополнительные пожелания..."
|
||||
value={form.notes}
|
||||
onChange={e => set('notes', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{nights > 0 && room && (
|
||||
<div className="rounded-xl bg-slate-50 dark:bg-slate-700/50 px-4 py-3 flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-300">
|
||||
{nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'} × {room.baseRate.toLocaleString('ru-RU')} ₽
|
||||
</span>
|
||||
<span className="text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||
{total.toLocaleString('ru-RU')} ₽
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
332
src/components/calendar/BookingCalendar.tsx
Normal file
332
src/components/calendar/BookingCalendar.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import { addDays, format, startOfDay, differenceInDays, parseISO, isToday } from 'date-fns'
|
||||
import { ru } from 'date-fns/locale'
|
||||
import { ChevronLeft, ChevronRight, Plus } from 'lucide-react'
|
||||
import { cn, BOOKING_STATUS_COLORS, BOOKING_STATUS_LABELS, SOURCE_LABELS } from '../../lib/utils'
|
||||
import type { Room, Booking, DraftBooking } from '../../types'
|
||||
import { BookingModal } from '../bookings/BookingModal'
|
||||
import { BookingDetailPanel } from '../bookings/BookingDetailPanel'
|
||||
|
||||
const CELL_WIDTH = 52 // px per day column
|
||||
const ROW_HEIGHT = 56 // px per room row
|
||||
const LABEL_WIDTH = 160 // px for room label column
|
||||
const DAYS_VISIBLE = 30 // default window
|
||||
|
||||
interface BookingCalendarProps {
|
||||
rooms: Room[]
|
||||
bookings: Booking[]
|
||||
onBookingCreate: (b: Partial<Booking>) => void
|
||||
onBookingUpdate: (id: string, b: Partial<Booking>) => void
|
||||
}
|
||||
|
||||
function getRoomTypeColor(type: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'Стандарт': 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300',
|
||||
'Делюкс': 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300',
|
||||
'Полулюкс': 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300',
|
||||
'Люкс': 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300',
|
||||
'Пентхаус': 'bg-rose-100 dark:bg-rose-900/30 text-rose-700 dark:text-rose-300',
|
||||
'Апартаменты': 'bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300',
|
||||
}
|
||||
return map[type] ?? 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300'
|
||||
}
|
||||
|
||||
export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpdate }: BookingCalendarProps) {
|
||||
const [startDate, setStartDate] = useState(() => startOfDay(new Date()))
|
||||
const [days] = useState(DAYS_VISIBLE)
|
||||
|
||||
// Drag-to-book state
|
||||
const [draft, setDraft] = useState<DraftBooking | null>(null)
|
||||
const [dragStart, setDragStart] = useState<{ roomId: string; dayIdx: number } | null>(null)
|
||||
const [dragEnd, setDragEnd] = useState<number | null>(null)
|
||||
|
||||
// Modals
|
||||
const [bookingModalDraft, setBookingModalDraft] = useState<DraftBooking | null>(null)
|
||||
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null)
|
||||
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Generate date array
|
||||
const dates = Array.from({ length: days }, (_, i) => addDays(startDate, i))
|
||||
|
||||
// Navigate
|
||||
const shiftDays = (n: number) => setStartDate(d => addDays(d, n))
|
||||
|
||||
const jumpToToday = () => setStartDate(startOfDay(new Date()))
|
||||
|
||||
// Compute booking block position
|
||||
const getBlockStyle = (booking: Booking) => {
|
||||
const start = parseISO(booking.checkIn)
|
||||
const end = parseISO(booking.checkOut)
|
||||
const windowEnd = addDays(startDate, days)
|
||||
|
||||
const colStart = Math.max(0, differenceInDays(start, startDate))
|
||||
const colEnd = Math.min(days, differenceInDays(end, startDate))
|
||||
if (colStart >= days || colEnd <= 0) return null
|
||||
|
||||
return {
|
||||
left: colStart * CELL_WIDTH,
|
||||
width: (colEnd - colStart) * CELL_WIDTH - 4,
|
||||
}
|
||||
}
|
||||
|
||||
// Mouse handlers for drag-to-book
|
||||
const handleCellMouseDown = useCallback((roomId: string, dayIdx: number, e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return
|
||||
e.preventDefault()
|
||||
setDragStart({ roomId, dayIdx })
|
||||
setDragEnd(dayIdx)
|
||||
}, [])
|
||||
|
||||
const handleCellMouseEnter = useCallback((dayIdx: number) => {
|
||||
if (!dragStart) return
|
||||
setDragEnd(dayIdx)
|
||||
}, [dragStart])
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (!dragStart || dragEnd === null) return
|
||||
const minDay = Math.min(dragStart.dayIdx, dragEnd)
|
||||
const maxDay = Math.max(dragStart.dayIdx, dragEnd)
|
||||
const checkIn = format(addDays(startDate, minDay), 'yyyy-MM-dd')
|
||||
const checkOut = format(addDays(startDate, maxDay + 1), 'yyyy-MM-dd')
|
||||
setBookingModalDraft({ roomId: dragStart.roomId, checkIn, checkOut })
|
||||
setDragStart(null)
|
||||
setDragEnd(null)
|
||||
setDraft(null)
|
||||
}, [dragStart, dragEnd, startDate])
|
||||
|
||||
// Draft overlay bounds
|
||||
const getDraftStyle = (roomId: string) => {
|
||||
if (!dragStart || dragEnd === null || dragStart.roomId !== roomId) return null
|
||||
const minDay = Math.min(dragStart.dayIdx, dragEnd)
|
||||
const maxDay = Math.max(dragStart.dayIdx, dragEnd)
|
||||
return {
|
||||
left: minDay * CELL_WIDTH,
|
||||
width: (maxDay - minDay + 1) * CELL_WIDTH - 2,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full" onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp}>
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 shrink-0 flex-wrap gap-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button onClick={() => shiftDays(-7)} className="btn-ghost p-2"><ChevronLeft size={16} /></button>
|
||||
<button onClick={jumpToToday} className="btn-secondary px-3 py-1.5 text-xs">Сегодня</button>
|
||||
<button onClick={() => shiftDays(7)} className="btn-ghost p-2"><ChevronRight size={16} /></button>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300 capitalize">
|
||||
{format(startDate, 'LLLL yyyy', { locale: ru })}
|
||||
</span>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Legend */}
|
||||
<div className="hidden lg:flex items-center gap-3 text-xs text-slate-500">
|
||||
{(['confirmed', 'checked_in', 'checked_out', 'inquiry', 'cancelled'] as const).map(s => (
|
||||
<div key={s} className="flex items-center gap-1.5">
|
||||
<div className={cn('w-3 h-3 rounded-sm', BOOKING_STATUS_COLORS[s].split(' ')[0])} />
|
||||
<span>{BOOKING_STATUS_LABELS[s]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
const today = format(new Date(), 'yyyy-MM-dd')
|
||||
const tomorrow = format(addDays(new Date(), 1), 'yyyy-MM-dd')
|
||||
setBookingModalDraft({ roomId: rooms[0]?.id ?? '', checkIn: today, checkOut: tomorrow })
|
||||
}}
|
||||
className="btn-primary"
|
||||
>
|
||||
<Plus size={15} />
|
||||
<span className="hidden sm:inline">Новое бронирование</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="flex-1 overflow-auto" ref={gridRef}>
|
||||
<div style={{ minWidth: LABEL_WIDTH + days * CELL_WIDTH }}>
|
||||
|
||||
{/* Date header row */}
|
||||
<div
|
||||
className="flex sticky top-0 z-20 bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 shadow-sm"
|
||||
style={{ height: 48 }}
|
||||
>
|
||||
{/* Room label header */}
|
||||
<div
|
||||
className="shrink-0 sticky left-0 z-30 bg-white dark:bg-slate-800 border-r border-slate-200 dark:border-slate-700 flex items-center px-3"
|
||||
style={{ width: LABEL_WIDTH }}
|
||||
>
|
||||
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wide">Номер</span>
|
||||
</div>
|
||||
{/* Date cells */}
|
||||
{dates.map((date, i) => {
|
||||
const isWe = date.getDay() === 0 || date.getDay() === 6
|
||||
const isTod = isToday(date)
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'shrink-0 flex flex-col items-center justify-center border-r border-slate-200 dark:border-slate-700 select-none',
|
||||
isWe && 'bg-slate-50 dark:bg-slate-800/60',
|
||||
isTod && 'bg-brand-50 dark:bg-brand-900/20',
|
||||
)}
|
||||
style={{ width: CELL_WIDTH }}
|
||||
>
|
||||
<span className={cn(
|
||||
'text-xs font-medium',
|
||||
isTod ? 'text-brand-600 dark:text-brand-400' : isWe ? 'text-slate-400' : 'text-slate-500 dark:text-slate-400',
|
||||
)}>
|
||||
{format(date, 'EEE', { locale: ru }).slice(0, 2)}
|
||||
</span>
|
||||
<span className={cn(
|
||||
'text-sm font-bold',
|
||||
isTod
|
||||
? 'text-brand-600 dark:text-brand-400'
|
||||
: 'text-slate-700 dark:text-slate-200',
|
||||
)}>
|
||||
{format(date, 'd')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
<span className={cn(
|
||||
'text-xs px-1.5 py-0.5 rounded-md font-medium',
|
||||
getRoomTypeColor(room.type),
|
||||
)}>
|
||||
{room.type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-auto text-xs text-slate-400 dark:text-slate-500">
|
||||
{room.baseRate.toLocaleString('ru-RU')} ₽
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Day cells + booking blocks */}
|
||||
<div className="relative flex-1">
|
||||
{/* Grid cells */}
|
||||
<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))
|
||||
return (
|
||||
<div
|
||||
key={booking.id}
|
||||
className={cn(
|
||||
'booking-block border-l-4',
|
||||
BOOKING_STATUS_COLORS[booking.status],
|
||||
)}
|
||||
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}`}
|
||||
>
|
||||
<span className="truncate text-xs font-semibold opacity-95 drop-shadow-sm">
|
||||
{booking.guestName}
|
||||
</span>
|
||||
{style.width > 80 && (
|
||||
<span className="ml-2 opacity-75 text-[10px] shrink-0">
|
||||
{nights}н • {SOURCE_LABELS[booking.source]}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{/* Booking create modal */}
|
||||
{bookingModalDraft && (
|
||||
<BookingModal
|
||||
open={true}
|
||||
draft={bookingModalDraft}
|
||||
rooms={rooms}
|
||||
onClose={() => setBookingModalDraft(null)}
|
||||
onSave={(data) => {
|
||||
onBookingCreate(data)
|
||||
setBookingModalDraft(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Booking detail panel */}
|
||||
{selectedBooking && (
|
||||
<BookingDetailPanel
|
||||
booking={selectedBooking}
|
||||
room={rooms.find(r => r.id === selectedBooking.roomId)}
|
||||
onClose={() => setSelectedBooking(null)}
|
||||
onUpdate={(id, data) => {
|
||||
onBookingUpdate(id, data)
|
||||
setSelectedBooking(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
140
src/components/layout/Sidebar.tsx
Normal file
140
src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { NavLink, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
CalendarDays, BookOpen, BedDouble, Sparkles, Globe, Settings,
|
||||
FileText, LayoutDashboard, Building2, Users, X, Hotel,
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { MOCK_HOTELS } from '../../data/mockData'
|
||||
|
||||
interface SidebarProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function NavItem({
|
||||
to, icon: Icon, label, onClick,
|
||||
}: {
|
||||
to: string
|
||||
icon: React.ElementType
|
||||
label: string
|
||||
onClick?: () => void
|
||||
}) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
onClick={onClick}
|
||||
className={({ isActive }) => cn('sidebar-link', isActive && 'active')}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{label}</span>
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
|
||||
export function Sidebar({ open, onClose }: SidebarProps) {
|
||||
const { user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const hotel = user?.hotelId ? MOCK_HOTELS.find(h => h.id === user.hotelId) : null
|
||||
const slug = hotel?.slug ?? 'grand-palace'
|
||||
|
||||
const isAdmin = user?.role === 'super_admin'
|
||||
const isHousekeeper = user?.role === 'housekeeper'
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile backdrop */}
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 z-40 lg:hidden"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside className={cn(
|
||||
'fixed left-0 top-0 bottom-0 z-50 w-64 flex flex-col',
|
||||
'bg-white dark:bg-slate-800',
|
||||
'border-r border-slate-200 dark:border-slate-700',
|
||||
'transition-transform duration-200 ease-out',
|
||||
'lg:translate-x-0 lg:static lg:z-auto',
|
||||
open ? 'translate-x-0' : '-translate-x-full',
|
||||
)}>
|
||||
{/* Logo */}
|
||||
<div className="h-16 flex items-center justify-between px-4 border-b border-slate-200 dark:border-slate-700 shrink-0">
|
||||
<div className="flex items-center gap-2.5 cursor-pointer" onClick={() => navigate('/')}>
|
||||
<div className="w-8 h-8 rounded-lg bg-brand-600 flex items-center justify-center shrink-0">
|
||||
<Hotel size={16} className="text-white" />
|
||||
</div>
|
||||
<span className="text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||
Hotel<span className="text-brand-600">Next</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="lg:hidden btn-ghost p-1.5"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Hotel selector (non-admin) */}
|
||||
{hotel && (
|
||||
<div className="mx-3 mt-3 px-3 py-2.5 rounded-lg bg-slate-50 dark:bg-slate-700/50 border border-slate-200 dark:border-slate-600">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium mb-0.5">Текущий отель</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Building2 size={14} className="text-brand-600 shrink-0" />
|
||||
<span className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">
|
||||
{hotel.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-3 space-y-0.5">
|
||||
{isAdmin ? (
|
||||
<>
|
||||
<p className="px-3 pt-2 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||
Администрирование
|
||||
</p>
|
||||
<NavItem to="/admin" icon={LayoutDashboard} label="Дашборд" onClick={onClose} />
|
||||
<NavItem to="/admin/hotels" icon={Building2} label="Отели" onClick={onClose} />
|
||||
<NavItem to="/admin/users" icon={Users} label="Пользователи" onClick={onClose} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="px-3 pt-2 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||
Основное
|
||||
</p>
|
||||
<NavItem to={`/${slug}/calendar`} icon={CalendarDays} label="Шахматка" onClick={onClose} />
|
||||
{!isHousekeeper && (
|
||||
<NavItem to={`/${slug}/bookings`} icon={BookOpen} label="Бронирования" onClick={onClose} />
|
||||
)}
|
||||
<NavItem to={`/${slug}/housekeeping`} icon={Sparkles} label="Уборка" onClick={onClose} />
|
||||
{!isHousekeeper && (
|
||||
<>
|
||||
<p className="px-3 pt-3 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||
Управление
|
||||
</p>
|
||||
<NavItem to={`/${slug}/rooms`} icon={BedDouble} label="Номера" onClick={onClose} />
|
||||
<NavItem to={`/${slug}/channels`} icon={Globe} label="Каналы" onClick={onClose} />
|
||||
<NavItem to={`/${slug}/settings`} icon={Settings} label="Настройки" onClick={onClose} />
|
||||
<p className="px-3 pt-3 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||
Разработчикам
|
||||
</p>
|
||||
<NavItem to={`/${slug}/api-docs`} icon={FileText} label="API & Документация" onClick={onClose} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Version */}
|
||||
<div className="px-4 py-3 border-t border-slate-200 dark:border-slate-700 shrink-0">
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500">HotelSync v0.1.0 • SaaS PMS</p>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
95
src/components/layout/Topbar.tsx
Normal file
95
src/components/layout/Topbar.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Sun, Moon, Bell, LogOut, ChevronDown, Menu } from 'lucide-react'
|
||||
import { useTheme } from '../../contexts/ThemeContext'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
import { ROLE_LABELS } from '../../lib/utils'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
interface TopbarProps {
|
||||
onMenuToggle?: () => void
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function Topbar({ onMenuToggle, title }: TopbarProps) {
|
||||
const { theme, toggle } = useTheme()
|
||||
const { user, logout } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="h-16 shrink-0 bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 flex items-center px-4 gap-3 z-30">
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
onClick={onMenuToggle}
|
||||
className="lg:hidden btn-ghost p-2"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
|
||||
{/* Page title (mobile) */}
|
||||
{title && (
|
||||
<span className="lg:hidden font-semibold text-slate-900 dark:text-slate-100 truncate">
|
||||
{title}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Notification bell */}
|
||||
<button className="btn-ghost p-2 relative">
|
||||
<Bell size={18} />
|
||||
<span className="absolute top-1.5 right-1.5 w-2 h-2 rounded-full bg-red-500" />
|
||||
</button>
|
||||
|
||||
{/* Theme toggle */}
|
||||
<button onClick={toggle} className="btn-ghost p-2">
|
||||
{theme === 'light' ? <Moon size={18} /> : <Sun size={18} />}
|
||||
</button>
|
||||
|
||||
{/* User menu */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setUserMenuOpen(v => !v)}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-brand-600 flex items-center justify-center text-white text-sm font-semibold shrink-0">
|
||||
{user?.name.charAt(0) ?? 'U'}
|
||||
</div>
|
||||
<div className="hidden sm:block text-left">
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 leading-tight">
|
||||
{user?.name}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{user ? ROLE_LABELS[user.role] : ''}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown size={14} className="text-slate-400 hidden sm:block" />
|
||||
</button>
|
||||
|
||||
{userMenuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setUserMenuOpen(false)} />
|
||||
<div className="absolute right-0 top-full mt-1 w-52 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl shadow-xl z-20 py-1 animate-fade-in">
|
||||
<div className="px-3 py-2 border-b border-slate-100 dark:border-slate-700">
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{user?.name}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">{user?.email}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||
>
|
||||
<LogOut size={14} />
|
||||
Выйти
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
14
src/components/ui/Badge.tsx
Normal file
14
src/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { cn } from '../../lib/utils'
|
||||
|
||||
interface BadgeProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Badge({ children, className }: BadgeProps) {
|
||||
return (
|
||||
<span className={cn('badge', className)}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
75
src/components/ui/Modal.tsx
Normal file
75
src/components/ui/Modal.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useEffect } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '../../lib/utils'
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
footer?: React.ReactNode
|
||||
}
|
||||
|
||||
const SIZES = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-2xl',
|
||||
}
|
||||
|
||||
export function Modal({ open, onClose, title, children, size = 'md', footer }: ModalProps) {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
if (open) document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [open, onClose])
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = open ? 'hidden' : ''
|
||||
return () => { document.body.style.overflow = '' }
|
||||
}, [open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm animate-fade-in"
|
||||
onClick={onClose}
|
||||
/>
|
||||
{/* Panel */}
|
||||
<div className={cn(
|
||||
'relative w-full bg-white dark:bg-slate-800 rounded-2xl shadow-2xl',
|
||||
'flex flex-col max-h-[90vh] animate-fade-in',
|
||||
SIZES[size],
|
||||
)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-slate-700 shrink-0">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-500 transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{children}
|
||||
</div>
|
||||
{/* Footer */}
|
||||
{footer && (
|
||||
<div className="shrink-0 px-6 py-4 border-t border-slate-200 dark:border-slate-700 flex justify-end gap-3">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user