Files
hotelsync/src/pages/TariffsPage.tsx

802 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect } from 'react'
import {
Plus, Edit2, Trash2, ToggleLeft, ToggleRight, Coffee, Utensils,
Wifi, Car, Waves, Sparkles, Plane, Package, Check, X, Info, Tag,
ChevronDown, ChevronUp, Pencil, Loader2,
} from 'lucide-react'
import { Modal } from '../components/ui/Modal'
import { cn } from '../lib/utils'
import { useAuth } from '../contexts/AuthContext'
import { api } from '../lib/api'
import type { TariffApi } from '../lib/api'
// ─── Types ────────────────────────────────────────────────────────────────────
type MealPlan = string
interface TariffInclusion {
id: string
label: string
icon: React.ElementType
}
const ALL_INCLUSIONS: TariffInclusion[] = [
{ id: 'breakfast', label: 'Завтрак', icon: Coffee },
{ id: 'dinner', label: 'Ужин', icon: Utensils },
{ id: 'wifi', label: 'Wi-Fi', icon: Wifi },
{ id: 'parking', label: 'Парковка', icon: Car },
{ id: 'pool', label: 'Бассейн', icon: Waves },
{ id: 'spa', label: 'СПА-доступ', icon: Sparkles },
{ id: 'transfer', label: 'Трансфер', icon: Plane },
{ id: 'minibar', label: 'Мини-бар', icon: Package },
]
const MEAL_PLANS: { id: MealPlan; label: string; description: string; inclusions: string[] }[] = [
{ id: 'no_meals', label: 'Без питания', description: 'BB — Bed only', inclusions: [] },
{ id: 'breakfast', label: 'Завтрак', description: 'BB — Bed & Breakfast', inclusions: ['breakfast'] },
{ id: 'hb', label: 'Полупансион', description: 'HB — Half Board (завтрак + ужин)', inclusions: ['breakfast', 'dinner'] },
{ id: 'fb', label: 'Полный пансион', description: 'FB — Full Board (3 разовое питание)', inclusions: ['breakfast', 'dinner'] },
{ id: 'ai', label: 'Всё включено', description: 'AI — All Inclusive', inclusions: ['breakfast', 'dinner', 'pool', 'wifi', 'minibar'] },
]
interface Tariff {
id: string
name: string
code: string
mealPlan: MealPlan
inclusions: string[]
modifierType: 'percent' | 'fixed'
modifierValue: number // + means surcharge, - means discount
minNights: number
cancellationPolicy: 'flexible' | 'moderate' | 'strict' | 'nonrefundable'
description: string
isActive: boolean
}
// ─── Converter ────────────────────────────────────────────────────────────────
function fromApi(t: TariffApi): Tariff {
return {
id: t.id, name: t.name, code: t.code,
mealPlan: t.meal_plan, inclusions: t.inclusions,
modifierType: t.modifier_type, modifierValue: t.modifier_value,
minNights: t.min_nights, cancellationPolicy: t.cancellation_policy,
description: t.description, isActive: t.is_active,
}
}
// ─── Mock (fallback, will be replaced by API) ─────────────────────────────────
const MOCK_TARIFFS: Tariff[] = [
{
id: 't-1',
name: 'Стандарт без питания',
code: 'STD-RO',
mealPlan: 'no_meals',
inclusions: ['wifi'],
modifierType: 'percent',
modifierValue: 0,
minNights: 1,
cancellationPolicy: 'moderate',
description: 'Базовый тариф. Только проживание, Wi-Fi включён.',
isActive: true,
},
{
id: 't-2',
name: 'С завтраком',
code: 'STD-BB',
mealPlan: 'breakfast',
inclusions: ['wifi', 'breakfast'],
modifierType: 'fixed',
modifierValue: 800,
minNights: 1,
cancellationPolicy: 'flexible',
description: 'Проживание + шведский стол с 7:00 до 10:30. Отмена без штрафа за 24 ч.',
isActive: true,
},
{
id: 't-3',
name: 'Полупансион',
code: 'STD-HB',
mealPlan: 'hb',
inclusions: ['wifi', 'breakfast', 'dinner'],
modifierType: 'fixed',
modifierValue: 2200,
minNights: 2,
cancellationPolicy: 'moderate',
description: 'Завтрак + ужин по меню. Минимальный срок — 2 ночи.',
isActive: true,
},
{
id: 't-4',
name: 'Всё включено',
code: 'AI',
mealPlan: 'ai',
inclusions: ['wifi', 'breakfast', 'dinner', 'pool', 'minibar', 'spa'],
modifierType: 'percent',
modifierValue: 35,
minNights: 3,
cancellationPolicy: 'strict',
description: 'Питание, напитки, бассейн, СПА. Отмена — не позднее 5 дней до заезда.',
isActive: true,
},
{
id: 't-5',
name: 'Невозвратный',
code: 'NR',
mealPlan: 'breakfast',
inclusions: ['wifi', 'breakfast'],
modifierType: 'percent',
modifierValue: -15,
minNights: 1,
cancellationPolicy: 'nonrefundable',
description: 'Скидка 15% за невозвратное бронирование. Без изменений и отмены.',
isActive: false,
},
]
const CANCEL_POLICY: Record<Tariff['cancellationPolicy'], { label: string; cls: string }> = {
flexible: { label: 'Гибкая', cls: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' },
moderate: { label: 'Умеренная', cls: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' },
strict: { label: 'Строгая', cls: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' },
nonrefundable: { label: 'Невозвратный', cls: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' },
}
// ─── Tariff Modal ─────────────────────────────────────────────────────────────
const EMPTY_TARIFF: Omit<Tariff, 'id'> = {
name: '', code: '', mealPlan: 'no_meals', inclusions: ['wifi'],
modifierType: 'fixed', modifierValue: 0, minNights: 1,
cancellationPolicy: 'flexible', description: '', isActive: true,
}
function TariffModal({
tariff, onSave, onClose, mealPlans, allInclusions, saving, error,
}: {
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 }>
saving?: boolean
error?: string
}) {
const [form, setForm] = useState<Omit<Tariff, 'id'>>(
tariff ? { ...tariff } : { ...EMPTY_TARIFF }
)
const set = <K extends keyof typeof form>(k: K, v: typeof form[K]) =>
setForm(prev => ({ ...prev, [k]: v }))
// Авто-генерация кода из названия (если поле кода пустое)
const autoCode = (name: string) =>
name.trim().split(/\s+/).map(w => w[0]?.toUpperCase() ?? '').join('').slice(0, 6) || ''
const handleNameChange = (v: string) => {
setForm(prev => ({
...prev,
name: v,
code: prev.code || !tariff ? autoCode(v) : prev.code,
}))
}
const toggleInclusion = (id: string) =>
setForm(prev => ({
...prev,
inclusions: prev.inclusions.includes(id)
? prev.inclusions.filter(i => i !== id)
: [...prev.inclusions, id],
}))
const applyMealPlan = (plan: MealPlan) => {
const mp = mealPlans.find(m => m.id === plan)!
setForm(prev => ({
...prev,
mealPlan: plan,
inclusions: [
...new Set([
...prev.inclusions.filter(i => !['breakfast', 'dinner'].includes(i)),
...mp.inclusions,
]),
],
}))
}
const sampleBase = 5600
const modifier = form.modifierType === 'percent'
? Math.round(sampleBase * form.modifierValue / 100)
: form.modifierValue
const effectivePrice = sampleBase + modifier
return (
<Modal
open
onClose={onClose}
title={tariff ? 'Редактировать тариф' : 'Новый тариф'}
size="2xl"
footer={
<>
{error && <p className="text-sm text-red-600 dark:text-red-400 mr-auto">{error}</p>}
<button onClick={onClose} className="btn-secondary" disabled={saving}>Отмена</button>
<button
onClick={() => {
if (!form.name || saving) return
const code = form.code || autoCode(form.name) || `T${Date.now().toString(36).toUpperCase().slice(-4)}`
onSave({ ...form, code })
}}
className="btn-primary gap-1.5 disabled:opacity-50"
disabled={!form.name || saving}
>
{saving && <Loader2 size={14} className="animate-spin" />}
{tariff ? 'Сохранить' : 'Создать тариф'}
</button>
</>
}
>
<div className="space-y-5">
{/* Name + Code */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="sm:col-span-2">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Название тарифа *
</label>
<input type="text" className="input" placeholder="Стандарт с завтраком"
value={form.name} onChange={e => handleNameChange(e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Код <span className="text-slate-400 font-normal text-xs">(необязательно)</span>
</label>
<input type="text" className="input font-mono uppercase" placeholder="авто"
value={form.code}
onChange={e => set('code', e.target.value.toUpperCase())} />
</div>
</div>
{/* Meal Plan */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
Тип питания
</label>
<div className="grid grid-cols-3 sm:grid-cols-5 gap-2">
{mealPlans.map(mp => (
<button
key={mp.id}
type="button"
onClick={() => applyMealPlan(mp.id)}
className={cn(
'p-2.5 rounded-xl border text-center transition-colors',
form.mealPlan === mp.id
? 'bg-brand-600 border-brand-600 text-white'
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 hover:border-brand-400',
)}
>
<p className="text-xs font-semibold leading-tight">{mp.label}</p>
<p className={cn('text-[10px] mt-0.5 leading-tight',
form.mealPlan === mp.id ? 'text-white/70' : 'text-slate-400'
)}>{mp.description.split(' — ')[0]}</p>
</button>
))}
</div>
</div>
{/* Inclusions */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
Что включено
</label>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{allInclusions.map(inc => {
const active = form.inclusions.includes(inc.id)
return (
<button
key={inc.id}
type="button"
onClick={() => toggleInclusion(inc.id)}
className={cn(
'flex items-center gap-2 px-3 py-2.5 rounded-xl border text-sm font-medium transition-colors',
active
? 'bg-emerald-50 border-emerald-400 text-emerald-700 dark:bg-emerald-900/20 dark:border-emerald-600 dark:text-emerald-400'
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-500 hover:border-slate-300',
)}
>
{active
? <Check size={13} className="text-emerald-500 shrink-0" />
: <X size={13} className="text-slate-300 shrink-0" />
}
<inc.icon size={13} className="shrink-0" />
<span className="truncate text-xs">{inc.label}</span>
</button>
)
})}
</div>
</div>
{/* Price modifier + min nights + cancellation */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Надбавка / скидка к базовой цене
</label>
<div className="flex gap-1.5">
<button
type="button"
onClick={() => set('modifierType', 'fixed')}
className={cn('px-2.5 py-1.5 rounded-lg text-xs font-medium border transition-colors',
form.modifierType === 'fixed'
? 'bg-brand-600 text-white border-brand-600'
: 'bg-white dark:bg-slate-700 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-600'
)}
></button>
<button
type="button"
onClick={() => set('modifierType', 'percent')}
className={cn('px-2.5 py-1.5 rounded-lg text-xs font-medium border transition-colors',
form.modifierType === 'percent'
? 'bg-brand-600 text-white border-brand-600'
: 'bg-white dark:bg-slate-700 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-600'
)}
>%</button>
<input
type="number"
className="input flex-1 text-sm"
placeholder={form.modifierType === 'fixed' ? '+800' : '+15'}
value={form.modifierValue || ''}
onChange={e => set('modifierValue', parseInt(e.target.value) || 0)}
/>
</div>
{form.modifierValue !== 0 && (
<p className="text-xs mt-1 text-slate-500">
Пример: {sampleBase.toLocaleString('ru-RU')} + {modifier > 0 ? '+' : ''}{modifier.toLocaleString('ru-RU')} = <strong>{effectivePrice.toLocaleString('ru-RU')} /ночь</strong>
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Минимальное кол-во ночей
</label>
<input
type="number" min={1} max={30} className="input"
value={form.minNights}
onChange={e => set('minNights', Math.max(1, parseInt(e.target.value) || 1))}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Политика отмены
</label>
<select className="input" value={form.cancellationPolicy}
onChange={e => set('cancellationPolicy', e.target.value as Tariff['cancellationPolicy'])}>
{(Object.entries(CANCEL_POLICY) as [Tariff['cancellationPolicy'], { label: string }][]).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
</div>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Описание (для гостей и виджета)
</label>
<textarea
className="input resize-none" rows={2}
placeholder="Что входит в тариф, особые условия..."
value={form.description}
onChange={e => set('description', e.target.value)}
/>
</div>
{/* Active */}
<div className="flex items-center justify-between p-3 rounded-xl bg-slate-50 dark:bg-slate-700/40 border border-slate-200 dark:border-slate-600">
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">Тариф активен</p>
<p className="text-xs text-slate-500 dark:text-slate-400">Гости смогут выбрать этот тариф при бронировании</p>
</div>
<button
type="button"
onClick={() => set('isActive', !form.isActive)}
className={cn('transition-colors', form.isActive ? 'text-brand-600' : 'text-slate-400')}
>
{form.isActive ? <ToggleRight size={28} /> : <ToggleLeft size={28} />}
</button>
</div>
</div>
</Modal>
)
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export function TariffsPage() {
const { user } = useAuth()
const slug = user?.hotelSlug ?? ''
const [tariffs, setTariffs] = useState<Tariff[]>([])
const [loading, setLoading] = useState(true)
const [modal, setModal] = useState<'create' | Tariff | null>(null)
const [deleteTarget, setDeleteTarget] = useState<Tariff | null>(null)
const [saveError, setSaveError] = useState('')
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!slug) return
api.tariffs.list(slug)
.then(rows => setTariffs(rows.map(fromApi)))
.catch(console.error)
.finally(() => setLoading(false))
}, [slug])
// 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 = async (data: Omit<Tariff, 'id'>) => {
if (!slug) return
setSaving(true)
setSaveError('')
try {
const payload = {
name: data.name, code: data.code, meal_plan: data.mealPlan,
inclusions: data.inclusions, modifier_type: data.modifierType,
modifier_value: data.modifierValue, min_nights: data.minNights,
cancellation_policy: data.cancellationPolicy,
description: data.description, is_active: data.isActive,
}
const isEdit = typeof modal === 'object' && modal !== null
const saved = isEdit
? await api.tariffs.update(slug, (modal as Tariff).id, payload)
: await api.tariffs.create(slug, payload)
const converted = fromApi(saved)
setTariffs(prev => isEdit
? prev.map(t => t.id === (modal as Tariff).id ? converted : t)
: [...prev, converted],
)
setModal(null)
} catch (err) {
setSaveError(err instanceof Error ? err.message : 'Ошибка сохранения')
} finally {
setSaving(false)
}
}
const toggleActive = async (id: string) => {
if (!slug) return
const t = tariffs.find(x => x.id === id)
if (!t) return
const saved = await api.tariffs.update(slug, id, { is_active: !t.isActive })
setTariffs(prev => prev.map(x => x.id === id ? fromApi(saved) : x))
}
const activeTariffs = tariffs.filter(t => t.isActive)
const inactiveTariffs = tariffs.filter(t => !t.isActive)
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 size={24} className="animate-spin text-brand-600" />
</div>
)
}
return (
<div className="p-4 md:p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Тарифы и планы питания</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
Создайте тарифные планы для разных условий проживания
</p>
</div>
<button onClick={() => setModal('create')} className="btn-primary flex items-center gap-2">
<Plus size={16} />
Новый тариф
</button>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[
{ label: 'Всего тарифов', value: tariffs.length, color: 'text-slate-900 dark:text-slate-100' },
{ label: 'Активных', value: activeTariffs.length, color: 'text-emerald-600 dark:text-emerald-400' },
{ label: 'Неактивных', value: inactiveTariffs.length, color: 'text-slate-400' },
{ label: 'С питанием', value: tariffs.filter(t => t.mealPlan !== 'no_meals').length, color: 'text-amber-600 dark:text-amber-400' },
].map(s => (
<div key={s.label} className="card p-4 text-center">
<p className="text-xs text-slate-500 dark:text-slate-400">{s.label}</p>
<p className={cn('text-3xl font-bold mt-1', s.color)}>{s.value}</p>
</div>
))}
</div>
{/* Info banner */}
<div className="flex items-start gap-3 p-4 rounded-xl bg-brand-50 dark:bg-brand-900/10 border border-brand-200 dark:border-brand-800">
<Info size={16} className="text-brand-600 dark:text-brand-400 mt-0.5 shrink-0" />
<p className="text-sm text-brand-700 dark:text-brand-400">
Тарифы применяются при создании бронирования гость или менеджер выбирает подходящий план.
Надбавка или скидка рассчитывается относительно базовой цены номера автоматически.
</p>
</div>
{/* Tariff list */}
<div className="space-y-3">
{tariffs.map(tariff => {
const mp = mealPlans.find(m => m.id === tariff.mealPlan) ?? mealPlans[0]
const modLabel = tariff.modifierValue === 0
? 'Без надбавки'
: `${tariff.modifierValue > 0 ? '+' : ''}${tariff.modifierValue}${tariff.modifierType === 'percent' ? '%' : ' ₽'}`
return (
<div
key={tariff.id}
className={cn(
'card p-4 flex items-start gap-4 transition-opacity',
!tariff.isActive && 'opacity-60',
)}
>
{/* Meal plan badge */}
<div className="w-14 shrink-0 text-center">
<div className="text-xs font-bold text-brand-600 dark:text-brand-400 bg-brand-50 dark:bg-brand-900/20 rounded-lg px-2 py-1.5 font-mono">
{tariff.code}
</div>
</div>
{/* Main info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1">
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">{tariff.name}</span>
{!tariff.isActive && (
<span className="px-2 py-0.5 rounded-full text-xs bg-slate-100 text-slate-500 dark:bg-slate-700">Неактивен</span>
)}
<span className={cn('px-2 py-0.5 rounded-full text-xs font-medium', CANCEL_POLICY[tariff.cancellationPolicy].cls)}>
{CANCEL_POLICY[tariff.cancellationPolicy].label}
</span>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-2">{tariff.description || mp.description}</p>
{/* Inclusions */}
<div className="flex flex-wrap gap-1.5">
{tariff.inclusions.map(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">
<inc.icon size={10} />
{inc.label}
</span>
)
})}
{tariff.minNights > 1 && (
<span className="flex items-center gap-1 px-2 py-0.5 rounded-lg text-xs bg-amber-50 text-amber-700 dark:bg-amber-900/20 dark:text-amber-400 border border-amber-200 dark:border-amber-800">
Мин. {tariff.minNights} ночи
</span>
)}
</div>
</div>
{/* Price + Actions (right column) */}
<div className="shrink-0 flex flex-col items-end gap-2">
<div className="text-right">
<p className={cn(
'text-sm font-semibold',
tariff.modifierValue > 0 ? 'text-amber-600 dark:text-amber-400'
: tariff.modifierValue < 0 ? 'text-emerald-600 dark:text-emerald-400'
: 'text-slate-500',
)}>
{modLabel}
</p>
<p className="text-xs text-slate-400">к базе</p>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => toggleActive(tariff.id)}
className={cn('p-1.5 rounded-lg transition-colors', tariff.isActive ? 'text-brand-600 hover:bg-brand-50' : 'text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700')}
title={tariff.isActive ? 'Деактивировать' : 'Активировать'}
>
{tariff.isActive ? <ToggleRight size={20} /> : <ToggleLeft size={20} />}
</button>
<button
onClick={() => setModal(tariff)}
className="p-1.5 rounded-lg text-slate-400 hover:text-brand-600 hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors"
>
<Edit2 size={15} />
</button>
<button
onClick={() => setDeleteTarget(tariff)}
className="p-1.5 rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
<Trash2 size={15} />
</button>
</div>
</div>
</div>
)
})}
</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); setSaveError('') }}
mealPlans={mealPlans}
allInclusions={allInclusions}
saving={saving}
error={saveError}
/>
)}
{/* Delete confirmation */}
{deleteTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-xl p-6 w-full max-w-sm">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">Удалить тариф?</h3>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-4">
«{deleteTarget.name}» будет удалён. Это не затронет уже созданные бронирования.
</p>
<div className="flex justify-end gap-2">
<button onClick={() => setDeleteTarget(null)} className="btn-secondary">Отмена</button>
<button
onClick={async () => {
if (!slug) return
await api.tariffs.delete(slug, deleteTarget.id)
setTariffs(prev => prev.filter(t => t.id !== deleteTarget.id))
setDeleteTarget(null)
}}
className="px-4 py-2 rounded-xl bg-red-600 text-white text-sm font-medium hover:bg-red-700 transition-colors"
>
Удалить
</button>
</div>
</div>
</div>
)}
{/* Builder tip */}
<div className="flex items-start gap-3 p-4 rounded-xl bg-slate-50 dark:bg-slate-700/30 border border-dashed border-slate-300 dark:border-slate-600">
<Tag size={16} className="text-slate-400 mt-0.5 shrink-0" />
<div className="text-sm text-slate-500 dark:text-slate-400">
<strong className="text-slate-600 dark:text-slate-300">Подсказка:</strong> В будущих версиях тарифы будут отображаться
в виджете онлайн-бронирования на сайте и синхронизироваться с менеджером каналов для каждой OTA-площадки отдельно.
</div>
</div>
</div>
)
}