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

@@ -0,0 +1,293 @@
import { useState, useMemo } from 'react'
import { Modal } from '../ui/Modal'
import { cn } from '../../lib/utils'
import type { BedType, RoomStatus } from '../../types'
import { useAmenities } from '../../contexts/AmenitiesContext'
const BED_TYPES: { value: BedType; label: string }[] = [
{ value: 'single', label: 'Одна кровать' },
{ value: 'double', label: 'Двуспальная' },
{ value: 'queen', label: 'Queen' },
{ value: 'king', label: 'King' },
{ value: 'twin', label: 'Две кровати' },
]
export interface BulkRoomData {
number: string
floor: number
type: string
categoryId?: string
bedType: BedType
maxGuests: number
baseRate: number
status: RoomStatus
amenities: string[]
housekeepingStatus: 'clean'
sortOrder: number
allowHourly: false
}
interface BulkRoomModalProps {
open: boolean
existingNumbers: string[]
categories: { id: string; name: string }[]
onClose: () => void
onSave: (rooms: BulkRoomData[]) => Promise<void>
}
function deriveFloor(num: number): number {
if (num < 10) return 1
if (num < 100) return Math.floor(num / 10)
return Math.floor(num / 100)
}
export function BulkRoomModal({ open, existingNumbers, categories, onClose, onSave }: BulkRoomModalProps) {
const { amenities: allAmenities } = useAmenities()
const [from, setFrom] = useState(101)
const [to, setTo] = useState(110)
const [autoFloor, setAutoFloor] = useState(true)
const [floor, setFloor] = useState(1)
const [categoryId, setCategoryId] = useState(categories[0]?.id ?? '')
const [bedType, setBedType] = useState<BedType>('double')
const [maxGuests, setMaxGuests] = useState(2)
const [baseRate, setBaseRate] = useState(5000)
const [amenities, setAmenities] = useState<string[]>(['Wi-Fi', 'TV', 'AC'])
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const toggleAmenity = (a: string) =>
setAmenities(prev => prev.includes(a) ? prev.filter(x => x !== a) : [...prev, a])
const taken = useMemo(() => new Set(existingNumbers), [existingNumbers])
const preview = useMemo(() => {
if (from > to || to - from > 499) return []
const rows: { number: string; floor: number; skip: boolean }[] = []
for (let n = from; n <= to; n++) {
const numStr = String(n)
rows.push({
number: numStr,
floor: autoFloor ? deriveFloor(n) : floor,
skip: taken.has(numStr),
})
}
return rows
}, [from, to, autoFloor, floor, taken])
const newCount = preview.filter(r => !r.skip).length
const skipCount = preview.filter(r => r.skip).length
const catName = categories.find(c => c.id === categoryId)?.name ?? ''
const handleSave = async () => {
if (newCount === 0) return
setSaving(true)
setError(null)
try {
const rooms: BulkRoomData[] = preview
.filter(r => !r.skip)
.map((r, i) => ({
number: r.number,
floor: r.floor,
type: catName || 'Стандарт',
categoryId: categoryId || undefined,
bedType,
maxGuests,
baseRate,
status: 'available' as RoomStatus,
housekeepingStatus: 'clean' as const,
amenities,
sortOrder: i + 1,
allowHourly: false as const,
}))
await onSave(rooms)
} catch (err) {
setError(err instanceof Error ? err.message : 'Ошибка сохранения')
setSaving(false)
}
}
const rangeValid = from > 0 && to >= from && (to - from) <= 499
return (
<Modal
open={open}
onClose={onClose}
title="Групповое добавление номеров"
size="xl"
footer={
<>
{error && <p className="text-sm text-red-600 dark:text-red-400 flex-1 text-left">{error}</p>}
<button onClick={onClose} className="btn-secondary" disabled={saving}>Отмена</button>
<button
onClick={handleSave}
className="btn-primary"
disabled={saving || newCount === 0 || !rangeValid}
>
{saving ? 'Создание...' : `Создать ${newCount} номер${newCount === 1 ? '' : newCount < 5 ? 'а' : 'ов'}`}
</button>
</>
}
>
<div className="space-y-5">
{/* Range */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Диапазон номеров</label>
<div className="flex items-center gap-3">
<div className="flex-1">
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1">С</label>
<input
type="number" min={1} max={9999} className="input"
value={from}
onChange={e => setFrom(parseInt(e.target.value) || 1)}
/>
</div>
<span className="text-slate-400 mt-5"></span>
<div className="flex-1">
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1">По</label>
<input
type="number" min={1} max={9999} className="input"
value={to}
onChange={e => setTo(parseInt(e.target.value) || 1)}
/>
</div>
</div>
{!rangeValid && (
<p className="text-xs text-red-500 mt-1">
{to < from ? 'Конец диапазона меньше начала' : 'Максимум 500 номеров за раз'}
</p>
)}
</div>
{/* Floor */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">Этаж</label>
<label className="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400 cursor-pointer">
<input
type="checkbox"
checked={autoFloor}
onChange={e => setAutoFloor(e.target.checked)}
className="rounded"
/>
Автоматически (по номеру)
</label>
</div>
{!autoFloor && (
<input
type="number" min={1} max={50} className="input w-32"
value={floor}
onChange={e => setFloor(parseInt(e.target.value) || 1)}
/>
)}
{autoFloor && (
<p className="text-xs text-slate-500 dark:text-slate-400">
101199 этаж 1, 201299 этаж 2, и т.д.
</p>
)}
</div>
{/* Category + Bed type */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Категория</label>
{categories.length > 0 ? (
<select className="input" value={categoryId} onChange={e => setCategoryId(e.target.value)}>
<option value=""> Без категории </option>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
) : (
<div className="input bg-slate-50 dark:bg-slate-700/50 text-slate-400 text-sm">
Нет категорий
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Тип кровати</label>
<select className="input" value={bedType} onChange={e => setBedType(e.target.value as BedType)}>
{BED_TYPES.map(b => <option key={b.value} value={b.value}>{b.label}</option>)}
</select>
</div>
</div>
{/* Guests + Rate */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Макс. гостей</label>
<input type="number" min={1} max={10} className="input" value={maxGuests}
onChange={e => setMaxGuests(parseInt(e.target.value) || 1)} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Цена /ночь</label>
<input type="number" min={0} className="input" value={baseRate}
onChange={e => setBaseRate(parseInt(e.target.value) || 0)} />
</div>
</div>
{/* Amenities */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Удобства</label>
<div className="flex flex-wrap gap-2">
{allAmenities.map(a => {
const selected = amenities.includes(a)
return (
<button
key={a}
type="button"
onClick={() => toggleAmenity(a)}
className={cn(
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
selected
? 'bg-brand-600 text-white border-brand-600'
: 'bg-white dark:bg-slate-700 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-brand-400',
)}
>
{a}
</button>
)
})}
</div>
</div>
{/* Preview */}
{rangeValid && preview.length > 0 && (
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">
Предпросмотр
</label>
<div className="flex gap-3 text-xs">
{newCount > 0 && (
<span className="text-emerald-600 dark:text-emerald-400">{newCount} будет создано</span>
)}
{skipCount > 0 && (
<span className="text-slate-400">ещё {skipCount} уже существуют пропущены</span>
)}
</div>
</div>
<div className="max-h-40 overflow-y-auto rounded-xl border border-slate-200 dark:border-slate-600">
<div className="flex flex-wrap gap-1.5 p-3">
{preview.map(r => (
<span
key={r.number}
className={cn(
'px-2.5 py-1 rounded-lg text-xs font-medium',
r.skip
? 'bg-slate-100 dark:bg-slate-700 text-slate-400 line-through'
: 'bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300 border border-brand-200 dark:border-brand-800',
)}
title={r.skip ? 'Уже существует' : `Этаж ${r.floor}`}
>
{r.number}
</span>
))}
</div>
</div>
</div>
)}
</div>
</Modal>
)
}