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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useState, useEffect } from 'react'
|
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 { useAuth } from '../contexts/AuthContext'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import type { Room, RoomCategory } from '../types'
|
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 { cn, ROOM_STATUS_COLORS, ROOM_STATUS_LABELS, HK_STATUS_COLORS, HK_STATUS_LABELS, formatCurrency } from '../lib/utils'
|
||||||
import { Badge } from '../components/ui/Badge'
|
import { Badge } from '../components/ui/Badge'
|
||||||
import { RoomModal } from '../components/rooms/RoomModal'
|
import { RoomModal } from '../components/rooms/RoomModal'
|
||||||
|
import { BulkRoomModal, type BulkRoomData } from '../components/rooms/BulkRoomModal'
|
||||||
|
|
||||||
export function RoomsPage() {
|
export function RoomsPage() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
@@ -18,6 +19,8 @@ export function RoomsPage() {
|
|||||||
const [floorFilter, setFloorFilter] = useState<number | 'all'>('all')
|
const [floorFilter, setFloorFilter] = useState<number | 'all'>('all')
|
||||||
const [modalOpen, setModalOpen] = useState(false)
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
const [editingRoom, setEditingRoom] = useState<Room | undefined>(undefined)
|
const [editingRoom, setEditingRoom] = useState<Room | undefined>(undefined)
|
||||||
|
const [bulkOpen, setBulkOpen] = useState(false)
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState<string | 'all'>('all')
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -43,7 +46,8 @@ export function RoomsPage() {
|
|||||||
r.type.toLowerCase().includes(search.toLowerCase()) ||
|
r.type.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
(r.name ?? '').toLowerCase().includes(search.toLowerCase())
|
(r.name ?? '').toLowerCase().includes(search.toLowerCase())
|
||||||
const matchFloor = floorFilter === 'all' || r.floor === floorFilter
|
const matchFloor = floorFilter === 'all' || r.floor === floorFilter
|
||||||
return matchSearch && matchFloor
|
const matchCat = categoryFilter === 'all' || r.categoryId === categoryFilter
|
||||||
|
return matchSearch && matchFloor && matchCat
|
||||||
})
|
})
|
||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
@@ -65,6 +69,16 @@ export function RoomsPage() {
|
|||||||
|
|
||||||
const [saveError, setSaveError] = useState<string | null>(null)
|
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) => {
|
const handleDelete = async (roomId: string) => {
|
||||||
try {
|
try {
|
||||||
await api.rooms.delete(slug, roomId)
|
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>
|
<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>
|
<p className="text-sm text-slate-500 dark:text-slate-400">{rooms.length} номеров</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn-primary" onClick={openCreate}>
|
<div className="flex gap-2">
|
||||||
<Plus size={15} />
|
<button className="btn-secondary" onClick={() => setBulkOpen(true)}>
|
||||||
<span className="hidden sm:inline">Добавить номер</span>
|
<Layers size={15} />
|
||||||
</button>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
@@ -152,42 +172,83 @@ export function RoomsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex gap-3 flex-wrap">
|
<div className="space-y-2">
|
||||||
<div className="relative flex-1 min-w-48">
|
<div className="flex gap-3 flex-wrap">
|
||||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
<div className="relative flex-1 min-w-48">
|
||||||
<input
|
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||||
type="text"
|
<input
|
||||||
className="input pl-9"
|
type="text"
|
||||||
placeholder="Поиск номера..."
|
className="input pl-9"
|
||||||
value={search}
|
placeholder="Поиск номера..."
|
||||||
onChange={e => setSearch(e.target.value)}
|
value={search}
|
||||||
/>
|
onChange={e => setSearch(e.target.value)}
|
||||||
</div>
|
/>
|
||||||
<div className="flex gap-1.5">
|
</div>
|
||||||
<button
|
<div className="flex gap-1.5 flex-wrap">
|
||||||
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 => (
|
|
||||||
<button
|
<button
|
||||||
key={f}
|
onClick={() => setFloorFilter('all')}
|
||||||
onClick={() => setFloorFilter(f)}
|
|
||||||
className={cn('px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
|
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-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',
|
: 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{f} эт.
|
Все этажи
|
||||||
</button>
|
</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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Room grid */}
|
{/* Room grid */}
|
||||||
@@ -203,7 +264,7 @@ export function RoomsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Modal — key forces remount so useState reinitializes with the new room */}
|
{/* Room edit/create modal */}
|
||||||
<RoomModal
|
<RoomModal
|
||||||
key={editingRoom?.id ?? 'new'}
|
key={editingRoom?.id ?? 'new'}
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
@@ -211,9 +272,19 @@ export function RoomsPage() {
|
|||||||
existingNumbers={rooms.map(r => r.number)}
|
existingNumbers={rooms.map(r => r.number)}
|
||||||
onClose={() => { setModalOpen(false); setSaveError(null) }}
|
onClose={() => { setModalOpen(false); setSaveError(null) }}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
|
onDelete={editingRoom ? () => handleDelete(editingRoom.id) : undefined}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
error={saveError}
|
error={saveError}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Bulk room creation modal */}
|
||||||
|
<BulkRoomModal
|
||||||
|
open={bulkOpen}
|
||||||
|
existingNumbers={rooms.map(r => r.number)}
|
||||||
|
categories={categories}
|
||||||
|
onClose={() => setBulkOpen(false)}
|
||||||
|
onSave={handleBulkSave}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user