From 3e9622d281d4eb292bdb200f2d3538317ceb6140 Mon Sep 17 00:00:00 2001 From: HotelSync Date: Mon, 23 Mar 2026 13:57:31 +0300 Subject: [PATCH] feat: bulk room creation + category filter on rooms page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/components/rooms/BulkRoomModal.tsx | 293 +++++++++++++++++++++++++ src/pages/RoomsPage.tsx | 141 +++++++++--- 2 files changed, 399 insertions(+), 35 deletions(-) create mode 100644 src/components/rooms/BulkRoomModal.tsx diff --git a/src/components/rooms/BulkRoomModal.tsx b/src/components/rooms/BulkRoomModal.tsx new file mode 100644 index 0000000..ccd94f5 --- /dev/null +++ b/src/components/rooms/BulkRoomModal.tsx @@ -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 +} + +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('double') + const [maxGuests, setMaxGuests] = useState(2) + const [baseRate, setBaseRate] = useState(5000) + const [amenities, setAmenities] = useState(['Wi-Fi', 'TV', 'AC']) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(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 ( + + {error &&

{error}

} + + + + } + > +
+ + {/* Range */} +
+ +
+
+ + setFrom(parseInt(e.target.value) || 1)} + /> +
+ +
+ + setTo(parseInt(e.target.value) || 1)} + /> +
+
+ {!rangeValid && ( +

+ {to < from ? 'Конец диапазона меньше начала' : 'Максимум 500 номеров за раз'} +

+ )} +
+ + {/* Floor */} +
+
+ + +
+ {!autoFloor && ( + setFloor(parseInt(e.target.value) || 1)} + /> + )} + {autoFloor && ( +

+ 101–199 → этаж 1, 201–299 → этаж 2, и т.д. +

+ )} +
+ + {/* Category + Bed type */} +
+
+ + {categories.length > 0 ? ( + + ) : ( +
+ Нет категорий +
+ )} +
+
+ + +
+
+ + {/* Guests + Rate */} +
+
+ + setMaxGuests(parseInt(e.target.value) || 1)} /> +
+
+ + setBaseRate(parseInt(e.target.value) || 0)} /> +
+
+ + {/* Amenities */} +
+ +
+ {allAmenities.map(a => { + const selected = amenities.includes(a) + return ( + + ) + })} +
+
+ + {/* Preview */} + {rangeValid && preview.length > 0 && ( +
+
+ +
+ {newCount > 0 && ( + {newCount} будет создано + )} + {skipCount > 0 && ( + ещё {skipCount} уже существуют — пропущены + )} +
+
+
+
+ {preview.map(r => ( + + {r.number} + + ))} +
+
+
+ )} +
+
+ ) +} diff --git a/src/pages/RoomsPage.tsx b/src/pages/RoomsPage.tsx index 572e036..8a8da4d 100644 --- a/src/pages/RoomsPage.tsx +++ b/src/pages/RoomsPage.tsx @@ -1,11 +1,12 @@ 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 { api } from '../lib/api' 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 { Badge } from '../components/ui/Badge' import { RoomModal } from '../components/rooms/RoomModal' +import { BulkRoomModal, type BulkRoomData } from '../components/rooms/BulkRoomModal' export function RoomsPage() { const { user } = useAuth() @@ -18,6 +19,8 @@ export function RoomsPage() { const [floorFilter, setFloorFilter] = useState('all') const [modalOpen, setModalOpen] = useState(false) const [editingRoom, setEditingRoom] = useState(undefined) + const [bulkOpen, setBulkOpen] = useState(false) + const [categoryFilter, setCategoryFilter] = useState('all') useEffect(() => { @@ -43,7 +46,8 @@ export function RoomsPage() { r.type.toLowerCase().includes(search.toLowerCase()) || (r.name ?? '').toLowerCase().includes(search.toLowerCase()) const matchFloor = floorFilter === 'all' || r.floor === floorFilter - return matchSearch && matchFloor + const matchCat = categoryFilter === 'all' || r.categoryId === categoryFilter + return matchSearch && matchFloor && matchCat }) const stats = { @@ -65,6 +69,16 @@ export function RoomsPage() { const [saveError, setSaveError] = useState(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) => { try { await api.rooms.delete(slug, roomId) @@ -130,10 +144,16 @@ export function RoomsPage() {

Номерной фонд

{rooms.length} номеров

- +
+ + +
{/* Stats */} @@ -152,42 +172,83 @@ export function RoomsPage() { {/* Filters */} -
-
- - setSearch(e.target.value)} - /> -
-
- - {floors.map(f => ( +
+
+
+ + setSearch(e.target.value)} + /> +
+
- ))} + {floors.map(f => ( + + ))} +
+ + {/* Category filter */} + {categories.length > 0 && ( +
+ + {categories.map(cat => ( + + ))} + +
+ )}
{/* Room grid */} @@ -203,7 +264,7 @@ export function RoomsPage() {
)} - {/* Modal — key forces remount so useState reinitializes with the new room */} + {/* Room edit/create modal */} r.number)} onClose={() => { setModalOpen(false); setSaveError(null) }} onSave={handleSave} + onDelete={editingRoom ? () => handleDelete(editingRoom.id) : undefined} categories={categories} error={saveError} /> + + {/* Bulk room creation modal */} + r.number)} + categories={categories} + onClose={() => setBulkOpen(false)} + onSave={handleBulkSave} + />
) }