Files
hotelsync/src/pages/RoomsPage.tsx

387 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, Loader2, Pencil, Layers, Wrench } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { api } from '../lib/api'
import type { Room, RoomCategory } 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'
import { BulkRoomModal, type BulkRoomData } from '../components/rooms/BulkRoomModal'
import { RoomContextMenu } from '../components/rooms/RoomContextMenu'
import type { RoomStatus, HousekeepingStatus } from '../types'
export function RoomsPage() {
const { user } = useAuth()
const slug = user?.hotelSlug ?? ''
const [rooms, setRooms] = useState<Room[]>([])
const [categories, setCategories] = useState<RoomCategory[]>([])
const [loading, setLoading] = useState(true)
const [activeMaintenance, setActiveMaintenance] = useState<Set<string>>(new Set())
const [search, setSearch] = useState('')
const [floorFilter, setFloorFilter] = useState<number | 'all'>('all')
const [modalOpen, setModalOpen] = useState(false)
const [editingRoom, setEditingRoom] = useState<Room | undefined>(undefined)
const [bulkOpen, setBulkOpen] = useState(false)
const [categoryFilter, setCategoryFilter] = useState<string | 'all'>('all')
useEffect(() => {
if (!slug) return
Promise.all([
api.rooms.list(slug),
api.categories.list(slug).then(rows => rows.map(c => ({
id: c.id, hotelId: c.hotel_id, name: c.name,
description: c.description, color: c.color,
amenities: c.amenities, photos: c.photos,
} as RoomCategory))),
])
.then(([r, cats]) => { setRooms(r); setCategories(cats) })
.catch(console.error)
.finally(() => setLoading(false))
api.housekeeping.list(slug, { status: 'active', category: 'maintenance' })
.then(tasks => {
const ids = new Set(tasks.map(t => {
const tAny = t as unknown as Record<string, string>
return tAny.roomId ?? tAny.room_id ?? ''
}).filter(Boolean))
setActiveMaintenance(ids)
})
.catch(() => {})
}, [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
const matchCat = categoryFilter === 'all' ||
(categoryFilter === '' ? !r.categoryId : r.categoryId === categoryFilter)
return matchSearch && matchFloor && matchCat
})
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 [ctxMenu, setCtxMenu] = useState<{ room: Room; x: number; y: number } | null>(null)
const handleRoomQuickUpdate = async (roomId: string, patch: Partial<Room>) => {
try {
const updated = await api.rooms.update(slug, roomId, patch)
setRooms(prev => prev.map(r => r.id === updated.id ? updated : r))
} catch (err) {
console.error('Failed to update room:', err)
}
}
const handleBulkSave = async (bulkRooms: BulkRoomData[]) => {
const created: Room[] = []
for (const r of bulkRooms) {
const room = await api.rooms.create(slug, r)
created.push(room)
}
setRooms(prev => [...prev, ...created])
setBulkOpen(false)
}
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>
<div className="flex gap-2">
<button className="btn-secondary" onClick={() => setBulkOpen(true)}>
<Layers size={15} />
<span className="hidden sm:inline">Групповое добавление</span>
</button>
<button className="btn-primary" onClick={openCreate}>
<Plus size={15} />
<span className="hidden sm:inline">Добавить номер</span>
</button>
</div>
</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 — one row */}
<div className="flex items-center gap-2">
<div className="relative flex-1 max-w-xs">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400" />
<input
type="text"
className="input pl-8 text-sm"
placeholder="Поиск номера..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<select
className="input text-sm w-36 shrink-0"
value={floorFilter === 'all' ? 'all' : String(floorFilter)}
onChange={e => setFloorFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))}
>
<option value="all">Все этажи</option>
{floors.map(f => <option key={f} value={f}>{f} этаж</option>)}
</select>
{categories.length > 0 && (
<select
className="input text-sm w-44 shrink-0"
value={categoryFilter}
onChange={e => setCategoryFilter(e.target.value)}
>
<option value="all">Все категории</option>
{categories.map(cat => <option key={cat.id} value={cat.id}>{cat.name}</option>)}
<option value="">Без категории</option>
</select>
)}
</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}
hasActiveMaintenance={activeMaintenance.has(room.id)}
onEdit={() => openEdit(room)}
onContextMenu={(e) => { e.preventDefault(); setCtxMenu({ room, x: e.clientX, y: e.clientY }) }}
/>
))}
</div>
)}
{/* Room edit/create modal */}
<RoomModal
key={editingRoom?.id ?? 'new'}
open={modalOpen}
room={editingRoom}
existingNumbers={rooms.map(r => r.number)}
slug={slug}
onClose={() => { setModalOpen(false); setSaveError(null) }}
onSave={handleSave}
onDelete={editingRoom ? () => handleDelete(editingRoom.id) : undefined}
categories={categories}
error={saveError}
/>
{/* Bulk room creation modal */}
<BulkRoomModal
open={bulkOpen}
existingNumbers={rooms.map(r => r.number)}
categories={categories}
onClose={() => setBulkOpen(false)}
onSave={handleBulkSave}
/>
{ctxMenu && (
<RoomContextMenu
room={ctxMenu.room}
x={ctxMenu.x}
y={ctxMenu.y}
onClose={() => setCtxMenu(null)}
onStatusChange={(id, status: RoomStatus, from, to) => handleRoomQuickUpdate(id, { status, maintenanceFrom: from, maintenanceTo: to })}
onHkStatusChange={(id, status: HousekeepingStatus) => handleRoomQuickUpdate(id, { housekeepingStatus: status })}
onEdit={(room) => openEdit(room)}
/>
)}
</div>
)
}
function RoomCard({ room, hasActiveMaintenance, onEdit, onContextMenu }: { room: Room; hasActiveMaintenance?: boolean; onEdit: () => void; onContextMenu: (e: React.MouseEvent) => void }) {
return (
<div
className="card p-4 hover:shadow-card-hover transition-shadow cursor-pointer group relative"
onContextMenu={onContextMenu}
>
<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>
{(room.status === 'maintenance' || hasActiveMaintenance) && (
<div className="absolute top-3 left-3 flex gap-1">
{room.status === 'maintenance' && (
<span className="flex items-center gap-1 text-[10px] font-medium text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-900/20 px-1.5 py-0.5 rounded-md border border-orange-200 dark:border-orange-800">
<Wrench size={10} />ремонт
</span>
)}
{hasActiveMaintenance && room.status !== 'maintenance' && (
<span title="Есть активная тех. задача" className="flex items-center gap-1 text-[10px] font-medium text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 px-1.5 py-0.5 rounded-md border border-red-200 dark:border-red-800">
<Wrench size={10} />неисправность
</span>
)}
</div>
)}
<div className={cn('flex items-start justify-between mb-3', (room.status === 'maintenance' || hasActiveMaintenance) && 'mt-6')}>
<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>
)
}