Initial commit: HotelSync PMS v0.1.0
- React 18 + TypeScript + Vite + Tailwind CSS - Шахматка бронирований (drag-to-book) - Страницы: Calendar, Bookings, Rooms, Housekeeping, Channels, API Docs, Settings - Роли: super_admin, hotel_manager, housekeeper - Светлая/тёмная тема - Docker + Nginx конфигурация - Лендинг hotelsync.ru
This commit is contained in:
180
src/pages/RoomsPage.tsx
Normal file
180
src/pages/RoomsPage.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useState } from 'react'
|
||||
import { BedDouble, Users, Wifi, Plus, Search } from 'lucide-react'
|
||||
import { MOCK_ROOMS } from '../data/mockData'
|
||||
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'
|
||||
|
||||
export function RoomsPage() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [floorFilter, setFloorFilter] = useState<number | 'all'>('all')
|
||||
|
||||
const floors = [...new Set(MOCK_ROOMS.map(r => r.floor))].sort()
|
||||
|
||||
const filtered = MOCK_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: MOCK_ROOMS.filter(r => r.status === 'available').length,
|
||||
occupied: MOCK_ROOMS.filter(r => r.status === 'occupied').length,
|
||||
maintenance: MOCK_ROOMS.filter(r => r.status === 'maintenance').length,
|
||||
dirty: MOCK_ROOMS.filter(r => r.housekeepingStatus === 'dirty' || r.housekeepingStatus === 'cleaning').length,
|
||||
}
|
||||
|
||||
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">{MOCK_ROOMS.length} номеров</p>
|
||||
</div>
|
||||
<button className="btn-primary">
|
||||
<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 */}
|
||||
<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} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RoomCard({ room }: { room: Room }) {
|
||||
return (
|
||||
<div className="card p-4 hover:shadow-card-hover transition-shadow cursor-pointer group">
|
||||
<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', {
|
||||
'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">/ночь</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user