Add beds constructor, extra places and child policy to room modal
- New types: BedItem, ExtraPlace, ChildPolicy on Room interface
- New 'Места' tab in RoomModal with 3 sections:
· Beds constructor: add/remove/count any combo of bed types (1-sp, 2-sp,
queen, king, twin, sofa, bunk, cot) with emoji icons and ±1 steppers
· Extra places: toggle + count (max 5) + price per night
· Child policy: toggle + free-under-age stepper + charge-from-age stepper
with green/amber visual cards and summary text
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
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 } from '../../types'
|
import type { Room, RoomStatus, HousekeepingStatus, BedType, BedItem, BedItemType, ExtraPlace, ChildPolicy } from '../../types'
|
||||||
import { ImagePlus, X as XIcon, ChevronLeft, ChevronRight } from 'lucide-react'
|
import { ImagePlus, X as XIcon, ChevronLeft, ChevronRight, Plus, Minus, Baby } from 'lucide-react'
|
||||||
import { useAmenities } from '../../contexts/AmenitiesContext'
|
import { useAmenities } from '../../contexts/AmenitiesContext'
|
||||||
|
|
||||||
const ROOM_TYPES = ['Стандарт', 'Делюкс', 'Полулюкс', 'Люкс', 'Пентхаус', 'Апартаменты', 'Другой']
|
const ROOM_TYPES = ['Стандарт', 'Делюкс', 'Полулюкс', 'Люкс', 'Пентхаус', 'Апартаменты', 'Другой']
|
||||||
@@ -29,8 +29,19 @@ 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' | 'description' | 'photos'
|
type ModalTab = 'main' | 'places' | 'description' | 'photos'
|
||||||
|
|
||||||
interface RoomModalProps {
|
interface RoomModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -67,6 +78,22 @@ export function RoomModal({ open, room, categories = [], onClose, onSave }: Room
|
|||||||
const [photoIdx, setPhotoIdx] = useState(0)
|
const [photoIdx, setPhotoIdx] = useState(0)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// ── Beds constructor ──────────────────────────────────────────────────────
|
||||||
|
const [beds, setBeds] = useState<BedItem[]>(
|
||||||
|
room?.beds ?? [{ type: 'double', count: 1 }]
|
||||||
|
)
|
||||||
|
const [extraPlace, setExtraPlace] = useState<ExtraPlace>(
|
||||||
|
room?.extraPlace ?? { enabled: false, count: 1, price: 1500 }
|
||||||
|
)
|
||||||
|
const [childPolicy, setChildPolicy] = useState<ChildPolicy>(
|
||||||
|
room?.childPolicy ?? { enabled: false, freeUnderAge: 12, chargeFromAge: 12 }
|
||||||
|
)
|
||||||
|
|
||||||
|
const addBed = () => setBeds(prev => [...prev, { type: 'single', count: 1 }])
|
||||||
|
const removeBed = (i: number) => setBeds(prev => prev.filter((_, idx) => idx !== i))
|
||||||
|
const updateBed = (i: number, patch: Partial<BedItem>) =>
|
||||||
|
setBeds(prev => prev.map((b, idx) => idx === i ? { ...b, ...patch } : b))
|
||||||
|
|
||||||
const set = <K extends keyof typeof form>(k: K, v: typeof form[K]) =>
|
const set = <K extends keyof typeof form>(k: K, v: typeof form[K]) =>
|
||||||
setForm(prev => ({ ...prev, [k]: v }))
|
setForm(prev => ({ ...prev, [k]: v }))
|
||||||
|
|
||||||
@@ -116,11 +143,15 @@ export function RoomModal({ open, room, categories = [], onClose, onSave }: Room
|
|||||||
categoryId: form.categoryId || undefined,
|
categoryId: form.categoryId || undefined,
|
||||||
description: form.description || undefined,
|
description: form.description || undefined,
|
||||||
photos: photos.length > 0 ? photos : undefined,
|
photos: photos.length > 0 ? photos : undefined,
|
||||||
|
beds: beds.length > 0 ? beds : undefined,
|
||||||
|
extraPlace: extraPlace.enabled ? extraPlace : undefined,
|
||||||
|
childPolicy: childPolicy.enabled ? childPolicy : undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const TABS: { key: ModalTab; label: string }[] = [
|
const TABS: { key: ModalTab; label: string }[] = [
|
||||||
{ key: 'main', label: 'Основное' },
|
{ key: 'main', label: 'Основное' },
|
||||||
|
{ key: 'places', label: 'Места' },
|
||||||
{ key: 'description', label: 'Описание' },
|
{ key: 'description', label: 'Описание' },
|
||||||
{ key: 'photos', label: `Фото${photos.length > 0 ? ` (${photos.length})` : ''}` },
|
{ key: 'photos', label: `Фото${photos.length > 0 ? ` (${photos.length})` : ''}` },
|
||||||
]
|
]
|
||||||
@@ -285,6 +316,194 @@ export function RoomModal({ open, room, categories = [], onClose, onSave }: Room
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Places tab ── */}
|
||||||
|
{tab === 'places' && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
|
||||||
|
{/* Beds constructor */}
|
||||||
|
<div className="rounded-xl border border-slate-200 dark:border-slate-600 overflow-hidden">
|
||||||
|
<div className="px-4 py-3 bg-slate-50 dark:bg-slate-700/40 border-b border-slate-200 dark:border-slate-600">
|
||||||
|
<p className="text-sm font-semibold text-slate-700 dark:text-slate-300">Спальные места</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Кровати, диваны и другая мебель для сна</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
{beds.map((bed, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<span className="text-lg w-7 text-center shrink-0">
|
||||||
|
{BED_ITEM_TYPES.find(b => b.value === bed.type)?.icon ?? '🛏'}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
className="input flex-1"
|
||||||
|
value={bed.type}
|
||||||
|
onChange={e => updateBed(i, { type: e.target.value as BedItemType })}
|
||||||
|
>
|
||||||
|
{BED_ITEM_TYPES.map(b => (
|
||||||
|
<option key={b.value} value={b.value}>{b.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{/* Count stepper */}
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateBed(i, { count: Math.max(1, bed.count - 1) })}
|
||||||
|
className="w-7 h-7 rounded-lg border border-slate-200 dark:border-slate-600 flex items-center justify-center text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
<Minus size={12} />
|
||||||
|
</button>
|
||||||
|
<span className="w-6 text-center text-sm font-medium text-slate-700 dark:text-slate-300">{bed.count}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateBed(i, { count: Math.min(6, bed.count + 1) })}
|
||||||
|
className="w-7 h-7 rounded-lg border border-slate-200 dark:border-slate-600 flex items-center justify-center text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
<Plus size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeBed(i)}
|
||||||
|
disabled={beds.length === 1}
|
||||||
|
className="w-7 h-7 rounded-lg flex items-center justify-center text-slate-400 hover:text-red-500 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<XIcon size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addBed}
|
||||||
|
className="btn-secondary w-full justify-center text-xs py-1.5"
|
||||||
|
>
|
||||||
|
<Plus size={13} />
|
||||||
|
Добавить спальное место
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Extra places */}
|
||||||
|
<div className="rounded-xl border border-slate-200 dark:border-slate-600 overflow-hidden">
|
||||||
|
<div className="px-4 py-3 bg-slate-50 dark:bg-slate-700/40 border-b border-slate-200 dark:border-slate-600 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-slate-700 dark:text-slate-300">Дополнительные места</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Раскладные кровати, кушетки за доп. плату</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExtraPlace(p => ({ ...p, enabled: !p.enabled }))}
|
||||||
|
className={cn('relative w-11 h-6 rounded-full transition-colors shrink-0', extraPlace.enabled ? 'bg-brand-600' : 'bg-slate-300 dark:bg-slate-600')}
|
||||||
|
>
|
||||||
|
<div className={cn('absolute top-0.5 w-5 h-5 rounded-full bg-white shadow-sm transition-transform', extraPlace.enabled ? 'left-[22px]' : 'left-0.5')} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{extraPlace.enabled && (
|
||||||
|
<div className="p-4 grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Кол-во мест</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button type="button" onClick={() => setExtraPlace(p => ({ ...p, count: Math.max(1, p.count - 1) }))}
|
||||||
|
className="w-8 h-8 rounded-lg border border-slate-200 dark:border-slate-600 flex items-center justify-center text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700">
|
||||||
|
<Minus size={13} />
|
||||||
|
</button>
|
||||||
|
<span className="w-8 text-center font-semibold text-slate-800 dark:text-slate-200">{extraPlace.count}</span>
|
||||||
|
<button type="button" onClick={() => setExtraPlace(p => ({ ...p, count: Math.min(5, p.count + 1) }))}
|
||||||
|
className="w-8 h-8 rounded-lg border border-slate-200 dark:border-slate-600 flex items-center justify-center text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700">
|
||||||
|
<Plus size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Цена ₽/ночь</label>
|
||||||
|
<input
|
||||||
|
type="number" min={0} className="input"
|
||||||
|
value={extraPlace.price}
|
||||||
|
onChange={e => setExtraPlace(p => ({ ...p, price: parseInt(e.target.value) || 0 }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Child policy */}
|
||||||
|
<div className="rounded-xl border border-slate-200 dark:border-slate-600 overflow-hidden">
|
||||||
|
<div className="px-4 py-3 bg-slate-50 dark:bg-slate-700/40 border-b border-slate-200 dark:border-slate-600 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Baby size={16} className="text-slate-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-slate-700 dark:text-slate-300">Политика для детей</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Возраст бесплатного проживания</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setChildPolicy(p => ({ ...p, enabled: !p.enabled }))}
|
||||||
|
className={cn('relative w-11 h-6 rounded-full transition-colors shrink-0', childPolicy.enabled ? 'bg-brand-600' : 'bg-slate-300 dark:bg-slate-600')}
|
||||||
|
>
|
||||||
|
<div className={cn('absolute top-0.5 w-5 h-5 rounded-full bg-white shadow-sm transition-transform', childPolicy.enabled ? 'left-[22px]' : 'left-0.5')} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{childPolicy.enabled ? (
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800">
|
||||||
|
<span className="text-xl">🆓</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs font-medium text-emerald-800 dark:text-emerald-300">Бесплатно до</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
<button type="button" onClick={() => setChildPolicy(p => ({ ...p, freeUnderAge: Math.max(1, p.freeUnderAge - 1) }))}
|
||||||
|
className="w-7 h-7 rounded-lg border border-emerald-200 dark:border-emerald-700 flex items-center justify-center text-emerald-600 hover:bg-emerald-100 dark:hover:bg-emerald-900/40">
|
||||||
|
<Minus size={12} />
|
||||||
|
</button>
|
||||||
|
<span className="w-16 text-center text-sm font-semibold text-emerald-800 dark:text-emerald-300">
|
||||||
|
до {childPolicy.freeUnderAge} лет
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={() => setChildPolicy(p => ({ ...p, freeUnderAge: Math.min(17, p.freeUnderAge + 1) }))}
|
||||||
|
className="w-7 h-7 rounded-lg border border-emerald-200 dark:border-emerald-700 flex items-center justify-center text-emerald-600 hover:bg-emerald-100 dark:hover:bg-emerald-900/40">
|
||||||
|
<Plus size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800">
|
||||||
|
<span className="text-xl">💰</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs font-medium text-amber-800 dark:text-amber-300">Доп. место от</p>
|
||||||
|
{extraPlace.enabled && (
|
||||||
|
<p className="text-[10px] text-amber-600 dark:text-amber-400">{extraPlace.price} ₽/ночь</p>
|
||||||
|
)}
|
||||||
|
{!extraPlace.enabled && (
|
||||||
|
<p className="text-[10px] text-amber-600 dark:text-amber-400">Включите «Доп. места» и укажите цену</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
<button type="button" onClick={() => setChildPolicy(p => ({ ...p, chargeFromAge: Math.max(1, p.chargeFromAge - 1) }))}
|
||||||
|
className="w-7 h-7 rounded-lg border border-amber-200 dark:border-amber-700 flex items-center justify-center text-amber-600 hover:bg-amber-100 dark:hover:bg-amber-900/40">
|
||||||
|
<Minus size={12} />
|
||||||
|
</button>
|
||||||
|
<span className="w-16 text-center text-sm font-semibold text-amber-800 dark:text-amber-300">
|
||||||
|
от {childPolicy.chargeFromAge} лет
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={() => setChildPolicy(p => ({ ...p, chargeFromAge: Math.min(17, p.chargeFromAge + 1) }))}
|
||||||
|
className="w-7 h-7 rounded-lg border border-amber-200 dark:border-amber-700 flex items-center justify-center text-amber-600 hover:bg-amber-100 dark:hover:bg-amber-900/40">
|
||||||
|
<Plus size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 px-1">
|
||||||
|
Дети до {childPolicy.freeUnderAge} лет — бесплатно без доп. места.
|
||||||
|
Дети от {childPolicy.chargeFromAge} лет — оплачивают дополнительное место.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">Стандартные условия, все гости оплачиваются</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Description tab ── */}
|
{/* ── Description tab ── */}
|
||||||
{tab === 'description' && (
|
{tab === 'description' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -42,6 +42,25 @@ export type RoomStatus = 'available' | 'occupied' | 'maintenance' | 'blocked'
|
|||||||
export type HousekeepingStatus = 'clean' | 'dirty' | 'cleaning' | 'inspect'
|
export type HousekeepingStatus = 'clean' | 'dirty' | 'cleaning' | 'inspect'
|
||||||
export type BedType = 'single' | 'double' | 'queen' | 'king' | 'twin'
|
export type BedType = 'single' | 'double' | 'queen' | 'king' | 'twin'
|
||||||
|
|
||||||
|
export type BedItemType = 'single' | 'double' | 'queen' | 'king' | 'twin' | 'sofa' | 'bunk' | 'cot'
|
||||||
|
|
||||||
|
export interface BedItem {
|
||||||
|
type: BedItemType
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtraPlace {
|
||||||
|
enabled: boolean
|
||||||
|
count: number // max extra places available
|
||||||
|
price: number // price per extra place per night
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChildPolicy {
|
||||||
|
enabled: boolean
|
||||||
|
freeUnderAge: number // children strictly under this age stay free
|
||||||
|
chargeFromAge: number // children this age and above pay for extra place
|
||||||
|
}
|
||||||
|
|
||||||
export interface RoomCategory {
|
export interface RoomCategory {
|
||||||
id: string
|
id: string
|
||||||
hotelId: string
|
hotelId: string
|
||||||
@@ -71,6 +90,9 @@ export interface Room {
|
|||||||
description?: string
|
description?: string
|
||||||
photos?: string[]
|
photos?: string[]
|
||||||
categoryId?: string
|
categoryId?: string
|
||||||
|
beds?: BedItem[]
|
||||||
|
extraPlace?: ExtraPlace
|
||||||
|
childPolicy?: ChildPolicy
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocumentTemplate {
|
export interface DocumentTemplate {
|
||||||
|
|||||||
Reference in New Issue
Block a user