Add centralized amenities directory and hourly booking example
- AmenitiesContext: shared amenities list with add/edit/delete, available app-wide - RoomsPage: collapsible Справочник удобств section (add/rename/delete) - RoomModal + RoomCategoriesPage: use amenities from context, no hardcoded lists - RoomCategoriesPage: removed custom amenity input (use справочник instead) - mockData: room 103 now has allowHourly=true, added sample hourly booking (today, 10-13ч) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { ThemeProvider } from './contexts/ThemeContext'
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
import { ModulesProvider } from './contexts/ModulesContext'
|
||||
import { AmenitiesProvider } from './contexts/AmenitiesContext'
|
||||
import { AppLayout } from './layouts/AppLayout'
|
||||
import { LoginPage } from './pages/LoginPage'
|
||||
import { CalendarPage } from './pages/CalendarPage'
|
||||
@@ -38,6 +39,7 @@ export default function App() {
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<ModulesProvider>
|
||||
<AmenitiesProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Public */}
|
||||
@@ -86,6 +88,7 @@ export default function App() {
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AmenitiesProvider>
|
||||
</ModulesProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -3,11 +3,7 @@ import { Modal } from '../ui/Modal'
|
||||
import { cn } from '../../lib/utils'
|
||||
import type { Room, RoomStatus, HousekeepingStatus, BedType } from '../../types'
|
||||
import { ImagePlus, X as XIcon, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
|
||||
const AMENITY_LIST = [
|
||||
'Wi-Fi', 'TV', 'AC', 'Mini-bar', 'Bathrobe', 'Jacuzzi',
|
||||
'Panoramic view', 'Kitchen', 'Washing machine', 'Butler', 'Terrace',
|
||||
]
|
||||
import { useAmenities } from '../../contexts/AmenitiesContext'
|
||||
|
||||
const ROOM_TYPES = ['Стандарт', 'Делюкс', 'Полулюкс', 'Люкс', 'Пентхаус', 'Апартаменты', 'Другой']
|
||||
|
||||
@@ -65,6 +61,7 @@ export function RoomModal({ open, room, categories = [], onClose, onSave }: Room
|
||||
description: room?.description ?? '',
|
||||
})
|
||||
|
||||
const { amenities: allAmenities } = useAmenities()
|
||||
const [amenities, setAmenities] = useState<string[]>(room?.amenities ?? ['Wi-Fi', 'TV', 'AC'])
|
||||
const [photos, setPhotos] = useState<string[]>(room?.photos ?? [])
|
||||
const [photoIdx, setPhotoIdx] = useState(0)
|
||||
@@ -265,7 +262,7 @@ export function RoomModal({ open, room, categories = [], onClose, onSave }: Room
|
||||
<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">
|
||||
{AMENITY_LIST.map(a => {
|
||||
{allAmenities.map(a => {
|
||||
const selected = amenities.includes(a)
|
||||
return (
|
||||
<button
|
||||
|
||||
38
src/contexts/AmenitiesContext.tsx
Normal file
38
src/contexts/AmenitiesContext.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { createContext, useContext, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
const DEFAULT_AMENITIES = [
|
||||
'Wi-Fi', 'TV', 'AC', 'Mini-bar', 'Bathrobe', 'Jacuzzi',
|
||||
'Panoramic view', 'Kitchen', 'Washing machine', 'Butler', 'Terrace',
|
||||
'Safe', 'Hairdryer', 'Iron', 'Balcony',
|
||||
]
|
||||
|
||||
interface AmenitiesContextValue {
|
||||
amenities: string[]
|
||||
addAmenity: (a: string) => void
|
||||
removeAmenity: (a: string) => void
|
||||
renameAmenity: (oldName: string, newName: string) => void
|
||||
}
|
||||
|
||||
const AmenitiesContext = createContext<AmenitiesContextValue | null>(null)
|
||||
|
||||
export function AmenitiesProvider({ children }: { children: ReactNode }) {
|
||||
const [amenities, setAmenities] = useState<string[]>(DEFAULT_AMENITIES)
|
||||
|
||||
const addAmenity = (a: string) => setAmenities(prev => prev.includes(a) ? prev : [...prev, a])
|
||||
const removeAmenity = (a: string) => setAmenities(prev => prev.filter(x => x !== a))
|
||||
const renameAmenity = (oldName: string, newName: string) =>
|
||||
setAmenities(prev => prev.map(x => x === oldName ? newName.trim() || x : x))
|
||||
|
||||
return (
|
||||
<AmenitiesContext.Provider value={{ amenities, addAmenity, removeAmenity, renameAmenity }}>
|
||||
{children}
|
||||
</AmenitiesContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAmenities() {
|
||||
const ctx = useContext(AmenitiesContext)
|
||||
if (!ctx) throw new Error('useAmenities must be used within AmenitiesProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -82,7 +82,7 @@ export const MOCK_USERS: User[] = [
|
||||
export const MOCK_ROOMS: Room[] = [
|
||||
{ id: 'r1', hotelId: 'hotel-1', number: '101', floor: 1, type: 'Стандарт', bedType: 'double', maxGuests: 2, baseRate: 4500, status: 'occupied', housekeepingStatus: 'dirty', amenities: ['Wi-Fi', 'TV', 'AC'], sortOrder: 1 },
|
||||
{ id: 'r2', hotelId: 'hotel-1', number: '102', floor: 1, type: 'Стандарт', bedType: 'twin', maxGuests: 2, baseRate: 4500, status: 'available', housekeepingStatus: 'clean', amenities: ['Wi-Fi', 'TV', 'AC'], sortOrder: 2 },
|
||||
{ id: 'r3', hotelId: 'hotel-1', number: '103', floor: 1, type: 'Стандарт', bedType: 'single', maxGuests: 1, baseRate: 3200, status: 'available', housekeepingStatus: 'clean', amenities: ['Wi-Fi', 'TV'], sortOrder: 3 },
|
||||
{ id: 'r3', hotelId: 'hotel-1', number: '103', floor: 1, type: 'Стандарт', bedType: 'single', maxGuests: 1, baseRate: 3200, status: 'available', housekeepingStatus: 'clean', amenities: ['Wi-Fi', 'TV'], sortOrder: 3, allowHourly: true, hourlyRate: 800 },
|
||||
{ id: 'r4', hotelId: 'hotel-1', number: '201', floor: 2, type: 'Делюкс', bedType: 'king', maxGuests: 2, baseRate: 7800, status: 'occupied', housekeepingStatus: 'cleaning', amenities: ['Wi-Fi', 'TV', 'AC', 'Mini-bar', 'Bathrobe'], sortOrder: 4 },
|
||||
{ id: 'r5', hotelId: 'hotel-1', number: '202', floor: 2, type: 'Делюкс', bedType: 'queen', maxGuests: 2, baseRate: 7200, status: 'available', housekeepingStatus: 'inspect', amenities: ['Wi-Fi', 'TV', 'AC', 'Mini-bar'], sortOrder: 5 },
|
||||
{ id: 'r6', hotelId: 'hotel-1', number: '203', floor: 2, type: 'Делюкс', bedType: 'twin', maxGuests: 3, baseRate: 7500, status: 'maintenance', housekeepingStatus: 'clean', amenities: ['Wi-Fi', 'TV', 'AC', 'Mini-bar'], sortOrder: 6 },
|
||||
@@ -120,6 +120,8 @@ export const MOCK_BOOKINGS: Booking[] = [
|
||||
{ id: 'b12', hotelId: 'hotel-1', roomId: 'r8', guestId: 'g12', guestName: 'Людмила Захарова', guestEmail: 'zahar@corp.ru', checkIn: d(6), checkOut: d(11), status: 'confirmed', source: 'booking_com', totalAmount: 55000, paidAmount: 27500, adults: 2, children: 1, createdAt: TODAY },
|
||||
{ id: 'b13', hotelId: 'hotel-1', roomId: 'r3', guestId: 'g13', guestName: 'Роман Степанов', guestEmail: 'stepanov@biz.ru', checkIn: d(6), checkOut: d(9), status: 'inquiry', source: 'direct', totalAmount: 9600, paidAmount: 0, adults: 1, children: 0, createdAt: TODAY },
|
||||
{ id: 'b14', hotelId: 'hotel-1', roomId: 'r11', guestId: 'g14', guestName: 'Виктор Громов', guestEmail: 'gromov@vip.ru', checkIn: d(7), checkOut: d(14), status: 'confirmed', source: 'direct', totalAmount: 315000, paidAmount: 157500, adults: 4, children: 2, createdAt: p(3) },
|
||||
// r3 — hourly booking (today)
|
||||
{ id: 'b15', hotelId: 'hotel-1', roomId: 'r3', guestId: 'g15', guestName: 'Алексей Смирнов', guestEmail: 'smirnov@corp.ru', checkIn: TODAY, checkOut: TODAY, status: 'confirmed', source: 'direct', totalAmount: 2400, paidAmount: 2400, adults: 1, children: 0, notes: '[Почасово: 10:00–13:00] Деловая встреча', createdAt: TODAY },
|
||||
]
|
||||
|
||||
// ─── Channels ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { Plus, Pencil, Trash2, ImagePlus, X as XIcon, Tag, ChevronRight, Check } from 'lucide-react'
|
||||
import { Plus, Pencil, Trash2, ImagePlus, X as XIcon, Tag, ChevronRight } from 'lucide-react'
|
||||
import { cn } from '../lib/utils'
|
||||
import type { RoomCategory } from '../types'
|
||||
import { useAmenities } from '../contexts/AmenitiesContext'
|
||||
|
||||
// ── Mock data ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -10,11 +11,6 @@ const CATEGORY_COLORS = [
|
||||
'#DC2626', '#D97706', '#DB2777', '#475569',
|
||||
]
|
||||
|
||||
const AMENITY_LIST = [
|
||||
'Wi-Fi', 'TV', 'AC', 'Mini-bar', 'Bathrobe', 'Jacuzzi',
|
||||
'Panoramic view', 'Kitchen', 'Terrace', 'Butler',
|
||||
]
|
||||
|
||||
export const MOCK_CATEGORIES: RoomCategory[] = [
|
||||
{
|
||||
id: 'cat1',
|
||||
@@ -62,6 +58,7 @@ interface CategoryFormProps {
|
||||
|
||||
function CategoryForm({ category, onClose, onSave }: CategoryFormProps) {
|
||||
const isEdit = !!category
|
||||
const { amenities: allAmenities } = useAmenities()
|
||||
const [tab, setTab] = useState<'main' | 'photos'>('main')
|
||||
const [name, setName] = useState(category?.name ?? '')
|
||||
const [description, setDescription] = useState(category?.description ?? '')
|
||||
@@ -71,21 +68,9 @@ function CategoryForm({ category, onClose, onSave }: CategoryFormProps) {
|
||||
const [photoIdx, setPhotoIdx] = useState(0)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [customInput, setCustomInput] = useState('')
|
||||
|
||||
const toggleAmenity = (a: string) =>
|
||||
setAmenities(prev => prev.includes(a) ? prev.filter(x => x !== a) : [...prev, a])
|
||||
|
||||
const addCustom = () => {
|
||||
const v = customInput.trim()
|
||||
if (!v || amenities.includes(v)) return
|
||||
setAmenities(prev => [...prev, v])
|
||||
setCustomInput('')
|
||||
}
|
||||
|
||||
// All amenities to show as toggles: predefined + any custom ones already in the list
|
||||
const customAmenities = amenities.filter(a => !AMENITY_LIST.includes(a))
|
||||
|
||||
const handlePhotoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
Array.from(e.target.files ?? []).forEach(file => {
|
||||
const reader = new FileReader()
|
||||
@@ -190,9 +175,12 @@ function CategoryForm({ category, onClose, onSave }: CategoryFormProps) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Удобства в номере</label>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">Удобства в номере</label>
|
||||
<span className="text-xs text-slate-400">Справочник удобств — на странице «Номера»</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{AMENITY_LIST.map(a => {
|
||||
{allAmenities.map(a => {
|
||||
const sel = amenities.includes(a)
|
||||
return (
|
||||
<button
|
||||
@@ -210,38 +198,10 @@ function CategoryForm({ category, onClose, onSave }: CategoryFormProps) {
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{/* Custom amenities */}
|
||||
{customAmenities.map(a => (
|
||||
<span
|
||||
key={a}
|
||||
className="flex items-center gap-1 pl-3 pr-1 py-1.5 rounded-lg text-xs font-medium border bg-brand-600 text-white border-brand-600"
|
||||
>
|
||||
{a}
|
||||
<button type="button" onClick={() => toggleAmenity(a)} className="w-4 h-4 rounded-full hover:bg-brand-700 flex items-center justify-center">
|
||||
<XIcon size={10} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/* Add custom amenity */}
|
||||
<div className="flex gap-2 mt-2">
|
||||
<input
|
||||
type="text"
|
||||
className="input flex-1 text-sm"
|
||||
placeholder="Добавить своё удобство..."
|
||||
value={customInput}
|
||||
onChange={e => setCustomInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), addCustom())}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addCustom}
|
||||
disabled={!customInput.trim() || amenities.includes(customInput.trim())}
|
||||
className="btn-secondary px-3 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{amenities.length > 0 && (
|
||||
<p className="text-xs text-slate-400 mt-1.5">Выбрано: {amenities.length}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { BedDouble, Users, Wifi, Plus, Search, Pencil } from 'lucide-react'
|
||||
import { BedDouble, Users, Wifi, Plus, Search, Pencil, ChevronDown, ChevronUp, Check, Trash2, X as XIcon } from 'lucide-react'
|
||||
import { useAmenities } from '../contexts/AmenitiesContext'
|
||||
import { MOCK_ROOMS } from '../data/mockData'
|
||||
import { MOCK_CATEGORIES } from './RoomCategoriesPage'
|
||||
import type { Room } from '../types'
|
||||
@@ -14,6 +15,12 @@ export function RoomsPage() {
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editingRoom, setEditingRoom] = useState<Room | undefined>(undefined)
|
||||
|
||||
const { amenities, addAmenity, removeAmenity, renameAmenity } = useAmenities()
|
||||
const [showAmenities, setShowAmenities] = useState(false)
|
||||
const [newAmenity, setNewAmenity] = useState('')
|
||||
const [editingAmenity, setEditingAmenity] = useState<string | null>(null)
|
||||
const [editAmenityValue, setEditAmenityValue] = useState('')
|
||||
|
||||
const floors = [...new Set(rooms.map(r => r.floor))].sort()
|
||||
|
||||
const filtered = rooms.filter(r => {
|
||||
@@ -130,6 +137,96 @@ export function RoomsPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Справочник удобств */}
|
||||
<div className="card overflow-hidden">
|
||||
<button
|
||||
onClick={() => setShowAmenities(v => !v)}
|
||||
className="w-full flex items-center justify-between px-5 py-4 text-left hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900 dark:text-slate-100">Справочник удобств</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||||
{amenities.length} удобств · используется в номерах и категориях
|
||||
</p>
|
||||
</div>
|
||||
{showAmenities
|
||||
? <ChevronUp size={16} className="text-slate-400" />
|
||||
: <ChevronDown size={16} className="text-slate-400" />}
|
||||
</button>
|
||||
|
||||
{showAmenities && (
|
||||
<div className="border-t border-slate-200 dark:border-slate-700 p-5 space-y-4">
|
||||
{/* Add new */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
className="input flex-1 text-sm"
|
||||
placeholder="Добавить удобство (напр. Фен, Сейф, Балкон...)"
|
||||
value={newAmenity}
|
||||
onChange={e => setNewAmenity(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); if (newAmenity.trim()) { addAmenity(newAmenity.trim()); setNewAmenity('') } }
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => { if (newAmenity.trim()) { addAmenity(newAmenity.trim()); setNewAmenity('') } }}
|
||||
disabled={!newAmenity.trim() || amenities.includes(newAmenity.trim())}
|
||||
className="btn-primary px-4 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
|
||||
{amenities.map(a => (
|
||||
<div
|
||||
key={a}
|
||||
className="flex items-center gap-1.5 px-2.5 py-2 rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800 group"
|
||||
>
|
||||
{editingAmenity === a ? (
|
||||
<>
|
||||
<input
|
||||
autoFocus
|
||||
className="input text-xs flex-1 py-0.5 h-6"
|
||||
value={editAmenityValue}
|
||||
onChange={e => setEditAmenityValue(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') { renameAmenity(a, editAmenityValue); setEditingAmenity(null) }
|
||||
if (e.key === 'Escape') setEditingAmenity(null)
|
||||
}}
|
||||
/>
|
||||
<button onClick={() => { renameAmenity(a, editAmenityValue); setEditingAmenity(null) }}
|
||||
className="text-emerald-500 hover:text-emerald-600 shrink-0"><Check size={13} /></button>
|
||||
<button onClick={() => setEditingAmenity(null)}
|
||||
className="text-slate-400 hover:text-slate-600 shrink-0"><XIcon size={13} /></button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-xs text-slate-700 dark:text-slate-300 flex-1 truncate">{a}</span>
|
||||
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||
<button
|
||||
onClick={() => { setEditingAmenity(a); setEditAmenityValue(a) }}
|
||||
className="p-0.5 rounded hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400 hover:text-slate-700"
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeAmenity(a)}
|
||||
className="p-0.5 rounded hover:bg-red-100 dark:hover:bg-red-900/30 text-slate-400 hover:text-red-500"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
<RoomModal
|
||||
open={modalOpen}
|
||||
|
||||
Reference in New Issue
Block a user