feat: configurable bed types via BedTypesContext
- Add BedTypesContext with default types + add/remove/update operations - Replace hardcoded BED_ITEM_TYPES array in RoomModal with useBedTypes() - Add "Справочник спальных мест" section to RoomCategoriesPage (expandable, same UX as amenities: add icon+label, rename, delete) - BedItem.type changed from union to string for dynamic support Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { ThemeProvider } from './contexts/ThemeContext'
|
|||||||
import { AuthProvider } from './contexts/AuthContext'
|
import { AuthProvider } from './contexts/AuthContext'
|
||||||
import { ModulesProvider } from './contexts/ModulesContext'
|
import { ModulesProvider } from './contexts/ModulesContext'
|
||||||
import { AmenitiesProvider } from './contexts/AmenitiesContext'
|
import { AmenitiesProvider } from './contexts/AmenitiesContext'
|
||||||
|
import { BedTypesProvider } from './contexts/BedTypesContext'
|
||||||
import { NotificationsProvider } from './contexts/NotificationsContext'
|
import { NotificationsProvider } from './contexts/NotificationsContext'
|
||||||
import { AppLayout } from './layouts/AppLayout'
|
import { AppLayout } from './layouts/AppLayout'
|
||||||
import { LoginPage } from './pages/LoginPage'
|
import { LoginPage } from './pages/LoginPage'
|
||||||
@@ -45,6 +46,7 @@ export default function App() {
|
|||||||
<ModulesProvider>
|
<ModulesProvider>
|
||||||
<NotificationsProvider>
|
<NotificationsProvider>
|
||||||
<AmenitiesProvider>
|
<AmenitiesProvider>
|
||||||
|
<BedTypesProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Public */}
|
{/* Public */}
|
||||||
@@ -89,6 +91,7 @@ export default function App() {
|
|||||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
</BedTypesProvider>
|
||||||
</AmenitiesProvider>
|
</AmenitiesProvider>
|
||||||
</NotificationsProvider>
|
</NotificationsProvider>
|
||||||
</ModulesProvider>
|
</ModulesProvider>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState, useRef } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import { Modal } from '../ui/Modal'
|
import { Modal } from '../ui/Modal'
|
||||||
import { cn } from '../../lib/utils'
|
import { cn } from '../../lib/utils'
|
||||||
import type { Room, RoomStatus, HousekeepingStatus, BedType, BedItem, BedItemType, ExtraPlace, ChildPolicy } from '../../types'
|
import type { Room, RoomStatus, HousekeepingStatus, BedType, BedItem, ExtraPlace, ChildPolicy } from '../../types'
|
||||||
import { ImagePlus, X as XIcon, ChevronLeft, ChevronRight, Plus, Minus, Baby, Trash2 } from 'lucide-react'
|
import { ImagePlus, X as XIcon, ChevronLeft, ChevronRight, Plus, Minus, Baby, Trash2 } from 'lucide-react'
|
||||||
import { useAmenities } from '../../contexts/AmenitiesContext'
|
import { useAmenities } from '../../contexts/AmenitiesContext'
|
||||||
|
import { useBedTypes } from '../../contexts/BedTypesContext'
|
||||||
|
|
||||||
const ROOM_TYPES = ['Стандарт', 'Делюкс', 'Полулюкс', 'Люкс', 'Пентхаус', 'Апартаменты', 'Другой']
|
const ROOM_TYPES = ['Стандарт', 'Делюкс', 'Полулюкс', 'Люкс', 'Пентхаус', 'Апартаменты', 'Другой']
|
||||||
|
|
||||||
@@ -29,16 +30,6 @@ const HK_STATUSES: { value: HousekeepingStatus; label: string }[] = [
|
|||||||
{ value: 'inspect', label: 'Проверка' },
|
{ value: 'inspect', label: 'Проверка' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const BED_ITEM_TYPES: { value: BedItemType; label: string; icon: string }[] = [
|
|
||||||
{ value: 'single', label: 'Кровать 1-сп.', icon: '🛏' },
|
|
||||||
{ value: 'double', label: 'Кровать 2-сп.', icon: '🛏' },
|
|
||||||
{ value: 'queen', label: 'Queen-кровать', icon: '🛏' },
|
|
||||||
{ value: 'king', label: 'King-кровать', icon: '🛏' },
|
|
||||||
{ value: 'twin', label: 'Две кровати', icon: '🛏' },
|
|
||||||
{ value: 'sofa', label: 'Диван', icon: '🛋' },
|
|
||||||
{ value: 'bunk', label: 'Двухъярусная', icon: '🛏' },
|
|
||||||
{ value: 'cot', label: 'Раскладушка', icon: '🪑' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// ── Tab type ───────────────────────────────────────────────────────────────────
|
// ── Tab type ───────────────────────────────────────────────────────────────────
|
||||||
type ModalTab = 'main' | 'places' | 'description' | 'photos'
|
type ModalTab = 'main' | 'places' | 'description' | 'photos'
|
||||||
@@ -78,6 +69,7 @@ export function RoomModal({ open, room, existingNumbers = [], categories = [], o
|
|||||||
})
|
})
|
||||||
|
|
||||||
const { amenities: allAmenities } = useAmenities()
|
const { amenities: allAmenities } = useAmenities()
|
||||||
|
const { bedTypes } = useBedTypes()
|
||||||
const [amenities, setAmenities] = useState<string[]>(room?.amenities ?? ['Wi-Fi', 'TV', 'AC'])
|
const [amenities, setAmenities] = useState<string[]>(room?.amenities ?? ['Wi-Fi', 'TV', 'AC'])
|
||||||
const [photos, setPhotos] = useState<string[]>(room?.photos ?? [])
|
const [photos, setPhotos] = useState<string[]>(room?.photos ?? [])
|
||||||
const [photoIdx, setPhotoIdx] = useState(0)
|
const [photoIdx, setPhotoIdx] = useState(0)
|
||||||
@@ -423,14 +415,14 @@ export function RoomModal({ open, room, existingNumbers = [], categories = [], o
|
|||||||
{beds.map((bed, i) => (
|
{beds.map((bed, i) => (
|
||||||
<div key={i} className="flex items-center gap-2">
|
<div key={i} className="flex items-center gap-2">
|
||||||
<span className="text-lg w-7 text-center shrink-0">
|
<span className="text-lg w-7 text-center shrink-0">
|
||||||
{BED_ITEM_TYPES.find(b => b.value === bed.type)?.icon ?? '🛏'}
|
{bedTypes.find(b => b.value === bed.type)?.icon ?? '🛏'}
|
||||||
</span>
|
</span>
|
||||||
<select
|
<select
|
||||||
className="input flex-1"
|
className="input flex-1"
|
||||||
value={bed.type}
|
value={bed.type}
|
||||||
onChange={e => updateBed(i, { type: e.target.value as BedItemType })}
|
onChange={e => updateBed(i, { type: e.target.value })}
|
||||||
>
|
>
|
||||||
{BED_ITEM_TYPES.map(b => (
|
{bedTypes.map(b => (
|
||||||
<option key={b.value} value={b.value}>{b.label}</option>
|
<option key={b.value} value={b.value}>{b.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
53
src/contexts/BedTypesContext.tsx
Normal file
53
src/contexts/BedTypesContext.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { createContext, useContext, useState } from 'react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
export interface BedTypeItem {
|
||||||
|
value: string // unique key stored in DB (e.g. 'double', 'sofa')
|
||||||
|
label: string // display name
|
||||||
|
icon: string // emoji
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_BED_TYPES: BedTypeItem[] = [
|
||||||
|
{ value: 'single', label: 'Кровать 1-сп.', icon: '🛏' },
|
||||||
|
{ value: 'double', label: 'Кровать 2-сп.', icon: '🛏' },
|
||||||
|
{ value: 'queen', label: 'Queen-кровать', icon: '🛏' },
|
||||||
|
{ value: 'king', label: 'King-кровать', icon: '🛏' },
|
||||||
|
{ value: 'twin', label: 'Две кровати', icon: '🛏' },
|
||||||
|
{ value: 'sofa', label: 'Диван', icon: '🛋' },
|
||||||
|
{ value: 'bunk', label: 'Двухъярусная', icon: '🛏' },
|
||||||
|
{ value: 'cot', label: 'Раскладушка', icon: '🪑' },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface BedTypesContextValue {
|
||||||
|
bedTypes: BedTypeItem[]
|
||||||
|
addBedType: (item: BedTypeItem) => void
|
||||||
|
removeBedType: (value: string) => void
|
||||||
|
updateBedType: (value: string, patch: Partial<Omit<BedTypeItem, 'value'>>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const BedTypesContext = createContext<BedTypesContextValue | null>(null)
|
||||||
|
|
||||||
|
export function BedTypesProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [bedTypes, setBedTypes] = useState<BedTypeItem[]>(DEFAULT_BED_TYPES)
|
||||||
|
|
||||||
|
const addBedType = (item: BedTypeItem) =>
|
||||||
|
setBedTypes(prev => prev.find(b => b.value === item.value) ? prev : [...prev, item])
|
||||||
|
|
||||||
|
const removeBedType = (value: string) =>
|
||||||
|
setBedTypes(prev => prev.filter(b => b.value !== value))
|
||||||
|
|
||||||
|
const updateBedType = (value: string, patch: Partial<Omit<BedTypeItem, 'value'>>) =>
|
||||||
|
setBedTypes(prev => prev.map(b => b.value === value ? { ...b, ...patch } : b))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BedTypesContext.Provider value={{ bedTypes, addBedType, removeBedType, updateBedType }}>
|
||||||
|
{children}
|
||||||
|
</BedTypesContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBedTypes() {
|
||||||
|
const ctx = useContext(BedTypesContext)
|
||||||
|
if (!ctx) throw new Error('useBedTypes must be used within BedTypesProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Plus, Pencil, Trash2, ImagePlus, X as XIcon, Tag, ChevronRight, Chevron
|
|||||||
import { cn } from '../lib/utils'
|
import { cn } from '../lib/utils'
|
||||||
import type { RoomCategory } from '../types'
|
import type { RoomCategory } from '../types'
|
||||||
import { useAmenities } from '../contexts/AmenitiesContext'
|
import { useAmenities } from '../contexts/AmenitiesContext'
|
||||||
|
import { useBedTypes } from '../contexts/BedTypesContext'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import type { CategoryApi } from '../lib/api'
|
import type { CategoryApi } from '../lib/api'
|
||||||
@@ -362,6 +363,15 @@ export function RoomCategoriesPage() {
|
|||||||
const [editingAmenity, setEditingAmenity] = useState<string | null>(null)
|
const [editingAmenity, setEditingAmenity] = useState<string | null>(null)
|
||||||
const [editAmenityValue, setEditAmenityValue] = useState('')
|
const [editAmenityValue, setEditAmenityValue] = useState('')
|
||||||
|
|
||||||
|
// Bed types directory state
|
||||||
|
const { bedTypes, addBedType, removeBedType, updateBedType } = useBedTypes()
|
||||||
|
const [showBedTypes, setShowBedTypes] = useState(false)
|
||||||
|
const [newBedLabel, setNewBedLabel] = useState('')
|
||||||
|
const [newBedIcon, setNewBedIcon] = useState('🛏')
|
||||||
|
const [editingBed, setEditingBed] = useState<string | null>(null)
|
||||||
|
const [editBedLabel, setEditBedLabel] = useState('')
|
||||||
|
const [editBedIcon, setEditBedIcon] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!slug) return
|
if (!slug) return
|
||||||
api.categories.list(slug)
|
api.categories.list(slug)
|
||||||
@@ -599,6 +609,139 @@ export function RoomCategoriesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Справочник спальных мест */}
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowBedTypes(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">
|
||||||
|
{bedTypes.length} типов · используется при настройке номеров
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{showBedTypes
|
||||||
|
? <ChevronUp size={16} className="text-slate-400" />
|
||||||
|
: <ChevronDown size={16} className="text-slate-400" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showBedTypes && (
|
||||||
|
<div className="border-t border-slate-200 dark:border-slate-700 p-5 space-y-4">
|
||||||
|
{/* Add new */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
className="input w-16 text-lg px-2 text-center"
|
||||||
|
value={newBedIcon}
|
||||||
|
onChange={e => setNewBedIcon(e.target.value)}
|
||||||
|
>
|
||||||
|
{['🛏', '🛋', '🪑', '🛌', '🚪'].map(ic => (
|
||||||
|
<option key={ic} value={ic}>{ic}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input flex-1 text-sm"
|
||||||
|
placeholder="Название (напр. Водяная кровать, Гамак...)"
|
||||||
|
value={newBedLabel}
|
||||||
|
onChange={e => setNewBedLabel(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
const label = newBedLabel.trim()
|
||||||
|
if (!label) return
|
||||||
|
const value = label.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_а-яё]/gi, '')
|
||||||
|
+ '_' + Date.now().toString(36)
|
||||||
|
addBedType({ value, label, icon: newBedIcon })
|
||||||
|
setNewBedLabel('')
|
||||||
|
setNewBedIcon('🛏')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const label = newBedLabel.trim()
|
||||||
|
if (!label) return
|
||||||
|
const value = label.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_а-яё]/gi, '')
|
||||||
|
+ '_' + Date.now().toString(36)
|
||||||
|
addBedType({ value, label, icon: newBedIcon })
|
||||||
|
setNewBedLabel('')
|
||||||
|
setNewBedIcon('🛏')
|
||||||
|
}}
|
||||||
|
disabled={!newBedLabel.trim()}
|
||||||
|
className="btn-primary px-4 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Plus size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
|
||||||
|
{bedTypes.map(b => (
|
||||||
|
<div
|
||||||
|
key={b.value}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{editingBed === b.value ? (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
className="text-base w-8 shrink-0 bg-transparent"
|
||||||
|
value={editBedIcon}
|
||||||
|
onChange={e => setEditBedIcon(e.target.value)}
|
||||||
|
>
|
||||||
|
{['🛏', '🛋', '🪑', '🛌', '🚪'].map(ic => (
|
||||||
|
<option key={ic} value={ic}>{ic}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
className="input text-xs flex-1 py-0.5 h-6"
|
||||||
|
value={editBedLabel}
|
||||||
|
onChange={e => setEditBedLabel(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
updateBedType(b.value, { label: editBedLabel, icon: editBedIcon })
|
||||||
|
setEditingBed(null)
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') setEditingBed(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => { updateBedType(b.value, { label: editBedLabel, icon: editBedIcon }); setEditingBed(null) }}
|
||||||
|
className="text-emerald-500 hover:text-emerald-600 shrink-0"
|
||||||
|
>
|
||||||
|
<Check size={13} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setEditingBed(null)} className="text-slate-400 hover:text-slate-600 shrink-0">
|
||||||
|
<XIcon size={13} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-base shrink-0">{b.icon}</span>
|
||||||
|
<span className="text-xs text-slate-700 dark:text-slate-300 flex-1 truncate">{b.label}</span>
|
||||||
|
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingBed(b.value); setEditBedLabel(b.label); setEditBedIcon(b.icon) }}
|
||||||
|
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={() => removeBedType(b.value)}
|
||||||
|
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>
|
||||||
|
|
||||||
{formOpen && (
|
{formOpen && (
|
||||||
<CategoryForm
|
<CategoryForm
|
||||||
category={editingCat}
|
category={editingCat}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export type BedType = 'single' | 'double' | 'queen' | 'king' | 'twin'
|
|||||||
export type BedItemType = 'single' | 'double' | 'queen' | 'king' | 'twin' | 'sofa' | 'bunk' | 'cot'
|
export type BedItemType = 'single' | 'double' | 'queen' | 'king' | 'twin' | 'sofa' | 'bunk' | 'cot'
|
||||||
|
|
||||||
export interface BedItem {
|
export interface BedItem {
|
||||||
type: BedItemType
|
type: string
|
||||||
count: number
|
count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user