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:
293
src/components/rooms/BulkRoomModal.tsx
Normal file
293
src/components/rooms/BulkRoomModal.tsx
Normal 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">
|
||||
101–199 → этаж 1, 201–299 → этаж 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user