feat: bulk room creation + category filter on rooms page

- Add BulkRoomModal: set number range (from→to), category, bed type,
  max guests, base rate, amenities; auto-derives floor from room number;
  preview shows new vs skipped (already existing) rooms
- Add category filter chips on Rooms page (alongside existing floor filter)
- Add "Групповое добавление" button next to "Добавить номер"
- Wire onDelete prop to RoomModal on rooms page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 13:57:31 +03:00
parent eea76657e1
commit 3e9622d281
2 changed files with 399 additions and 35 deletions

View File

@@ -1,11 +1,12 @@
import { useState, useEffect } from 'react'
import { BedDouble, Users, Wifi, Plus, Search, Loader2, Pencil } from 'lucide-react'
import { BedDouble, Users, Wifi, Plus, Search, Loader2, Pencil, Layers } 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'
export function RoomsPage() {
const { user } = useAuth()
@@ -18,6 +19,8 @@ export function RoomsPage() {
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(() => {
@@ -43,7 +46,8 @@ export function RoomsPage() {
r.type.toLowerCase().includes(search.toLowerCase()) ||
(r.name ?? '').toLowerCase().includes(search.toLowerCase())
const matchFloor = floorFilter === 'all' || r.floor === floorFilter
return matchSearch && matchFloor
const matchCat = categoryFilter === 'all' || r.categoryId === categoryFilter
return matchSearch && matchFloor && matchCat
})
const stats = {
@@ -65,6 +69,16 @@ export function RoomsPage() {
const [saveError, setSaveError] = useState<string | null>(null)
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)
@@ -130,10 +144,16 @@ export function RoomsPage() {
<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 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 */}
@@ -152,42 +172,83 @@ export function RoomsPage() {
</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 => (
<div className="space-y-2">
<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 flex-wrap">
<button
key={f}
onClick={() => setFloorFilter(f)}
onClick={() => setFloorFilter('all')}
className={cn('px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
floorFilter === f
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',
)}
>
{f} эт.
Все этажи
</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>
{/* Category filter */}
{categories.length > 0 && (
<div className="flex gap-1.5 flex-wrap">
<button
onClick={() => setCategoryFilter('all')}
className={cn('px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
categoryFilter === 'all'
? 'bg-slate-700 text-white border-slate-700 dark:bg-slate-200 dark:text-slate-900 dark:border-slate-200'
: 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600',
)}
>
Все категории
</button>
{categories.map(cat => (
<button
key={cat.id}
onClick={() => setCategoryFilter(cat.id)}
className={cn('px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
categoryFilter === cat.id
? 'bg-slate-700 text-white border-slate-700 dark:bg-slate-200 dark:text-slate-900 dark:border-slate-200'
: 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600',
)}
>
{cat.name}
</button>
))}
<button
onClick={() => setCategoryFilter('')}
className={cn('px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
categoryFilter === ''
? 'bg-slate-700 text-white border-slate-700 dark:bg-slate-200 dark:text-slate-900 dark:border-slate-200'
: 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600',
)}
>
Без категории
</button>
</div>
)}
</div>
{/* Room grid */}
@@ -203,7 +264,7 @@ export function RoomsPage() {
</div>
)}
{/* Modal — key forces remount so useState reinitializes with the new room */}
{/* Room edit/create modal */}
<RoomModal
key={editingRoom?.id ?? 'new'}
open={modalOpen}
@@ -211,9 +272,19 @@ export function RoomsPage() {
existingNumbers={rooms.map(r => r.number)}
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}
/>
</div>
)
}