Files
hotelsync/src/components/rooms/BulkRoomModal.tsx
HotelSync 3e9622d281 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>
2026-03-23 13:57:31 +03:00

294 lines
11 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, 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>
)
}