Files
hotelsync/src/pages/RoomsPage.tsx
HotelSync 3ed3156fe8 feat: move amenities directory to RoomCategoriesPage + cdn.hotelsync.ru for uploads
- Remove "Справочник удобств" block from RoomsPage, add to RoomCategoriesPage
- Fix RoomsPage imports (add Pencil back, remove unused amenity icons)
- Update hint in CategoryForm to point to current page
- Upload route now returns cdn.hotelsync.ru URLs instead of api.hotelsync.ru/uploads/
- Nginx config for cdn.hotelsync.ru (serves /opt/hotelsync/uploads directly)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 12:31:08 +03:00

301 lines
12 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 } 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'
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 [search, setSearch] = useState('')
const [floorFilter, setFloorFilter] = useState<number | 'all'>('all')
const [modalOpen, setModalOpen] = useState(false)
const [editingRoom, setEditingRoom] = useState<Room | undefined>(undefined)
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))
}, [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>
)}
{/* Modal */}
<RoomModal
open={modalOpen}
room={editingRoom}
onClose={() => { setModalOpen(false); setSaveError(null) }}
onSave={handleSave}
categories={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>
)
}