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