387 lines
16 KiB
TypeScript
387 lines
16 KiB
TypeScript
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>
|
||
)
|
||
}
|