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

195
src/pages/BookingsPage.tsx Normal file
View File

@@ -0,0 +1,195 @@
import { useState } from 'react'
import { Search, Filter, Plus, ArrowUpDown } from 'lucide-react'
import { MOCK_BOOKINGS, MOCK_ROOMS } from '../data/mockData'
import type { Booking, BookingStatus } from '../types'
import { cn, BOOKING_STATUS_BADGE, BOOKING_STATUS_LABELS, SOURCE_COLORS, SOURCE_LABELS, formatCurrency, nightsCount } from '../lib/utils'
import { Badge } from '../components/ui/Badge'
import { BookingModal } from '../components/bookings/BookingModal'
import { BookingDetailPanel } from '../components/bookings/BookingDetailPanel'
import { format, addDays } from 'date-fns'
const STATUS_FILTERS: { label: string; value: BookingStatus | 'all' }[] = [
{ label: 'Все', value: 'all' },
{ label: 'Подтверждённые', value: 'confirmed' },
{ label: 'Заселены', value: 'checked_in' },
{ label: 'Запросы', value: 'inquiry' },
{ label: 'Выехали', value: 'checked_out' },
{ label: 'Отменены', value: 'cancelled' },
]
export function BookingsPage() {
const [bookings, setBookings] = useState<Booking[]>(MOCK_BOOKINGS)
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<BookingStatus | 'all'>('all')
const [selected, setSelected] = useState<Booking | null>(null)
const [showCreateModal, setShowCreateModal] = useState(false)
const filtered = bookings.filter(b => {
const matchSearch = search === '' ||
b.guestName.toLowerCase().includes(search.toLowerCase()) ||
b.guestEmail.toLowerCase().includes(search.toLowerCase()) ||
b.id.includes(search)
const matchStatus = statusFilter === 'all' || b.status === statusFilter
return matchSearch && matchStatus
})
const room = (id: string) => MOCK_ROOMS.find(r => r.id === id)
return (
<div className="p-4 md:p-6 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">Бронирования</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">{filtered.length} из {bookings.length}</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="btn-primary"
>
<Plus size={15} />
<span className="hidden sm:inline">Новое</span>
</button>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
type="text"
className="input pl-9"
placeholder="Поиск по гостю, email, ID..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<div className="flex gap-1.5 flex-wrap">
{STATUS_FILTERS.map(f => (
<button
key={f.value}
onClick={() => setStatusFilter(f.value)}
className={cn(
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
statusFilter === f.value
? 'bg-brand-600 text-white border-brand-600'
: 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-brand-400',
)}
>
{f.label}
</button>
))}
</div>
</div>
{/* Table */}
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-700/40">
{['Гость', 'Номер', 'Заезд', 'Выезд', 'Статус', 'Источник', 'Сумма', ''].map(h => (
<th key={h} className="text-left px-4 py-3 text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{filtered.map(b => {
const r = room(b.roomId)
const nights = nightsCount(b.checkIn, b.checkOut)
return (
<tr
key={b.id}
className="border-b border-slate-100 dark:border-slate-700/50 hover:bg-slate-50 dark:hover:bg-slate-700/30 cursor-pointer transition-colors"
onClick={() => setSelected(b)}
>
<td className="px-4 py-3">
<p className="font-medium text-slate-900 dark:text-slate-100">{b.guestName}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">{b.guestEmail}</p>
</td>
<td className="px-4 py-3">
<p className="font-medium text-slate-900 dark:text-slate-100">
{r ? `${r.number}` : '—'}
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">{r?.type}</p>
</td>
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">
{new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short' }).format(new Date(b.checkIn))}
</td>
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">
<span>{new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short' }).format(new Date(b.checkOut))}</span>
<span className="ml-1.5 text-xs text-slate-400">{nights}н</span>
</td>
<td className="px-4 py-3">
<Badge className={BOOKING_STATUS_BADGE[b.status]}>
{BOOKING_STATUS_LABELS[b.status]}
</Badge>
</td>
<td className="px-4 py-3">
<Badge className={SOURCE_COLORS[b.source]}>
{SOURCE_LABELS[b.source]}
</Badge>
</td>
<td className="px-4 py-3">
<p className="font-semibold text-slate-900 dark:text-slate-100">
{formatCurrency(b.totalAmount)}
</p>
{b.paidAmount < b.totalAmount && (
<p className="text-xs text-red-500">
-{formatCurrency(b.totalAmount - b.paidAmount)}
</p>
)}
</td>
<td className="px-4 py-3">
<span className="text-brand-600 dark:text-brand-400 text-xs font-medium">
Открыть
</span>
</td>
</tr>
)
})}
</tbody>
</table>
{filtered.length === 0 && (
<div className="text-center py-12 text-slate-500 dark:text-slate-400">
Бронирования не найдены
</div>
)}
</div>
</div>
{/* Create modal */}
{showCreateModal && (
<BookingModal
open
draft={{
roomId: MOCK_ROOMS[0].id,
checkIn: format(new Date(), 'yyyy-MM-dd'),
checkOut: format(addDays(new Date(), 1), 'yyyy-MM-dd'),
}}
rooms={MOCK_ROOMS}
onClose={() => setShowCreateModal(false)}
onSave={(data) => {
setBookings(prev => [...prev, data as Booking])
setShowCreateModal(false)
}}
/>
)}
{/* Detail panel */}
{selected && (
<BookingDetailPanel
booking={selected}
room={room(selected.roomId)}
onClose={() => setSelected(null)}
onUpdate={(id, data) => {
setBookings(prev => prev.map(b => b.id === id ? { ...b, ...data } : b))
setSelected(null)
}}
/>
)}
</div>
)
}