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:
2026-03-10 20:38:32 +03:00
commit 420d55d57e
45 changed files with 7274 additions and 0 deletions

View 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>
</>
)
}

View 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>
)
}

View 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>
)
}

View 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>
</>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}