Files
hotelsync/src/pages/RoomsPage.tsx
2026-03-17 18:02:08 +03:00

388 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react'
import { BedDouble, Users, Wifi, Plus, Search, Pencil, ChevronDown, ChevronUp, Check, Trash2, X as XIcon, Loader2 } from 'lucide-react'
import { useAmenities } from '../contexts/AmenitiesContext'
import { useAuth } from '../contexts/AuthContext'
import { api } from '../lib/api'
import { MOCK_CATEGORIES } from './RoomCategoriesPage'
import type { Room } from '../types'
import { cn, ROOM_STATUS_COLORS, ROOM_STATUS_LABELS, HK_STATUS_COLORS, HK_STATUS_LABELS, formatCurrency } from '../lib/utils'
import { Badge } from '../components/ui/Badge'
import { RoomModal } from '../components/rooms/RoomModal'
export function RoomsPage() {
const { user } = useAuth()
const slug = user?.hotelSlug ?? ''
const [rooms, setRooms] = useState<Room[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [floorFilter, setFloorFilter] = useState<number | 'all'>('all')
const [modalOpen, setModalOpen] = useState(false)
const [editingRoom, setEditingRoom] = useState<Room | undefined>(undefined)
const { amenities, addAmenity, removeAmenity, renameAmenity } = useAmenities()
const [showAmenities, setShowAmenities] = useState(false)
const [newAmenity, setNewAmenity] = useState('')
const [editingAmenity, setEditingAmenity] = useState<string | null>(null)
const [editAmenityValue, setEditAmenityValue] = useState('')
useEffect(() => {
if (!slug) return
api.rooms.list(slug)
.then(setRooms)
.catch(console.error)
.finally(() => setLoading(false))
}, [slug])
const floors = [...new Set(rooms.map(r => r.floor))].sort()
const filtered = rooms.filter(r => {
const matchSearch = search === '' ||
r.number.includes(search) ||
r.type.toLowerCase().includes(search.toLowerCase()) ||
(r.name ?? '').toLowerCase().includes(search.toLowerCase())
const matchFloor = floorFilter === 'all' || r.floor === floorFilter
return matchSearch && matchFloor
})
const stats = {
available: rooms.filter(r => r.status === 'available').length,
occupied: rooms.filter(r => r.status === 'occupied').length,
maintenance: rooms.filter(r => r.status === 'maintenance').length,
dirty: rooms.filter(r => r.housekeepingStatus === 'dirty' || r.housekeepingStatus === 'cleaning').length,
}
const openCreate = () => {
setEditingRoom(undefined)
setModalOpen(true)
}
const openEdit = (room: Room) => {
setEditingRoom(room)
setModalOpen(true)
}
const [saveError, setSaveError] = useState<string | null>(null)
const handleDelete = async (roomId: string) => {
try {
await api.rooms.delete(slug, roomId)
setRooms(prev => prev.filter(r => r.id !== roomId))
setModalOpen(false)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Ошибка удаления'
setSaveError(msg)
}
}
const handleSave = async (room: Room) => {
setSaveError(null)
try {
const isNew = !rooms.find(r => r.id === room.id)
if (isNew) {
const created = await api.rooms.create(slug, {
number: room.number, type: room.type, floor: room.floor,
maxGuests: room.maxGuests, baseRate: room.baseRate, status: room.status,
amenities: room.amenities, name: room.name, categoryId: room.categoryId,
bedType: room.bedType, beds: room.beds, housekeepingStatus: room.housekeepingStatus,
sortOrder: room.sortOrder, allowHourly: room.allowHourly, hourlyRate: room.hourlyRate,
extraPlace: room.extraPlace, childPolicy: room.childPolicy,
description: room.description, photos: room.photos,
})
setRooms(prev => [...prev, created])
} else {
const updated = await api.rooms.update(slug, room.id, {
number: room.number, type: room.type, floor: room.floor,
maxGuests: room.maxGuests, baseRate: room.baseRate, status: room.status,
amenities: room.amenities, name: room.name, categoryId: room.categoryId,
bedType: room.bedType, beds: room.beds, housekeepingStatus: room.housekeepingStatus,
sortOrder: room.sortOrder, allowHourly: room.allowHourly, hourlyRate: room.hourlyRate,
extraPlace: room.extraPlace, childPolicy: room.childPolicy,
description: room.description, photos: room.photos,
})
setRooms(prev => prev.map(r => r.id === updated.id ? updated : r))
}
setModalOpen(false)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Ошибка сохранения'
setSaveError(
msg.includes('already exists') || msg.includes('duplicate') || msg.includes('unique')
? `Номер «${room.number}» уже существует`
: msg,
)
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 size={24} className="animate-spin text-brand-600" />
</div>
)
}
return (
<div className="p-4 md:p-6 space-y-5">
{/* 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">{rooms.length} номеров</p>
</div>
<button className="btn-primary" onClick={openCreate}>
<Plus size={15} />
<span className="hidden sm:inline">Добавить номер</span>
</button>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[
{ label: 'Свободных', value: stats.available, color: 'text-emerald-600 dark:text-emerald-400', bg: 'bg-emerald-50 dark:bg-emerald-900/20' },
{ label: 'Занятых', value: stats.occupied, color: 'text-brand-600 dark:text-brand-400', bg: 'bg-brand-50 dark:bg-brand-900/20' },
{ label: 'На ремонте', value: stats.maintenance, color: 'text-orange-600 dark:text-orange-400', bg: 'bg-orange-50 dark:bg-orange-900/20' },
{ label: 'Нужна уборка', value: stats.dirty, color: 'text-red-600 dark:text-red-400', bg: 'bg-red-50 dark:bg-red-900/20' },
].map(s => (
<div key={s.label} className={cn('card p-4', s.bg, 'border-0')}>
<p className={cn('text-2xl font-bold', s.color)}>{s.value}</p>
<p className="text-sm text-slate-600 dark:text-slate-400">{s.label}</p>
</div>
))}
</div>
{/* Filters */}
<div className="flex gap-3 flex-wrap">
<div className="relative flex-1 min-w-48">
<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="Поиск номера..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<div className="flex gap-1.5">
<button
onClick={() => setFloorFilter('all')}
className={cn('px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
floorFilter === 'all'
? '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',
)}
>
Все этажи
</button>
{floors.map(f => (
<button
key={f}
onClick={() => setFloorFilter(f)}
className={cn('px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
floorFilter === f
? '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',
)}
>
{f} эт.
</button>
))}
</div>
</div>
{/* Room grid */}
{filtered.length === 0 && !loading ? (
<div className="text-center py-16 text-slate-400 dark:text-slate-500">
{rooms.length === 0 ? 'Нет номеров. Добавьте первый номер.' : 'Ничего не найдено.'}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filtered.map(room => (
<RoomCard key={room.id} room={room} onEdit={() => openEdit(room)} />
))}
</div>
)}
{/* Справочник удобств */}
<div className="card overflow-hidden">
<button
onClick={() => setShowAmenities(v => !v)}
className="w-full flex items-center justify-between px-5 py-4 text-left hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors"
>
<div>
<p className="font-semibold text-slate-900 dark:text-slate-100">Справочник удобств</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
{amenities.length} удобств · используется в номерах и категориях
</p>
</div>
{showAmenities
? <ChevronUp size={16} className="text-slate-400" />
: <ChevronDown size={16} className="text-slate-400" />}
</button>
{showAmenities && (
<div className="border-t border-slate-200 dark:border-slate-700 p-5 space-y-4">
<div className="flex gap-2">
<input
type="text"
className="input flex-1 text-sm"
placeholder="Добавить удобство (напр. Фен, Сейф, Балкон...)"
value={newAmenity}
onChange={e => setNewAmenity(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') { e.preventDefault(); if (newAmenity.trim()) { addAmenity(newAmenity.trim()); setNewAmenity('') } }
}}
/>
<button
onClick={() => { if (newAmenity.trim()) { addAmenity(newAmenity.trim()); setNewAmenity('') } }}
disabled={!newAmenity.trim() || amenities.includes(newAmenity.trim())}
className="btn-primary px-4 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus size={15} />
</button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
{amenities.map(a => (
<div
key={a}
className="flex items-center gap-1.5 px-2.5 py-2 rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800 group"
>
{editingAmenity === a ? (
<>
<input
autoFocus
className="input text-xs flex-1 py-0.5 h-6"
value={editAmenityValue}
onChange={e => setEditAmenityValue(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') { renameAmenity(a, editAmenityValue); setEditingAmenity(null) }
if (e.key === 'Escape') setEditingAmenity(null)
}}
/>
<button onClick={() => { renameAmenity(a, editAmenityValue); setEditingAmenity(null) }}
className="text-emerald-500 hover:text-emerald-600 shrink-0"><Check size={13} /></button>
<button onClick={() => setEditingAmenity(null)}
className="text-slate-400 hover:text-slate-600 shrink-0"><XIcon size={13} /></button>
</>
) : (
<>
<span className="text-xs text-slate-700 dark:text-slate-300 flex-1 truncate">{a}</span>
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button
onClick={() => { setEditingAmenity(a); setEditAmenityValue(a) }}
className="p-0.5 rounded hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400 hover:text-slate-700"
>
<Pencil size={11} />
</button>
<button
onClick={() => removeAmenity(a)}
className="p-0.5 rounded hover:bg-red-100 dark:hover:bg-red-900/30 text-slate-400 hover:text-red-500"
>
<Trash2 size={11} />
</button>
</div>
</>
)}
</div>
))}
</div>
</div>
)}
</div>
{/* Modal */}
<RoomModal
open={modalOpen}
room={editingRoom}
onClose={() => { setModalOpen(false); setSaveError(null) }}
onSave={handleSave}
categories={MOCK_CATEGORIES}
error={saveError}
/>
</div>
)
}
function RoomCard({ room, onEdit }: { room: Room; onEdit: () => void }) {
return (
<div className="card p-4 hover:shadow-card-hover transition-shadow cursor-pointer group relative">
<button
onClick={e => { e.stopPropagation(); onEdit() }}
className="absolute top-3 right-3 p-1.5 rounded-lg bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 text-slate-500 opacity-0 group-hover:opacity-100 transition-opacity shadow-sm hover:text-brand-600"
>
<Pencil size={13} />
</button>
<div className="flex items-start justify-between mb-3">
<div>
<div className="flex items-center gap-2">
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">
{room.number}
</span>
{room.name && (
<span className="text-sm text-slate-500 dark:text-slate-400">{room.name}</span>
)}
</div>
<span className="text-sm text-slate-500 dark:text-slate-400">{room.type} · Этаж {room.floor}</span>
</div>
<div className={cn('w-3 h-3 rounded-full mt-1 mr-6', {
'bg-emerald-500': room.status === 'available',
'bg-brand-500': room.status === 'occupied',
'bg-orange-500': room.status === 'maintenance',
'bg-slate-400': room.status === 'blocked',
})} />
</div>
<div className="flex flex-wrap gap-1.5 mb-3">
<Badge className={ROOM_STATUS_COLORS[room.status]}>
{ROOM_STATUS_LABELS[room.status]}
</Badge>
<Badge className={HK_STATUS_COLORS[room.housekeepingStatus]}>
{HK_STATUS_LABELS[room.housekeepingStatus]}
</Badge>
</div>
<div className="flex items-center gap-3 text-xs text-slate-500 dark:text-slate-400 mb-3">
<div className="flex items-center gap-1">
<BedDouble size={12} />
{room.bedType}
</div>
<div className="flex items-center gap-1">
<Users size={12} />
до {room.maxGuests}
</div>
{room.amenities.includes('Wi-Fi') && (
<div className="flex items-center gap-1">
<Wifi size={12} />
Wi-Fi
</div>
)}
</div>
<div className="flex items-center justify-between">
<span className="text-base font-bold text-slate-900 dark:text-slate-100">
{formatCurrency(room.baseRate)}
</span>
<span className="text-xs text-slate-400 dark:text-slate-500">
/ночь{room.allowHourly ? ` · ${formatCurrency(room.hourlyRate ?? 0)}` : ''}
</span>
</div>
{room.amenities.length > 0 && (
<div className="mt-2.5 pt-2.5 border-t border-slate-100 dark:border-slate-700">
<div className="flex flex-wrap gap-1">
{room.amenities.slice(0, 3).map(a => (
<span key={a} className="text-[10px] bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400 px-1.5 py-0.5 rounded">
{a}
</span>
))}
{room.amenities.length > 3 && (
<span className="text-[10px] text-slate-400 px-1">+{room.amenities.length - 3}</span>
)}
</div>
</div>
)}
</div>
)
}