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:
195
src/pages/BookingsPage.tsx
Normal file
195
src/pages/BookingsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user