- ChannelsPage: load from API, toggle/sync call real endpoints; normalize enabled→isEnabled, lastSyncedAt→lastSyncAt, add displayName/mappings defaults - FloorMapPage: load rooms+bookings from API; create booking via API - SettingsPage: load hotel via GET /api/hotels/:slug; save general section via PATCH (name, address, timezone, currency, check_in/out times) - UsersPage: load users from API; create/update/delete via API; map backend User (name/role) → StaffUser (firstName/lastName/StaffRole) - api.ts: add hotels.get/update, users.delete, channel normalization, HotelPayload/toHotelPayload - types/index.ts: extend Hotel with phone/checkInTime/checkOutTime/optional fields; add User.createdAt/updatedAt - backend/routes/hotels.ts: extend PATCH to allow address/phone/check_in_time/check_out_time - backend/migrations/004_hotels_contacts.sql: add address/phone/check_in/out_time to hotels Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
115 lines
4.5 KiB
TypeScript
115 lines
4.5 KiB
TypeScript
import { useState, useEffect } from 'react'
|
||
import { format, addDays } from 'date-fns'
|
||
import { Pencil, Check } from 'lucide-react'
|
||
import { api } from '../lib/api'
|
||
import { useAuth } from '../contexts/AuthContext'
|
||
import { FloorMap } from '../components/floormap/FloorMap'
|
||
import { BookingModal } from '../components/bookings/BookingModal'
|
||
import { cn } from '../lib/utils'
|
||
import type { Room, Booking } from '../types'
|
||
import type { BookingPayload } from '../lib/api'
|
||
|
||
export function FloorMapPage() {
|
||
const { user } = useAuth()
|
||
const slug = user?.hotelSlug ?? ''
|
||
const [rooms, setRooms] = useState<Room[]>([])
|
||
const [bookings, setBookings] = useState<Booking[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [createForRoom, setCreateForRoom] = useState<string | null>(null)
|
||
const [editMode, setEditMode] = useState(false)
|
||
|
||
useEffect(() => {
|
||
if (!slug) return
|
||
Promise.all([api.rooms.list(slug), api.bookings.list(slug)])
|
||
.then(([r, b]) => { setRooms(r); setBookings(b) })
|
||
.catch(console.error)
|
||
.finally(() => setLoading(false))
|
||
}, [slug])
|
||
|
||
const stats = {
|
||
total: rooms.length,
|
||
occupied: rooms.filter(r => r.status === 'occupied').length,
|
||
available: rooms.filter(r => r.status === 'available').length,
|
||
maintenance: rooms.filter(r => r.status === 'maintenance' || r.status === 'blocked').length,
|
||
}
|
||
|
||
if (loading) return (
|
||
<div className="p-6 flex items-center justify-center text-slate-400 text-sm">Загрузка...</div>
|
||
)
|
||
|
||
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">
|
||
{editMode
|
||
? 'Режим редактирования — перетащите номера, загрузите план этажа'
|
||
: 'Нажмите на свободный номер чтобы создать бронирование'}
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setEditMode(v => !v)}
|
||
className={cn(
|
||
'flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium border-2 transition-all',
|
||
editMode
|
||
? 'bg-brand-600 border-brand-600 text-white shadow-md'
|
||
: 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-300 hover:border-brand-400',
|
||
)}
|
||
>
|
||
{editMode ? <Check size={15} /> : <Pencil size={15} />}
|
||
{editMode ? 'Готово' : 'Редактировать'}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Quick stats */}
|
||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||
{[
|
||
{ label: 'Всего номеров', value: stats.total, color: 'text-slate-900 dark:text-slate-100' },
|
||
{ label: 'Занято', value: stats.occupied, color: 'text-red-600 dark:text-red-400' },
|
||
{ label: 'Свободно', value: stats.available, color: 'text-emerald-600 dark:text-emerald-400' },
|
||
{ label: 'Ремонт/закрыт', value: stats.maintenance, color: 'text-slate-500 dark:text-slate-400' },
|
||
].map(s => (
|
||
<div key={s.label} className="card px-4 py-3">
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 mb-0.5">{s.label}</p>
|
||
<p className={`text-2xl font-bold ${s.color}`}>{s.value}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Floor map */}
|
||
<div className="card overflow-hidden p-0">
|
||
<FloorMap
|
||
rooms={rooms}
|
||
bookings={bookings}
|
||
editMode={editMode}
|
||
onSelectRoom={editMode ? undefined : (id) => setCreateForRoom(id)}
|
||
/>
|
||
</div>
|
||
|
||
{/* Create booking modal */}
|
||
{createForRoom && (
|
||
<BookingModal
|
||
open
|
||
draft={{
|
||
roomId: createForRoom,
|
||
checkIn: format(new Date(), 'yyyy-MM-dd'),
|
||
checkOut: format(addDays(new Date(), 1), 'yyyy-MM-dd'),
|
||
}}
|
||
rooms={rooms}
|
||
onClose={() => setCreateForRoom(null)}
|
||
onSave={async (data) => {
|
||
try {
|
||
const created = await api.bookings.create(slug, data as BookingPayload)
|
||
setBookings(prev => [...prev, created])
|
||
} finally {
|
||
setCreateForRoom(null)
|
||
}
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|