Add hourly indicator in calendar, extra beds, custom amenities, meal/inclusion management
- Calendar: show clock icon + hours on hourly bookings (detect [Почасово:] prefix in notes) - BookingModal: add extra beds counter (max 4, 1500₽/place/night) included in total - RoomCategoriesPage: custom amenity input — type any amenity and add it alongside predefined list - TariffsPage: collapsible Справочники section with full add/edit/delete for meal plan types and room inclusions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { Plus, Pencil, Trash2, ImagePlus, X as XIcon, Tag, ChevronRight } from 'lucide-react'
|
||||
import { Plus, Pencil, Trash2, ImagePlus, X as XIcon, Tag, ChevronRight, Check } from 'lucide-react'
|
||||
import { cn } from '../lib/utils'
|
||||
import type { RoomCategory } from '../types'
|
||||
|
||||
@@ -71,9 +71,21 @@ 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()
|
||||
@@ -178,7 +190,7 @@ 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>
|
||||
<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 => {
|
||||
const sel = amenities.includes(a)
|
||||
@@ -198,6 +210,37 @@ 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>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { useState } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
Plus, Edit2, Trash2, ToggleLeft, ToggleRight, Coffee, Utensils,
|
||||
Wifi, Car, Waves, Sparkles, Plane, Package, Check, X, Info, Tag,
|
||||
ChevronDown, ChevronUp, Pencil,
|
||||
} from 'lucide-react'
|
||||
import { Modal } from '../components/ui/Modal'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type MealPlan = 'no_meals' | 'breakfast' | 'hb' | 'fb' | 'ai'
|
||||
type MealPlan = string
|
||||
|
||||
interface TariffInclusion {
|
||||
id: string
|
||||
@@ -135,11 +136,13 @@ const EMPTY_TARIFF: Omit<Tariff, 'id'> = {
|
||||
}
|
||||
|
||||
function TariffModal({
|
||||
tariff, onSave, onClose,
|
||||
tariff, onSave, onClose, mealPlans, allInclusions,
|
||||
}: {
|
||||
tariff?: Tariff
|
||||
onSave: (t: Omit<Tariff, 'id'>) => void
|
||||
onClose: () => void
|
||||
mealPlans: Array<{ id: string; label: string; description: string; inclusions: string[] }>
|
||||
allInclusions: Array<{ id: string; label: string; icon: React.ElementType }>
|
||||
}) {
|
||||
const [form, setForm] = useState<Omit<Tariff, 'id'>>(
|
||||
tariff ? { ...tariff } : { ...EMPTY_TARIFF }
|
||||
@@ -157,7 +160,7 @@ function TariffModal({
|
||||
}))
|
||||
|
||||
const applyMealPlan = (plan: MealPlan) => {
|
||||
const mp = MEAL_PLANS.find(m => m.id === plan)!
|
||||
const mp = mealPlans.find(m => m.id === plan)!
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
mealPlan: plan,
|
||||
@@ -221,7 +224,7 @@ function TariffModal({
|
||||
Тип питания
|
||||
</label>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{MEAL_PLANS.map(mp => (
|
||||
{mealPlans.map(mp => (
|
||||
<button
|
||||
key={mp.id}
|
||||
type="button"
|
||||
@@ -248,7 +251,7 @@ function TariffModal({
|
||||
Что включено
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{ALL_INCLUSIONS.map(inc => {
|
||||
{allInclusions.map(inc => {
|
||||
const active = form.inclusions.includes(inc.id)
|
||||
return (
|
||||
<button
|
||||
@@ -375,6 +378,25 @@ export function TariffsPage() {
|
||||
const [modal, setModal] = useState<'create' | Tariff | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<Tariff | null>(null)
|
||||
|
||||
// Editable meal plans & inclusions
|
||||
const [mealPlans, setMealPlans] = useState<Array<{ id: string; label: string; description: string; inclusions: string[] }>>(MEAL_PLANS)
|
||||
const [allInclusions, setAllInclusions] = useState(ALL_INCLUSIONS)
|
||||
const [showDicts, setShowDicts] = useState(false)
|
||||
|
||||
// Meal plan editing
|
||||
const [editMeal, setEditMeal] = useState<{ id: string; label: string; description: string; inclusions: string[] } | null>(null)
|
||||
const [newMealLabel, setNewMealLabel] = useState('')
|
||||
const [newMealDesc, setNewMealDesc] = useState('')
|
||||
const [addMealOpen, setAddMealOpen] = useState(false)
|
||||
|
||||
// Inclusion editing
|
||||
const [editInc, setEditInc] = useState<{ id: string; label: string; icon: React.ElementType } | null>(null)
|
||||
const [newIncLabel, setNewIncLabel] = useState('')
|
||||
const [addIncOpen, setAddIncOpen] = useState(false)
|
||||
|
||||
const DEFAULT_MEAL_IDS = MEAL_PLANS.map(m => m.id)
|
||||
const DEFAULT_INC_IDS = ALL_INCLUSIONS.map(i => i.id)
|
||||
|
||||
const save = (data: Omit<Tariff, 'id'>) => {
|
||||
if (typeof modal === 'object' && modal !== null) {
|
||||
setTariffs(prev => prev.map(t => t.id === modal.id ? { ...data, id: modal.id } : t))
|
||||
@@ -433,7 +455,7 @@ export function TariffsPage() {
|
||||
{/* Tariff list */}
|
||||
<div className="space-y-3">
|
||||
{tariffs.map(tariff => {
|
||||
const mp = MEAL_PLANS.find(m => m.id === tariff.mealPlan)!
|
||||
const mp = mealPlans.find(m => m.id === tariff.mealPlan) ?? mealPlans[0]
|
||||
const modLabel = tariff.modifierValue === 0
|
||||
? 'Без надбавки'
|
||||
: `${tariff.modifierValue > 0 ? '+' : ''}${tariff.modifierValue}${tariff.modifierType === 'percent' ? '%' : ' ₽'}`
|
||||
@@ -469,7 +491,7 @@ export function TariffsPage() {
|
||||
{/* Inclusions */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{tariff.inclusions.map(incId => {
|
||||
const inc = ALL_INCLUSIONS.find(i => i.id === incId)
|
||||
const inc = allInclusions.find(i => i.id === incId)
|
||||
if (!inc) return null
|
||||
return (
|
||||
<span key={incId} className="flex items-center gap-1 px-2 py-0.5 rounded-lg text-xs bg-emerald-50 text-emerald-700 dark:bg-emerald-900/20 dark:text-emerald-400 border border-emerald-200 dark:border-emerald-800">
|
||||
@@ -526,12 +548,133 @@ export function TariffsPage() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Справочники (meal plans + inclusions) */}
|
||||
<div className="card overflow-hidden">
|
||||
<button
|
||||
onClick={() => setShowDicts(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">Типы питания и комплектация номера</p>
|
||||
</div>
|
||||
{showDicts ? <ChevronUp size={16} className="text-slate-400" /> : <ChevronDown size={16} className="text-slate-400" />}
|
||||
</button>
|
||||
|
||||
{showDicts && (
|
||||
<div className="border-t border-slate-200 dark:border-slate-700 grid md:grid-cols-2 divide-y md:divide-y-0 md:divide-x divide-slate-200 dark:divide-slate-700">
|
||||
{/* Meal plans */}
|
||||
<div className="p-5 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-slate-700 dark:text-slate-300">Типы питания</p>
|
||||
<button onClick={() => setAddMealOpen(true)} className="btn-secondary text-xs py-1 px-2.5 gap-1">
|
||||
<Plus size={12} /> Добавить
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{mealPlans.map(mp => (
|
||||
<div key={mp.id} className="flex items-center gap-2 py-1.5 px-2 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700/30 group">
|
||||
{editMeal?.id === mp.id ? (
|
||||
<>
|
||||
<input className="input text-sm flex-1 py-1" value={editMeal.label}
|
||||
onChange={e => setEditMeal(p => p ? {...p, label: e.target.value} : p)} />
|
||||
<input className="input text-xs flex-1 py-1 text-slate-500" value={editMeal.description}
|
||||
onChange={e => setEditMeal(p => p ? {...p, description: e.target.value} : p)} />
|
||||
<button onClick={() => { setMealPlans(prev => prev.map(m => m.id === mp.id ? editMeal : m)); setEditMeal(null) }}
|
||||
className="p-1 rounded hover:bg-emerald-100 text-emerald-600"><Check size={13} /></button>
|
||||
<button onClick={() => setEditMeal(null)} className="p-1 rounded hover:bg-red-100 text-red-500"><X size={13} /></button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{mp.label}</p>
|
||||
<p className="text-[11px] text-slate-400 truncate">{mp.description}</p>
|
||||
</div>
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={() => setEditMeal({...mp})} className="p-1 rounded hover:bg-slate-200 dark:hover:bg-slate-600 text-slate-400 hover:text-slate-700"><Pencil size={12} /></button>
|
||||
{!DEFAULT_MEAL_IDS.includes(mp.id) && (
|
||||
<button onClick={() => setMealPlans(prev => prev.filter(m => m.id !== mp.id))} className="p-1 rounded hover:bg-red-100 text-slate-400 hover:text-red-500"><Trash2 size={12} /></button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{addMealOpen && (
|
||||
<div className="space-y-2 p-3 rounded-xl border border-brand-200 dark:border-brand-700 bg-brand-50/30 dark:bg-brand-900/10">
|
||||
<input className="input text-sm" placeholder="Название (напр. Полный завтрак)" value={newMealLabel} onChange={e => setNewMealLabel(e.target.value)} />
|
||||
<input className="input text-sm" placeholder="Описание (напр. FB — Full Board)" value={newMealDesc} onChange={e => setNewMealDesc(e.target.value)} />
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setAddMealOpen(false)} className="btn-secondary flex-1 text-xs py-1.5 justify-center">Отмена</button>
|
||||
<button disabled={!newMealLabel.trim()} onClick={() => {
|
||||
setMealPlans(prev => [...prev, { id: `meal-${Date.now()}`, label: newMealLabel.trim(), description: newMealDesc.trim(), inclusions: [] }])
|
||||
setNewMealLabel(''); setNewMealDesc(''); setAddMealOpen(false)
|
||||
}} className="btn-primary flex-1 text-xs py-1.5 justify-center disabled:opacity-50">Добавить</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Inclusions */}
|
||||
<div className="p-5 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-slate-700 dark:text-slate-300">Что включено в номер</p>
|
||||
<button onClick={() => setAddIncOpen(true)} className="btn-secondary text-xs py-1 px-2.5 gap-1">
|
||||
<Plus size={12} /> Добавить
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{allInclusions.map(inc => (
|
||||
<div key={inc.id} className="flex items-center gap-2 py-1.5 px-2 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700/30 group">
|
||||
{editInc?.id === inc.id ? (
|
||||
<>
|
||||
<input className="input text-sm flex-1 py-1" value={editInc.label}
|
||||
onChange={e => setEditInc(p => p ? {...p, label: e.target.value} : p)} />
|
||||
<button onClick={() => { setAllInclusions(prev => prev.map(i => i.id === inc.id ? editInc : i)); setEditInc(null) }}
|
||||
className="p-1 rounded hover:bg-emerald-100 text-emerald-600"><Check size={13} /></button>
|
||||
<button onClick={() => setEditInc(null)} className="p-1 rounded hover:bg-red-100 text-red-500"><X size={13} /></button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<inc.icon size={14} className="text-slate-400 shrink-0" />
|
||||
<span className="flex-1 text-sm text-slate-800 dark:text-slate-200">{inc.label}</span>
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={() => setEditInc({...inc})} className="p-1 rounded hover:bg-slate-200 dark:hover:bg-slate-600 text-slate-400 hover:text-slate-700"><Pencil size={12} /></button>
|
||||
{!DEFAULT_INC_IDS.includes(inc.id) && (
|
||||
<button onClick={() => setAllInclusions(prev => prev.filter(i => i.id !== inc.id))} className="p-1 rounded hover:bg-red-100 text-slate-400 hover:text-red-500"><Trash2 size={12} /></button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{addIncOpen && (
|
||||
<div className="space-y-2 p-3 rounded-xl border border-brand-200 dark:border-brand-700 bg-brand-50/30 dark:bg-brand-900/10">
|
||||
<input className="input text-sm" placeholder="Название (напр. Тренажёрный зал)" value={newIncLabel} onChange={e => setNewIncLabel(e.target.value)} />
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setAddIncOpen(false)} className="btn-secondary flex-1 text-xs py-1.5 justify-center">Отмена</button>
|
||||
<button disabled={!newIncLabel.trim()} onClick={() => {
|
||||
setAllInclusions(prev => [...prev, { id: `inc-${Date.now()}`, label: newIncLabel.trim(), icon: Package }])
|
||||
setNewIncLabel(''); setAddIncOpen(false)
|
||||
}} className="btn-primary flex-1 text-xs py-1.5 justify-center disabled:opacity-50">Добавить</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tariff Modal */}
|
||||
{modal && (
|
||||
<TariffModal
|
||||
tariff={typeof modal === 'object' ? modal : undefined}
|
||||
onSave={save}
|
||||
onClose={() => setModal(null)}
|
||||
mealPlans={mealPlans}
|
||||
allInclusions={allInclusions}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user