Add pricing, tariffs, loyalty, maintenance, discounts features

- TariffsPage (/tariffs): rate plan builder — meal plans (RO/BB/HB/FB/AI),
  inclusions picker, price modifier (% or ₽), min nights, cancellation policy
- DynamicPricingPage (/dynamic-pricing): rule engine for weekends, holidays,
  seasons, custom date ranges, weather; interactive price calendar preview
  with colour-coded effective rates per day
- LoyaltyPage (/loyalty): program settings (points/₽, point value, expiry),
  4-level structure (Bronze/Silver/Gold/Platinum) with editable perks,
  guest portal preview mockup, top-guests leaderboard
- MaintenancePage (/maintenance): scheduled room & service downtime records;
  status flow (scheduled → in_progress → done); time range support for services
- DiscountsPage (/discounts): discount catalogue — % or ₽, category (all /
  loyalty / corporate / promo), min nights, validity dates, active toggle
- BookingModal: discount now selected from dropdown of active discounts only
  (replaces free-form input); summary shows discount name + saved amount
- Sidebar: new "Цены и тарифы" section (Тарифы, Динамические цены, Скидки);
  Управление extended with Лояльность and Тех. перерывы

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 19:56:17 +03:00
parent acc17387a7
commit 78788f68b0
8 changed files with 2705 additions and 72 deletions

View File

@@ -27,6 +27,11 @@ import { RoomCategoriesPage } from './pages/RoomCategoriesPage'
import { DocumentsPage } from './pages/DocumentsPage' import { DocumentsPage } from './pages/DocumentsPage'
import { UsersPage } from './pages/UsersPage' import { UsersPage } from './pages/UsersPage'
import { GuestsPage } from './pages/GuestsPage' import { GuestsPage } from './pages/GuestsPage'
import { TariffsPage } from './pages/TariffsPage'
import { DynamicPricingPage } from './pages/DynamicPricingPage'
import { LoyaltyPage } from './pages/LoyaltyPage'
import { MaintenancePage } from './pages/MaintenancePage'
import { DiscountsPage } from './pages/DiscountsPage'
export default function App() { export default function App() {
return ( return (
@@ -63,6 +68,11 @@ export default function App() {
<Route path="/documents" element={<DocumentsPage />} /> <Route path="/documents" element={<DocumentsPage />} />
<Route path="/users" element={<UsersPage />} /> <Route path="/users" element={<UsersPage />} />
<Route path="/guests" element={<GuestsPage />} /> <Route path="/guests" element={<GuestsPage />} />
<Route path="/tariffs" element={<TariffsPage />} />
<Route path="/dynamic-pricing" element={<DynamicPricingPage />} />
<Route path="/loyalty" element={<LoyaltyPage />} />
<Route path="/maintenance" element={<MaintenancePage />} />
<Route path="/discounts" element={<DiscountsPage />} />
</Route> </Route>
{/* Admin routes */} {/* Admin routes */}

View File

@@ -1,6 +1,8 @@
import { useState } from 'react' import { useState } from 'react'
import { format } from 'date-fns' import { format } from 'date-fns'
import { Star, Banknote, CreditCard, AlertCircle, Map, Plus, Trash2, IdCard, CalendarDays, Percent, Tag } from 'lucide-react' import { Star, Banknote, CreditCard, AlertCircle, Map, Plus, Trash2, IdCard, CalendarDays, Tag } from 'lucide-react'
import { MOCK_DISCOUNTS } from '../../pages/DiscountsPage'
import type { Discount } from '../../pages/DiscountsPage'
import { Modal } from '../ui/Modal' import { Modal } from '../ui/Modal'
import { cn, BOOKING_STATUS_LABELS } from '../../lib/utils' import { cn, BOOKING_STATUS_LABELS } from '../../lib/utils'
import type { Booking, DraftBooking, Room, BookingStatus } from '../../types' import type { Booking, DraftBooking, Room, BookingStatus } from '../../types'
@@ -82,9 +84,9 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
const [selectedServiceId, setSelectedServiceId] = useState(ADDITIONAL_SERVICES[0].id) const [selectedServiceId, setSelectedServiceId] = useState(ADDITIONAL_SERVICES[0].id)
const [activeTab, setActiveTab] = useState<'booking' | 'passport'>('booking') const [activeTab, setActiveTab] = useState<'booking' | 'passport'>('booking')
const [passport, setPassport] = useState<PassportData>(EMPTY_PASSPORT) const [passport, setPassport] = useState<PassportData>(EMPTY_PASSPORT)
const [discountType, setDiscountType] = useState<'percent' | 'fixed'>('percent') const [selectedDiscountId, setSelectedDiscountId] = useState<string>('')
const [discountValue, setDiscountValue] = useState(0) const activeDiscounts = MOCK_DISCOUNTS.filter(d => d.isActive)
const [isFreeStay, setIsFreeStay] = useState(false) const selectedDiscount: Discount | undefined = activeDiscounts.find(d => d.id === selectedDiscountId)
const setP = <K extends keyof PassportData>(k: K, v: PassportData[K]) => const setP = <K extends keyof PassportData>(k: K, v: PassportData[K]) =>
setPassport(prev => ({ ...prev, [k]: v })) setPassport(prev => ({ ...prev, [k]: v }))
@@ -106,10 +108,11 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
const roomBaseTotal = isHourly && room?.allowHourly const roomBaseTotal = isHourly && room?.allowHourly
? (room.hourlyRate ?? 0) * hourlyHours ? (room.hourlyRate ?? 0) * hourlyHours
: (room?.baseRate ?? 0) * nights : (room?.baseRate ?? 0) * nights
const discountAmount = isFreeStay ? roomBaseTotal const discountAmount = selectedDiscount
: discountType === 'percent' ? selectedDiscount.valueType === 'percent'
? Math.round(roomBaseTotal * Math.min(100, Math.max(0, discountValue)) / 100) ? Math.round(roomBaseTotal * Math.min(100, selectedDiscount.value) / 100)
: Math.min(roomBaseTotal, Math.max(0, discountValue)) : Math.min(roomBaseTotal, selectedDiscount.value)
: 0
const roomTotal = Math.max(0, roomBaseTotal - discountAmount) const roomTotal = Math.max(0, roomBaseTotal - discountAmount)
const servicesTotal = addedServices.reduce((s, sv) => s + sv.price * sv.qty, 0) const servicesTotal = addedServices.reduce((s, sv) => s + sv.price * sv.qty, 0)
const total = roomTotal + servicesTotal const total = roomTotal + servicesTotal
@@ -489,61 +492,25 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
</div> </div>
{/* Discount */} {/* Discount */}
<div> <div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-1.5">Скидка</label> <label className="block text-xs text-slate-500 dark:text-slate-400 mb-1.5 flex items-center gap-1">
<div className="flex items-center gap-2"> <Tag size={11} /> Скидка
<label className="flex items-center gap-1.5 cursor-pointer select-none">
<input
type="checkbox"
className="w-3.5 h-3.5 rounded accent-brand-600"
checked={isFreeStay}
onChange={e => { setIsFreeStay(e.target.checked); if (e.target.checked) setDiscountValue(0) }}
/>
<span className="text-xs text-slate-600 dark:text-slate-300 flex items-center gap-1">
<Tag size={11} /> Бесплатное проживание
</span>
</label> </label>
</div> <select
{!isFreeStay && ( className="input text-sm"
<div className="flex gap-1.5 mt-2"> value={selectedDiscountId}
<button onChange={e => setSelectedDiscountId(e.target.value)}
type="button"
onClick={() => setDiscountType('percent')}
className={cn(
'px-2.5 py-1.5 rounded-lg text-xs font-medium border transition-colors flex items-center gap-1',
discountType === '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',
)}
> >
<Percent size={11} /> % <option value=""> Без скидки </option>
</button> {activeDiscounts.map(d => (
<button <option key={d.id} value={d.id}>
type="button" {d.name} ({d.value === 100 ? 'Бесплатно' : `${d.value}${d.valueType === 'percent' ? '%' : ' ₽'}`})
onClick={() => setDiscountType('fixed')} </option>
className={cn( ))}
'px-2.5 py-1.5 rounded-lg text-xs font-medium border transition-colors', </select>
discountType === 'fixed' {selectedDiscount && (
? '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"
min={0}
max={discountType === 'percent' ? 100 : roomBaseTotal}
className="input flex-1 text-sm"
placeholder={discountType === 'percent' ? '0100 %' : '0 ₽'}
value={discountValue || ''}
onChange={e => setDiscountValue(Math.max(0, parseInt(e.target.value) || 0))}
/>
</div>
)}
{(isFreeStay || discountAmount > 0) && (
<p className="text-xs text-emerald-600 dark:text-emerald-400 mt-1"> <p className="text-xs text-emerald-600 dark:text-emerald-400 mt-1">
Скидка: {discountAmount.toLocaleString('ru-RU')} {discountAmount.toLocaleString('ru-RU')}
{isFreeStay ? ' (бесплатное проживание)' : discountType === 'percent' ? ` (${discountValue}%)` : ''} {selectedDiscount.note ? ` · ${selectedDiscount.note}` : ''}
</p> </p>
)} )}
</div> </div>
@@ -616,7 +583,7 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
onChange={e => set('notes', e.target.value)} onChange={e => set('notes', e.target.value)}
/> />
</div> </div>
{(total > 0 || isFreeStay || discountAmount > 0) && room && ( {(total > 0 || discountAmount > 0) && room && (
<div className="flex-1 rounded-xl bg-slate-50 dark:bg-slate-700/50 px-4 py-3 space-y-1.5 self-end"> <div className="flex-1 rounded-xl bg-slate-50 dark:bg-slate-700/50 px-4 py-3 space-y-1.5 self-end">
{isHourly && room.allowHourly ? ( {isHourly && room.allowHourly ? (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -637,10 +604,10 @@ export function BookingModal({ open, draft, rooms, onClose, onSave, existing }:
</span> </span>
</div> </div>
)} )}
{discountAmount > 0 && ( {discountAmount > 0 && selectedDiscount && (
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-emerald-600 dark:text-emerald-400"> <span className="text-emerald-600 dark:text-emerald-400">
{isFreeStay ? 'Бесплатное проживание' : `Скидка${discountType === 'percent' ? ` ${discountValue}%` : ''}`} {selectedDiscount.value === 100 ? 'Бесплатное проживание' : selectedDiscount.name}
</span> </span>
<span className="font-semibold text-emerald-600 dark:text-emerald-400"> <span className="font-semibold text-emerald-600 dark:text-emerald-400">
{discountAmount.toLocaleString('ru-RU')} {discountAmount.toLocaleString('ru-RU')}

View File

@@ -2,6 +2,7 @@ import { NavLink, useNavigate } from 'react-router-dom'
import { import {
CalendarDays, BookOpen, BedDouble, Globe, Settings, CalendarDays, BookOpen, BedDouble, Globe, Settings,
FileText, LayoutDashboard, Building2, Users, X, Hotel, Puzzle, CalendarRange, Map, LayoutGrid, UserCog, UsersRound, FileText, LayoutDashboard, Building2, Users, X, Hotel, Puzzle, CalendarRange, Map, LayoutGrid, UserCog, UsersRound,
TrendingUp, Tag, Award, Wrench, Utensils,
} from 'lucide-react' } from 'lucide-react'
import { useAuth } from '../../contexts/AuthContext' import { useAuth } from '../../contexts/AuthContext'
import { useModules } from '../../contexts/ModulesContext' import { useModules } from '../../contexts/ModulesContext'
@@ -124,6 +125,14 @@ export function Sidebar({ open, onClose }: SidebarProps) {
<NavItem to="/rooms" icon={BedDouble} label="Номера" onClick={onClose} /> <NavItem to="/rooms" icon={BedDouble} label="Номера" onClick={onClose} />
<NavItem to="/room-categories" icon={LayoutGrid} label="Категории номеров" onClick={onClose} /> <NavItem to="/room-categories" icon={LayoutGrid} label="Категории номеров" onClick={onClose} />
<NavItem to="/availability" icon={CalendarRange} label="Доступность" onClick={onClose} /> <NavItem to="/availability" icon={CalendarRange} label="Доступность" onClick={onClose} />
{/* ── Цены ── */}
<p className="px-3 pt-3 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
Цены и тарифы
</p>
<NavItem to="/tariffs" icon={Utensils} label="Тарифы" onClick={onClose} />
<NavItem to="/dynamic-pricing" icon={TrendingUp} label="Динамические цены" onClick={onClose} />
<NavItem to="/discounts" icon={Tag} label="Скидки" onClick={onClose} />
</> </>
)} )}
@@ -158,6 +167,8 @@ export function Sidebar({ open, onClose }: SidebarProps) {
Управление Управление
</p> </p>
<NavItem to="/users" icon={UserCog} label="Сотрудники" onClick={onClose} /> <NavItem to="/users" icon={UserCog} label="Сотрудники" onClick={onClose} />
<NavItem to="/loyalty" icon={Award} label="Лояльность" onClick={onClose} />
<NavItem to="/maintenance" icon={Wrench} label="Тех. перерывы" onClick={onClose} />
<NavItem to="/floor-map" icon={Map} label="План этажей" onClick={onClose} /> <NavItem to="/floor-map" icon={Map} label="План этажей" onClick={onClose} />
{isModuleActive('channel-manager') && ( {isModuleActive('channel-manager') && (
<NavItem <NavItem

435
src/pages/DiscountsPage.tsx Normal file
View File

@@ -0,0 +1,435 @@
import { useState } from 'react'
import {
Plus, Edit2, Trash2, ToggleLeft, ToggleRight, Tag, Percent,
Calendar, Users, Info, Check,
} from 'lucide-react'
import { Modal } from '../components/ui/Modal'
import { cn } from '../lib/utils'
// ─── Types ────────────────────────────────────────────────────────────────────
type DiscountTarget = 'all' | 'loyalty' | 'corporate' | 'promo'
type DiscountValueType = 'percent' | 'fixed'
export interface Discount {
id: string
name: string
code: string
valueType: DiscountValueType
value: number // % or ₽ off the room price
target: DiscountTarget
minNights?: number
validFrom?: string
validTo?: string
isActive: boolean
note: string
}
// ─── Mock ─────────────────────────────────────────────────────────────────────
export const MOCK_DISCOUNTS: Discount[] = [
{
id: 'd-1',
name: 'Скидка постоянному гостю',
code: 'LOYAL',
valueType: 'percent',
value: 10,
target: 'loyalty',
isActive: true,
note: 'Применяется для гостей уровня Серебро и выше',
},
{
id: 'd-2',
name: 'Корпоративная скидка',
code: 'CORP15',
valueType: 'percent',
value: 15,
target: 'corporate',
isActive: true,
note: 'Для корпоративных договоров. Требует подтверждения менеджера.',
},
{
id: 'd-3',
name: 'Бесплатное проживание',
code: 'FREE',
valueType: 'percent',
value: 100,
target: 'all',
isActive: true,
note: 'Полная отмена стоимости проживания. Только с одобрения директора.',
},
{
id: 'd-4',
name: 'Длительное проживание (7+ ночей)',
code: 'LONG7',
valueType: 'percent',
value: 20,
target: 'all',
minNights: 7,
isActive: true,
note: 'Автоматически предлагается при бронировании от 7 ночей',
},
{
id: 'd-5',
name: 'Весенняя акция',
code: 'SPRING26',
valueType: 'percent',
value: 25,
target: 'promo',
validFrom: '2026-03-01',
validTo: '2026-04-30',
isActive: false,
note: 'Сезонная акция. Не суммируется с другими скидками.',
},
{
id: 'd-6',
name: 'Фиксированная скидка 1000 ₽',
code: 'FLAT1000',
valueType: 'fixed',
value: 1000,
target: 'all',
isActive: true,
note: 'Разовая скидка для новых гостей',
},
]
// ─── Helpers ──────────────────────────────────────────────────────────────────
const TARGET_META: Record<DiscountTarget, { label: string; cls: string }> = {
all: { label: 'Все гости', cls: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300' },
loyalty: { label: 'Программа лояльности', cls: 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400' },
corporate: { label: 'Корпоративный', cls: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' },
promo: { label: 'Промо-акция', cls: 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-400' },
}
// ─── Discount Modal ───────────────────────────────────────────────────────────
const EMPTY: Omit<Discount, 'id'> = {
name: '', code: '', valueType: 'percent', value: 10,
target: 'all', isActive: true, note: '',
}
function DiscountModal({
discount, onSave, onClose,
}: {
discount?: Discount
onSave: (d: Omit<Discount, 'id'>) => void
onClose: () => void
}) {
const [form, setForm] = useState<Omit<Discount, 'id'>>(discount ? { ...discount } : { ...EMPTY })
const set = <K extends keyof typeof form>(k: K, v: typeof form[K]) =>
setForm(prev => ({ ...prev, [k]: v }))
return (
<Modal
open
onClose={onClose}
title={discount ? 'Редактировать скидку' : 'Новая скидка'}
size="lg"
footer={
<>
<button onClick={onClose} className="btn-secondary">Отмена</button>
<button
onClick={() => { if (form.name && form.code) onSave(form) }}
className="btn-primary"
disabled={!form.name || !form.code}
>
{discount ? 'Сохранить' : 'Создать скидку'}
</button>
</>
}
>
<div className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<div className="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 => set('name', e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Код *</label>
<input type="text" className="input font-mono uppercase" placeholder="CORP15"
value={form.code}
onChange={e => set('code', e.target.value.toUpperCase())} />
</div>
</div>
{/* Value type + value */}
<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('valueType', 'percent')}
className={cn('px-3 py-2 rounded-lg text-sm font-medium border transition-colors',
form.valueType === '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>
<button
type="button"
onClick={() => set('valueType', 'fixed')}
className={cn('px-3 py-2 rounded-lg text-sm font-medium border transition-colors',
form.valueType === '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>
<input
type="number" min={0}
max={form.valueType === 'percent' ? 100 : undefined}
className="input w-28 text-sm"
value={form.value || ''}
onChange={e => set('value', Math.max(0, parseInt(e.target.value) || 0))}
/>
<span className="flex items-center text-sm text-slate-500 px-1">
{form.valueType === 'percent' ? '%' : '₽'}
</span>
</div>
{form.valueType === 'percent' && form.value === 100 && (
<p className="text-xs text-amber-600 dark:text-amber-400 mt-1 flex items-center gap-1">
<Info size={11} /> Скидка 100% означает бесплатное проживание
</p>
)}
</div>
{/* Target */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Категория гостей</label>
<div className="grid grid-cols-2 gap-2">
{(Object.entries(TARGET_META) as [DiscountTarget, typeof TARGET_META[DiscountTarget]][]).map(([k, v]) => (
<button
key={k}
type="button"
onClick={() => set('target', k)}
className={cn(
'flex items-center gap-2 px-3 py-2.5 rounded-xl border text-sm font-medium transition-colors',
form.target === k
? 'bg-brand-600 border-brand-600 text-white'
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-brand-400',
)}
>
{form.target === k && <Check size={13} />}
{v.label}
</button>
))}
</div>
</div>
{/* Optional: minNights + validity */}
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Мин. ночей
</label>
<input
type="number" min={1} className="input"
placeholder="1"
value={form.minNights ?? ''}
onChange={e => set('minNights', parseInt(e.target.value) || undefined)}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Действует с</label>
<input type="date" className="input"
value={form.validFrom ?? ''}
onChange={e => set('validFrom', e.target.value || undefined)} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Действует до</label>
<input type="date" className="input"
value={form.validTo ?? ''}
onChange={e => set('validTo', e.target.value || undefined)} />
</div>
</div>
{/* Note */}
<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.note} onChange={e => set('note', e.target.value)} />
</div>
{/* Active toggle */}
<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">
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">Скидка активна</p>
<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 DiscountsPage() {
const [discounts, setDiscounts] = useState<Discount[]>(MOCK_DISCOUNTS)
const [modal, setModal] = useState<'create' | Discount | null>(null)
const [delTarget, setDel] = useState<Discount | null>(null)
const save = (data: Omit<Discount, 'id'>) => {
if (typeof modal === 'object' && modal !== null) {
setDiscounts(prev => prev.map(d => d.id === modal.id ? { ...data, id: modal.id } : d))
} else {
setDiscounts(prev => [...prev, { ...data, id: `d-${Date.now()}` }])
}
setModal(null)
}
const toggleActive = (id: string) =>
setDiscounts(prev => prev.map(d => d.id === id ? { ...d, isActive: !d.isActive } : d))
return (
<div className="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-4 gap-4">
{[
{ label: 'Всего скидок', value: discounts.length },
{ label: 'Активных', value: discounts.filter(d => d.isActive).length },
{ label: 'По % от суммы', value: discounts.filter(d => d.valueType === 'percent').length },
{ label: 'Фиксированных', value: discounts.filter(d => d.valueType === 'fixed').length },
].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="text-3xl font-bold mt-1 text-slate-900 dark:text-slate-100">{s.value}</p>
</div>
))}
</div>
<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>
{/* Discounts list */}
<div className="space-y-2">
{discounts.map(discount => (
<div
key={discount.id}
className={cn(
'card p-4 flex items-center gap-4 transition-opacity',
!discount.isActive && 'opacity-55',
)}
>
{/* Icon */}
<div className={cn(
'w-10 h-10 rounded-xl flex items-center justify-center shrink-0',
discount.valueType === 'percent' ? 'bg-brand-50 dark:bg-brand-900/20' : 'bg-emerald-50 dark:bg-emerald-900/20',
)}>
{discount.valueType === 'percent'
? <Percent size={18} className="text-brand-600 dark:text-brand-400" />
: <Tag size={18} className="text-emerald-600 dark:text-emerald-400" />
}
</div>
{/* Main info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-0.5">
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">{discount.name}</span>
<code className="px-1.5 py-0.5 rounded bg-slate-100 dark:bg-slate-700 text-xs font-mono text-slate-600 dark:text-slate-300">
{discount.code}
</code>
<span className={cn('px-2 py-0.5 rounded-full text-xs font-medium', TARGET_META[discount.target].cls)}>
{TARGET_META[discount.target].label}
</span>
{!discount.isActive && (
<span className="px-2 py-0.5 rounded-full text-xs bg-slate-100 text-slate-400 dark:bg-slate-700">Неактивна</span>
)}
</div>
{discount.note && (
<p className="text-xs text-slate-500 dark:text-slate-400">{discount.note}</p>
)}
<div className="flex items-center gap-3 mt-1 flex-wrap">
{discount.minNights && (
<span className="flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400">
<Calendar size={11} /> Мин. {discount.minNights} ночей
</span>
)}
{(discount.validFrom || discount.validTo) && (
<span className="flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400">
<Calendar size={11} />
{discount.validFrom ?? '...'} {discount.validTo ?? '...'}
</span>
)}
</div>
</div>
{/* Value badge */}
<div className="text-right shrink-0">
<p className={cn(
'text-xl font-bold',
discount.value === 100 ? 'text-emerald-600 dark:text-emerald-400' : 'text-brand-600 dark:text-brand-400',
)}>
{discount.value === 100 ? 'Бесплатно' : `${discount.value}${discount.valueType === 'percent' ? '%' : ' ₽'}`}
</p>
</div>
{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => toggleActive(discount.id)}
className={cn('p-1.5 rounded-lg transition-colors', discount.isActive ? 'text-brand-600' : 'text-slate-400')}
>
{discount.isActive ? <ToggleRight size={20} /> : <ToggleLeft size={20} />}
</button>
<button onClick={() => setModal(discount)}
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={() => setDel(discount)}
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>
{modal && (
<DiscountModal
discount={typeof modal === 'object' ? modal : undefined}
onSave={save}
onClose={() => setModal(null)}
/>
)}
{delTarget && (
<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 mb-4">«{delTarget.name}» будет удалена из справочника.</p>
<div className="flex justify-end gap-2">
<button onClick={() => setDel(null)} className="btn-secondary">Отмена</button>
<button
onClick={() => { setDiscounts(prev => prev.filter(d => d.id !== delTarget.id)); setDel(null) }}
className="px-4 py-2 rounded-xl bg-red-600 text-white text-sm font-medium hover:bg-red-700"
>Удалить</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,637 @@
import { useState } from 'react'
import {
Plus, Edit2, Trash2, ToggleLeft, ToggleRight, Sun, Cloud, CloudRain,
Snowflake, CalendarDays, Calendar, TrendingUp, TrendingDown, Info,
ChevronLeft, ChevronRight, Flame,
} from 'lucide-react'
import { Modal } from '../components/ui/Modal'
import { cn } from '../lib/utils'
import { format, addDays, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, getDay, isWeekend } from 'date-fns'
import { ru } from 'date-fns/locale'
// ─── Types ────────────────────────────────────────────────────────────────────
type RuleType = 'weekend' | 'holiday' | 'season' | 'custom' | 'weather'
type WeatherType = 'sunny' | 'cloudy' | 'rain' | 'snow'
interface PricingRule {
id: string
name: string
type: RuleType
isActive: boolean
modifierType: 'percent' | 'fixed'
modifierValue: number // positive = markup, negative = discount
priority: number // higher = applied last (wins)
// type-specific
weekdays?: number[] // 0=Sun, 1=Mon ... 6=Sat
dateFrom?: string
dateTo?: string
weatherTypes?: WeatherType[]
}
// ─── Mock Rules ───────────────────────────────────────────────────────────────
const MOCK_RULES: PricingRule[] = [
{
id: 'r-1',
name: 'Надбавка за выходные',
type: 'weekend',
isActive: true,
modifierType: 'percent',
modifierValue: 20,
priority: 1,
weekdays: [5, 6], // Fri, Sat
},
{
id: 'r-2',
name: 'Новогодние праздники',
type: 'holiday',
isActive: true,
modifierType: 'percent',
modifierValue: 60,
priority: 3,
dateFrom: '2026-12-30',
dateTo: '2027-01-08',
},
{
id: 'r-3',
name: '8 Марта',
type: 'holiday',
isActive: true,
modifierType: 'percent',
modifierValue: 30,
priority: 3,
dateFrom: '2026-03-06',
dateTo: '2026-03-09',
},
{
id: 'r-4',
name: 'Майские праздники',
type: 'holiday',
isActive: true,
modifierType: 'percent',
modifierValue: 40,
priority: 3,
dateFrom: '2026-04-30',
dateTo: '2026-05-10',
},
{
id: 'r-5',
name: 'Низкий сезон (январь–февраль)',
type: 'season',
isActive: true,
modifierType: 'percent',
modifierValue: -20,
priority: 0,
dateFrom: '2026-01-09',
dateTo: '2026-02-28',
},
{
id: 'r-6',
name: 'Высокий сезон (июнь–август)',
type: 'season',
isActive: true,
modifierType: 'percent',
modifierValue: 35,
priority: 0,
dateFrom: '2026-06-01',
dateTo: '2026-08-31',
},
{
id: 'r-7',
name: 'Солнечная погода +10%',
type: 'weather',
isActive: false,
modifierType: 'percent',
modifierValue: 10,
priority: 1,
weatherTypes: ['sunny'],
},
{
id: 'r-8',
name: 'Дождь — скидка 10%',
type: 'weather',
isActive: true,
modifierType: 'percent',
modifierValue: -10,
priority: 1,
weatherTypes: ['rain', 'snow'],
},
]
// ─── Helpers ──────────────────────────────────────────────────────────────────
const RULE_TYPE_META: Record<RuleType, { label: string; color: string; bg: string }> = {
weekend: { label: 'Выходные', color: 'text-blue-700 dark:text-blue-400', bg: 'bg-blue-50 dark:bg-blue-900/20' },
holiday: { label: 'Праздник', color: 'text-amber-700 dark:text-amber-400', bg: 'bg-amber-50 dark:bg-amber-900/20' },
season: { label: 'Сезон', color: 'text-green-700 dark:text-green-400', bg: 'bg-green-50 dark:bg-green-900/20' },
custom: { label: 'Свой период', color: 'text-violet-700 dark:text-violet-400', bg: 'bg-violet-50 dark:bg-violet-900/20' },
weather: { label: 'Погода', color: 'text-cyan-700 dark:text-cyan-400', bg: 'bg-cyan-50 dark:bg-cyan-900/20' },
}
const WEATHER_META: Record<WeatherType, { icon: React.ElementType; label: string }> = {
sunny: { icon: Sun, label: 'Ясно' },
cloudy: { icon: Cloud, label: 'Облачно' },
rain: { icon: CloudRain, label: 'Дождь' },
snow: { icon: Snowflake, label: 'Снег' },
}
const WEEKDAY_LABELS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб']
// Compute effective multiplier for a date from active rules
function getEffectiveModifier(date: Date, rules: PricingRule[]): number {
const dateStr = format(date, 'yyyy-MM-dd')
const dayOfWeek = getDay(date) // 0=Sun
const weekend = dayOfWeek === 0 || dayOfWeek === 6
const activeRules = rules
.filter(r => r.isActive)
.sort((a, b) => a.priority - b.priority)
let totalMod = 0
for (const rule of activeRules) {
let applies = false
if (rule.type === 'weekend') {
applies = (rule.weekdays ?? [5, 6]).some(d => {
// map our "Fri=5, Sat=6" to getDay (Fri=5, Sat=6)
return d === dayOfWeek
}) || weekend && !rule.weekdays
}
if (rule.type === 'holiday' || rule.type === 'season' || rule.type === 'custom') {
if (rule.dateFrom && rule.dateTo) {
applies = dateStr >= rule.dateFrom && dateStr <= rule.dateTo
}
}
// weather rules don't apply to static calendar preview
if (applies) {
totalMod += rule.modifierType === 'percent' ? rule.modifierValue : 0
}
}
return totalMod
}
// ─── Rule Modal ───────────────────────────────────────────────────────────────
const EMPTY_RULE: Omit<PricingRule, 'id'> = {
name: '', type: 'weekend', isActive: true,
modifierType: 'percent', modifierValue: 20, priority: 1,
weekdays: [5, 6],
}
function RuleModal({
rule, onSave, onClose,
}: {
rule?: PricingRule
onSave: (r: Omit<PricingRule, 'id'>) => void
onClose: () => void
}) {
const [form, setForm] = useState<Omit<PricingRule, 'id'>>(rule ? { ...rule } : { ...EMPTY_RULE })
const set = <K extends keyof typeof form>(k: K, v: typeof form[K]) =>
setForm(prev => ({ ...prev, [k]: v }))
const toggleWeekday = (d: number) =>
setForm(prev => ({
...prev,
weekdays: prev.weekdays?.includes(d)
? prev.weekdays.filter(x => x !== d)
: [...(prev.weekdays ?? []), d],
}))
const toggleWeatherType = (w: WeatherType) =>
setForm(prev => ({
...prev,
weatherTypes: prev.weatherTypes?.includes(w)
? prev.weatherTypes.filter(x => x !== w)
: [...(prev.weatherTypes ?? []), w],
}))
return (
<Modal
open
onClose={onClose}
title={rule ? 'Редактировать правило' : 'Новое правило цены'}
size="lg"
footer={
<>
<button onClick={onClose} className="btn-secondary">Отмена</button>
<button
onClick={() => { if (form.name) onSave(form) }}
className="btn-primary"
disabled={!form.name}
>
{rule ? 'Сохранить' : 'Добавить правило'}
</button>
</>
}
>
<div className="space-y-4">
{/* Name */}
<div>
<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 => set('name', e.target.value)} />
</div>
{/* Type */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Тип правила</label>
<div className="grid grid-cols-5 gap-2">
{(Object.entries(RULE_TYPE_META) as [RuleType, typeof RULE_TYPE_META[RuleType]][]).map(([k, v]) => (
<button
key={k}
type="button"
onClick={() => set('type', k)}
className={cn(
'py-2 rounded-xl border text-xs font-medium transition-colors',
form.type === k
? 'bg-brand-600 border-brand-600 text-white'
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-brand-400',
)}
>
{v.label}
</button>
))}
</div>
</div>
{/* Type-specific params */}
{form.type === 'weekend' && (
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Дни недели</label>
<div className="flex gap-2">
{WEEKDAY_LABELS.map((label, idx) => (
<button
key={idx}
type="button"
onClick={() => toggleWeekday(idx)}
className={cn(
'w-10 h-10 rounded-xl text-sm font-medium border transition-colors',
form.weekdays?.includes(idx)
? 'bg-brand-600 border-brand-600 text-white'
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300',
)}
>
{label}
</button>
))}
</div>
</div>
)}
{['holiday', 'season', 'custom'].includes(form.type) && (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Дата начала</label>
<input type="date" className="input"
value={form.dateFrom ?? ''} onChange={e => set('dateFrom', e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Дата конца</label>
<input type="date" className="input"
value={form.dateTo ?? ''} onChange={e => set('dateTo', e.target.value)} />
</div>
</div>
)}
{form.type === 'weather' && (
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Условие погоды</label>
<div className="flex gap-2">
{(Object.entries(WEATHER_META) as [WeatherType, typeof WEATHER_META[WeatherType]][]).map(([k, v]) => (
<button
key={k}
type="button"
onClick={() => toggleWeatherType(k)}
className={cn(
'flex-1 flex flex-col items-center gap-1 py-2.5 rounded-xl border text-xs font-medium transition-colors',
form.weatherTypes?.includes(k)
? 'bg-brand-600 border-brand-600 text-white'
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300',
)}
>
<v.icon size={18} />
{v.label}
</button>
))}
</div>
<p className="text-xs text-slate-400 mt-2">
Погодные правила применяются автоматически при подключении к API прогноза погоды (OpenWeather)
</p>
</div>
)}
{/* Modifier */}
<div className="grid grid-cols-2 gap-3">
<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', '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>
<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>
<input
type="number"
className="input flex-1 text-sm"
placeholder="+20 или -10"
value={form.modifierValue || ''}
onChange={e => set('modifierValue', parseInt(e.target.value) || 0)}
/>
</div>
<p className="text-xs text-slate-400 mt-1">
Пример: база 5 000 +{form.modifierValue}%
= {Math.round(5000 * (1 + form.modifierValue / 100)).toLocaleString('ru-RU')}
</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={0} max={10} className="input"
value={form.priority}
onChange={e => set('priority', Math.max(0, parseInt(e.target.value) || 0))}
/>
<p className="text-xs text-slate-400 mt-1">При конфликте выше приоритет побеждает</p>
</div>
</div>
</div>
</Modal>
)
}
// ─── Price Calendar ───────────────────────────────────────────────────────────
function PriceCalendar({ rules, baseRate }: { rules: PricingRule[]; baseRate: number }) {
const [month, setMonth] = useState(new Date(2026, 2)) // March 2026
const start = startOfMonth(month)
const end = endOfMonth(month)
const days = eachDayOfInterval({ start, end })
const offset = (getDay(start) + 6) % 7 // Mon=0
function modColor(mod: number) {
if (mod > 40) return 'bg-red-200 dark:bg-red-900/50 text-red-900 dark:text-red-200'
if (mod > 15) return 'bg-orange-100 dark:bg-orange-900/40 text-orange-900 dark:text-orange-200'
if (mod > 0) return 'bg-amber-50 dark:bg-amber-900/20 text-amber-900 dark:text-amber-200'
if (mod < -10) return 'bg-blue-100 dark:bg-blue-900/40 text-blue-900 dark:text-blue-200'
if (mod < 0) return 'bg-sky-50 dark:bg-sky-900/20 text-sky-900 dark:text-sky-200'
return 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300'
}
return (
<div className="card p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300">
Предпросмотр цен база {baseRate.toLocaleString('ru-RU')} /ночь
</h3>
<div className="flex items-center gap-2">
<button onClick={() => setMonth(m => new Date(m.getFullYear(), m.getMonth() - 1))} className="btn-ghost p-1">
<ChevronLeft size={16} />
</button>
<span className="text-sm font-medium text-slate-700 dark:text-slate-300 w-32 text-center">
{format(month, 'LLLL yyyy', { locale: ru })}
</span>
<button onClick={() => setMonth(m => new Date(m.getFullYear(), m.getMonth() + 1))} className="btn-ghost p-1">
<ChevronRight size={16} />
</button>
</div>
</div>
<div className="grid grid-cols-7 gap-1 mb-1">
{['Пн','Вт','Ср','Чт','Пт','Сб','Вс'].map(d => (
<div key={d} className="text-center text-xs font-medium text-slate-400 py-1">{d}</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{Array.from({ length: offset }).map((_, i) => <div key={`e-${i}`} />)}
{days.map(day => {
const mod = getEffectiveModifier(day, rules)
const price = Math.round(baseRate * (1 + mod / 100))
const sameM = isSameMonth(day, month)
return (
<div
key={day.toISOString()}
className={cn(
'rounded-lg p-1.5 text-center transition-colors',
sameM ? modColor(mod) : 'opacity-0',
)}
>
<p className="text-xs font-medium">{format(day, 'd')}</p>
<p className="text-[10px] font-bold leading-tight">
{price.toLocaleString('ru-RU')}
</p>
{mod !== 0 && (
<p className={cn('text-[9px] leading-tight font-semibold',
mod > 0 ? 'text-red-600 dark:text-red-400' : 'text-blue-600 dark:text-blue-400'
)}>
{mod > 0 ? `+${mod}` : mod}%
</p>
)}
</div>
)
})}
</div>
{/* Legend */}
<div className="flex flex-wrap gap-3 mt-3 pt-3 border-t border-slate-200 dark:border-slate-700">
{[
{ cls: 'bg-white dark:bg-slate-700', label: 'Базовая цена' },
{ cls: 'bg-amber-50 dark:bg-amber-900/20', label: '+115%' },
{ cls: 'bg-orange-100 dark:bg-orange-900/40', label: '+1640%' },
{ cls: 'bg-red-200 dark:bg-red-900/50', label: '+40%+' },
{ cls: 'bg-blue-100 dark:bg-blue-900/40', label: 'Скидка' },
].map(l => (
<div key={l.label} className="flex items-center gap-1.5">
<div className={cn('w-3 h-3 rounded border border-slate-200 dark:border-slate-600', l.cls)} />
<span className="text-xs text-slate-500 dark:text-slate-400">{l.label}</span>
</div>
))}
</div>
</div>
)
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export function DynamicPricingPage() {
const [rules, setRules] = useState<PricingRule[]>(MOCK_RULES)
const [modal, setModal] = useState<'create' | PricingRule | null>(null)
const [delTarget, setDel] = useState<PricingRule | null>(null)
const BASE_RATE = 5600
const save = (data: Omit<PricingRule, 'id'>) => {
if (typeof modal === 'object' && modal !== null) {
setRules(prev => prev.map(r => r.id === modal.id ? { ...data, id: modal.id } : r))
} else {
setRules(prev => [...prev, { ...data, id: `r-${Date.now()}` }])
}
setModal(null)
}
const toggleActive = (id: string) =>
setRules(prev => prev.map(r => r.id === id ? { ...r, isActive: !r.isActive } : r))
return (
<div className="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>
{/* Info */}
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-800">
<Flame size={16} className="text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<p className="text-sm text-amber-700 dark:text-amber-400">
Правила применяются к базовой цене номера. При нескольких совпадающих правилах суммируются все надбавки/скидки.
Правило с более высоким приоритетом перекрывает остальные при конфликте.
</p>
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* Rules list */}
<div className="space-y-3">
<h2 className="text-sm font-semibold text-slate-600 dark:text-slate-400 uppercase tracking-wide">
Правила ({rules.filter(r => r.isActive).length} активных)
</h2>
{rules.map(rule => {
const meta = RULE_TYPE_META[rule.type]
return (
<div
key={rule.id}
className={cn(
'card p-4 flex items-center gap-3 transition-opacity',
!rule.isActive && 'opacity-50',
)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-0.5">
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">{rule.name}</span>
<span className={cn('px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase', meta.bg, meta.color)}>
{meta.label}
</span>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
{rule.type === 'weekend' && rule.weekdays && (
`Дни: ${rule.weekdays.map(d => WEEKDAY_LABELS[d]).join(', ')}`
)}
{['holiday','season','custom'].includes(rule.type) && rule.dateFrom && (
`${rule.dateFrom}${rule.dateTo}`
)}
{rule.type === 'weather' && rule.weatherTypes && (
`Погода: ${rule.weatherTypes.join(', ')}`
)}
{' · '} Приоритет: {rule.priority}
</p>
</div>
<div className="flex items-center gap-1">
{rule.modifierValue > 0
? <TrendingUp size={14} className="text-red-500" />
: <TrendingDown size={14} className="text-blue-500" />
}
<span className={cn('text-sm font-bold',
rule.modifierValue > 0 ? 'text-red-600 dark:text-red-400' : 'text-blue-600 dark:text-blue-400'
)}>
{rule.modifierValue > 0 ? '+' : ''}{rule.modifierValue}
{rule.modifierType === 'percent' ? '%' : ' ₽'}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => toggleActive(rule.id)}
className={cn('p-1.5 rounded-lg transition-colors',
rule.isActive ? 'text-brand-600' : 'text-slate-400'
)}
>
{rule.isActive ? <ToggleRight size={20} /> : <ToggleLeft size={20} />}
</button>
<button onClick={() => setModal(rule)}
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={() => setDel(rule)}
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>
)
})}
{/* Weather integration note */}
<div className="card p-4 border-dashed">
<div className="flex items-start gap-3">
<Sun size={16} className="text-amber-500 mt-0.5 shrink-0" />
<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 mt-0.5">
Правила типа «Погода» применяются автоматически при подключении OpenWeather API в настройках.
Прогноз загружается на 7 дней вперёд и обновляется каждые 6 часов.
</p>
<button className="mt-2 text-xs text-brand-600 dark:text-brand-400 hover:underline">
Подключить API погоды
</button>
</div>
</div>
</div>
</div>
{/* Price calendar */}
<PriceCalendar rules={rules} baseRate={BASE_RATE} />
</div>
{modal && (
<RuleModal
rule={typeof modal === 'object' ? modal : undefined}
onSave={save}
onClose={() => setModal(null)}
/>
)}
{delTarget && (
<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">«{delTarget.name}» будет удалён.</p>
<div className="flex justify-end gap-2">
<button onClick={() => setDel(null)} className="btn-secondary">Отмена</button>
<button
onClick={() => { setRules(prev => prev.filter(r => r.id !== delTarget.id)); setDel(null) }}
className="px-4 py-2 rounded-xl bg-red-600 text-white text-sm font-medium hover:bg-red-700"
>Удалить</button>
</div>
</div>
</div>
)}
</div>
)
}

490
src/pages/LoyaltyPage.tsx Normal file
View File

@@ -0,0 +1,490 @@
import { useState } from 'react'
import {
Award, Star, Gift, Percent, CreditCard, Edit2, Save, Info,
Crown, Shield, User, ChevronRight, Smartphone, Mail, QrCode,
TrendingUp,
} from 'lucide-react'
import { cn } from '../lib/utils'
// ─── Types ────────────────────────────────────────────────────────────────────
type LevelId = 'bronze' | 'silver' | 'gold' | 'platinum'
interface LoyaltyLevel {
id: LevelId
name: string
minPoints: number
maxPoints: number | null
discount: number // % discount on stay
bonusPercent: number // % of spent amount returned as points
perks: string[]
color: string
icon: React.ElementType
}
interface ProgramSettings {
pointsPerRuble: number // points per 1 ₽ spent
pointValue: number // 1 point = N rubles
expiryMonths: number // points expire after N months
isActive: boolean
}
interface GuestPoints {
id: string
name: string
email: string
points: number
totalSpent: number
level: LevelId
}
// ─── Mock Data ────────────────────────────────────────────────────────────────
const DEFAULT_LEVELS: LoyaltyLevel[] = [
{
id: 'bronze',
name: 'Бронза',
minPoints: 0,
maxPoints: 999,
discount: 0,
bonusPercent: 3,
perks: ['Накопление бонусных баллов 3%', 'Приоритет на бесплатный Wi-Fi', 'Поздний выезд по запросу'],
color: 'text-amber-700 dark:text-amber-500',
icon: User,
},
{
id: 'silver',
name: 'Серебро',
minPoints: 1000,
maxPoints: 4999,
discount: 5,
bonusPercent: 5,
perks: ['Скидка 5% на проживание', 'Накопление 5%', 'Бесплатный ранний заезд', 'Комплимент от отеля'],
color: 'text-slate-500 dark:text-slate-400',
icon: Shield,
},
{
id: 'gold',
name: 'Золото',
minPoints: 5000,
maxPoints: 14999,
discount: 10,
bonusPercent: 8,
perks: ['Скидка 10% на проживание', 'Накопление 8%', 'Гарантированный апгрейд номера', 'Приветственный напиток'],
color: 'text-yellow-500 dark:text-yellow-400',
icon: Award,
},
{
id: 'platinum',
name: 'Платина',
minPoints: 15000,
maxPoints: null,
discount: 15,
bonusPercent: 12,
perks: ['Скидка 15% на проживание', 'Накопление 12%', 'VIP-заезд/выезд', 'Персональный менеджер', 'Ежегодный бонус 500 баллов'],
color: 'text-violet-500 dark:text-violet-400',
icon: Crown,
},
]
const DEFAULT_SETTINGS: ProgramSettings = {
pointsPerRuble: 1,
pointValue: 0.5,
expiryMonths: 24,
isActive: true,
}
const MOCK_GUESTS: GuestPoints[] = [
{ id: 'g-1', name: 'Александр Петров', email: 'a.petrov@gmail.com', points: 16250, totalSpent: 287400, level: 'platinum' },
{ id: 'g-4', name: 'Елена Новикова', email: 'e.novikova@gmail.com', points: 8100, totalSpent: 76500, level: 'gold' },
{ id: 'g-6', name: 'Анна Лебедева', email: 'anna.l@mail.ru', points: 3950, totalSpent: 132000, level: 'silver' },
{ id: 'g-2', name: 'Мария Сидорова', email: 'm.sidorova@mail.ru', points: 2200, totalSpent: 98200, level: 'silver' },
{ id: 'g-5', name: 'Игорь Волков', email: 'i.volkov@corp.ru', points: 780, totalSpent: 44000, level: 'bronze' },
{ id: 'g-3', name: 'Дмитрий Козлов', email: 'dkozlov@yandex.ru', points: 340, totalSpent: 18600, level: 'bronze' },
{ id: 'g-7', name: 'Сергей Морозов', email: '', points: 120, totalSpent: 8400, level: 'bronze' },
]
function levelBadge(levelId: LevelId) {
const level = DEFAULT_LEVELS.find(l => l.id === levelId)!
const bgs: Record<LevelId, string> = {
bronze: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
silver: 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300',
gold: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
platinum: 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400',
}
return (
<span className={cn('inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold', bgs[levelId])}>
<level.icon size={10} />
{level.name}
</span>
)
}
// ─── Level Editor ─────────────────────────────────────────────────────────────
function LevelCard({ level, onEdit }: { level: LoyaltyLevel; onEdit: (l: LoyaltyLevel) => void }) {
const [editing, setEditing] = useState(false)
const [form, setForm] = useState(level)
const save = () => { onEdit(form); setEditing(false) }
const levelBg: Record<LevelId, string> = {
bronze: 'from-amber-50 to-orange-50 border-amber-200 dark:from-amber-900/10 dark:to-orange-900/10 dark:border-amber-800',
silver: 'from-slate-50 to-slate-100 border-slate-200 dark:from-slate-800/50 dark:to-slate-700/50 dark:border-slate-600',
gold: 'from-yellow-50 to-amber-50 border-yellow-200 dark:from-yellow-900/10 dark:to-amber-900/10 dark:border-yellow-800',
platinum: 'from-violet-50 to-purple-50 border-violet-200 dark:from-violet-900/10 dark:to-purple-900/10 dark:border-violet-800',
}
return (
<div className={cn('rounded-2xl border bg-gradient-to-br p-4', levelBg[level.id])}>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<level.icon size={20} className={level.color} />
<span className={cn('font-bold text-base', level.color)}>{level.name}</span>
</div>
<button
onClick={() => setEditing(!editing)}
className="p-1.5 rounded-lg text-slate-400 hover:text-brand-600 hover:bg-white/60 transition-colors"
>
{editing ? <Save size={15} /> : <Edit2 size={15} />}
</button>
</div>
{editing ? (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs text-slate-500 mb-1">Мин. баллов</label>
<input type="number" className="input text-sm" value={form.minPoints}
onChange={e => setForm(p => ({ ...p, minPoints: parseInt(e.target.value) || 0 }))} />
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">Скидка %</label>
<input type="number" min={0} max={50} className="input text-sm" value={form.discount}
onChange={e => setForm(p => ({ ...p, discount: parseInt(e.target.value) || 0 }))} />
</div>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">Начисление баллов %</label>
<input type="number" min={0} max={20} className="input text-sm" value={form.bonusPercent}
onChange={e => setForm(p => ({ ...p, bonusPercent: parseInt(e.target.value) || 0 }))} />
</div>
<button onClick={save} className="btn-primary w-full text-sm py-1.5">Сохранить</button>
</div>
) : (
<div className="space-y-1.5">
<div className="flex justify-between text-xs">
<span className="text-slate-500">Порог баллов</span>
<span className="font-semibold text-slate-700 dark:text-slate-300">
{level.minPoints.toLocaleString('ru-RU')}
{level.maxPoints ? `${level.maxPoints.toLocaleString('ru-RU')}` : '+'}
</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-slate-500">Скидка на проживание</span>
<span className={cn('font-bold', level.color)}>{level.discount}%</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-slate-500">Начисление баллов</span>
<span className="font-semibold text-slate-700 dark:text-slate-300">{level.bonusPercent}% от суммы</span>
</div>
<div className="mt-2 pt-2 border-t border-white/50 dark:border-slate-600/50 space-y-1">
{level.perks.map((perk, i) => (
<div key={i} className="flex items-start gap-1.5 text-xs text-slate-600 dark:text-slate-300">
<span className="text-emerald-500 mt-0.5 shrink-0"></span>
{perk}
</div>
))}
</div>
</div>
)}
</div>
)
}
// ─── Guest Portal Preview ─────────────────────────────────────────────────────
function GuestPortalPreview({ settings }: { settings: ProgramSettings }) {
const guest = MOCK_GUESTS[0]
const level = DEFAULT_LEVELS.find(l => l.id === guest.level)!
const nextLevel = DEFAULT_LEVELS.find(l => l.minPoints > guest.points)
const progressToNext = nextLevel
? ((guest.points - level.minPoints) / (nextLevel.minPoints - level.minPoints)) * 100
: 100
return (
<div className="rounded-2xl border-2 border-dashed border-slate-300 dark:border-slate-600 p-4">
<div className="flex items-center gap-2 mb-3">
<Smartphone size={14} className="text-slate-400" />
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wide">
Личный кабинет гостя (превью)
</p>
</div>
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl p-4 text-white space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-slate-400">Добро пожаловать,</p>
<p className="font-bold text-sm">{guest.name.split(' ')[0]}</p>
</div>
<div className="text-right">
<p className="text-xs text-slate-400">Уровень</p>
<div className="flex items-center gap-1">
<level.icon size={12} className={level.color} />
<p className={cn('text-sm font-bold', level.color)}>{level.name}</p>
</div>
</div>
</div>
{/* Points balance */}
<div className="bg-white/10 rounded-xl p-3">
<p className="text-xs text-slate-400 mb-0.5">Накопленные баллы</p>
<p className="text-3xl font-bold">{guest.points.toLocaleString('ru-RU')}</p>
<p className="text-xs text-slate-400 mt-0.5">
{Math.round(guest.points * settings.pointValue).toLocaleString('ru-RU')} к оплате
</p>
</div>
{/* Progress to next level */}
{nextLevel && (
<div>
<div className="flex justify-between text-xs text-slate-400 mb-1">
<span>До {nextLevel.name}</span>
<span>{(nextLevel.minPoints - guest.points).toLocaleString('ru-RU')} баллов</span>
</div>
<div className="h-2 rounded-full bg-white/20">
<div
className="h-2 rounded-full bg-gradient-to-r from-brand-400 to-brand-600"
style={{ width: `${Math.min(100, progressToNext)}%` }}
/>
</div>
</div>
)}
{/* Quick perks */}
<div className="grid grid-cols-3 gap-2">
{[
{ icon: Percent, label: `${level.discount}% скидка` },
{ icon: Gift, label: 'Комплимент' },
{ icon: CreditCard, label: 'Оплата баллами' },
].map(p => (
<div key={p.label} className="bg-white/5 rounded-lg p-2 text-center">
<p.icon size={14} className="mx-auto text-brand-400 mb-1" />
<p className="text-[10px] text-slate-300">{p.label}</p>
</div>
))}
</div>
</div>
<div className="flex items-center gap-3 mt-3 pt-3 border-t border-slate-200 dark:border-slate-700">
<QrCode size={14} className="text-slate-400 shrink-0" />
<p className="text-xs text-slate-500 dark:text-slate-400">
Гость получает ссылку на личный кабинет по email после первого бронирования.
Доступ без пароля, по magic link.
</p>
</div>
</div>
)
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export function LoyaltyPage() {
const [levels, setLevels] = useState<LoyaltyLevel[]>(DEFAULT_LEVELS)
const [settings, setSettings] = useState<ProgramSettings>(DEFAULT_SETTINGS)
const [editSettings, setEditSettings] = useState(false)
const updateLevel = (updated: LoyaltyLevel) =>
setLevels(prev => prev.map(l => l.id === updated.id ? updated : l))
const totalGuests = MOCK_GUESTS.length
const activeGuests = MOCK_GUESTS.filter(g => g.points > 0).length
const totalPoints = MOCK_GUESTS.reduce((s, g) => s + g.points, 0)
return (
<div className="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>
<div className="flex items-center gap-3">
<span className={cn(
'px-3 py-1.5 rounded-xl text-sm font-medium border',
settings.isActive
? 'bg-emerald-50 text-emerald-700 border-emerald-300 dark:bg-emerald-900/20 dark:text-emerald-400 dark:border-emerald-700'
: 'bg-slate-100 text-slate-500 border-slate-200 dark:bg-slate-700 dark:text-slate-400 dark:border-slate-600',
)}>
{settings.isActive ? '● Программа активна' : '○ Программа отключена'}
</span>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-4 gap-4">
{[
{ label: 'Участников программы', value: activeGuests, icon: User, color: 'text-brand-600 dark:text-brand-400', bg: 'bg-brand-50 dark:bg-brand-900/20' },
{ label: 'Всего баллов в обороте', value: totalPoints.toLocaleString('ru-RU'), icon: TrendingUp, color: 'text-amber-600 dark:text-amber-400', bg: 'bg-amber-50 dark:bg-amber-900/20' },
{ label: 'Платиновых участников', value: MOCK_GUESTS.filter(g => g.level === 'platinum').length, icon: Crown, color: 'text-violet-600 dark:text-violet-400', bg: 'bg-violet-50 dark:bg-violet-900/20' },
{ label: 'Средний балс/гость', value: Math.round(totalPoints / totalGuests).toLocaleString('ru-RU'), icon: Star, color: 'text-yellow-600 dark:text-yellow-400', bg: 'bg-yellow-50 dark:bg-yellow-900/20' },
].map(s => (
<div key={s.label} className="card p-4 flex items-center gap-3">
<div className={cn('w-10 h-10 rounded-xl flex items-center justify-center shrink-0', s.bg)}>
<s.icon size={18} className={s.color} />
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 leading-tight">{s.label}</p>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100 mt-0.5">{s.value}</p>
</div>
</div>
))}
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div className="xl:col-span-2 space-y-5">
{/* Program settings */}
<div className="card p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300">Настройки программы</h2>
<button onClick={() => setEditSettings(!editSettings)} className="btn-secondary flex items-center gap-1.5 text-sm">
{editSettings ? <><Save size={13} /> Сохранить</> : <><Edit2 size={13} /> Изменить</>}
</button>
</div>
<div className="grid grid-cols-3 gap-4">
{[
{
label: 'Начисление',
desc: `${settings.pointsPerRuble} балл за 1 ₽`,
field: 'pointsPerRuble' as const,
unit: 'балл/₽',
},
{
label: 'Ценность балла',
desc: `1 балл = ${settings.pointValue}`,
field: 'pointValue' as const,
unit: '₽',
},
{
label: 'Срок действия',
desc: `${settings.expiryMonths} месяцев`,
field: 'expiryMonths' as const,
unit: 'мес.',
},
].map(item => (
<div key={item.field}>
<p className="text-xs text-slate-500 dark:text-slate-400">{item.label}</p>
{editSettings ? (
<div className="flex items-center gap-1 mt-1">
<input
type="number" step="0.1" min={0}
className="input text-sm"
value={settings[item.field]}
onChange={e => setSettings(prev => ({
...prev,
[item.field]: parseFloat(e.target.value) || 0,
}))}
/>
<span className="text-xs text-slate-400 shrink-0">{item.unit}</span>
</div>
) : (
<p className="text-base font-semibold text-slate-900 dark:text-slate-100 mt-0.5">{item.desc}</p>
)}
</div>
))}
</div>
</div>
{/* Level cards */}
<div>
<h2 className="text-sm font-semibold text-slate-600 dark:text-slate-400 uppercase tracking-wide mb-3">
Уровни программы
</h2>
<div className="grid grid-cols-2 gap-3">
{levels.map(level => (
<LevelCard key={level.id} level={level} onEdit={updateLevel} />
))}
</div>
</div>
{/* Top guests */}
<div className="card overflow-hidden">
<div className="px-4 py-3 border-b border-slate-200 dark:border-slate-700">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300">Топ участников</h3>
</div>
<table className="w-full">
<thead>
<tr className="bg-slate-50 dark:bg-slate-700/30">
<th className="px-4 py-2 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">Гость</th>
<th className="px-4 py-2 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">Уровень</th>
<th className="px-4 py-2 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">Баллы</th>
<th className="px-4 py-2 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">Потрачено</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-700/50">
{MOCK_GUESTS.sort((a, b) => b.points - a.points).map(g => (
<tr key={g.id} className="hover:bg-slate-50 dark:hover:bg-slate-700/20 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center shrink-0">
<span className="text-xs font-bold text-brand-600 dark:text-brand-400">
{g.name.split(' ').map(p => p[0]).join('').slice(0, 2)}
</span>
</div>
<span className="text-sm text-slate-700 dark:text-slate-300">{g.name}</span>
</div>
</td>
<td className="px-4 py-3">{levelBadge(g.level)}</td>
<td className="px-4 py-3 text-right">
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{g.points.toLocaleString('ru-RU')}
</span>
</td>
<td className="px-4 py-3 text-right">
<span className="text-sm text-slate-600 dark:text-slate-400">
{g.totalSpent.toLocaleString('ru-RU')}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Right column */}
<div className="space-y-4">
{/* Guest portal preview */}
<GuestPortalPreview settings={settings} />
{/* Redemption info */}
<div className="card p-4 space-y-3">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300">Как работает программа</h3>
{[
{ icon: CreditCard, text: 'При каждом бронировании гостю начисляются баллы пропорционально потраченной сумме' },
{ icon: Percent, text: 'Накопленные баллы можно списать при следующем бронировании' },
{ icon: Mail, text: 'После первого визита гость получает email с ссылкой на личный кабинет' },
{ icon: Award, text: 'При достижении нового уровня приходит уведомление с перечнем привилегий' },
].map((item, i) => (
<div key={i} className="flex items-start gap-2.5">
<div className="w-6 h-6 rounded-lg bg-brand-50 dark:bg-brand-900/20 flex items-center justify-center shrink-0 mt-0.5">
<item.icon size={12} className="text-brand-600 dark:text-brand-400" />
</div>
<p className="text-xs text-slate-600 dark:text-slate-400">{item.text}</p>
</div>
))}
</div>
<div className="flex items-start gap-3 p-3 rounded-xl bg-brand-50 dark:bg-brand-900/10 border border-brand-200 dark:border-brand-800">
<Info size={14} className="text-brand-600 dark:text-brand-400 mt-0.5 shrink-0" />
<p className="text-xs text-brand-700 dark:text-brand-400">
В будущих версиях баллы будут работать во всех отелях сети HotelSync единая программа для всего бренда.
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,511 @@
import { useState } from 'react'
import {
Plus, Edit2, Trash2, Wrench, AlertTriangle, CheckCircle2,
Clock, BedDouble, Dumbbell, Waves, UtensilsCrossed, Wifi,
Car, Sparkles, Package, X,
} from 'lucide-react'
import { Modal } from '../components/ui/Modal'
import { cn } from '../lib/utils'
import { format } from 'date-fns'
import { ru } from 'date-fns/locale'
// ─── Types ────────────────────────────────────────────────────────────────────
type MaintenanceTarget = 'room' | 'service'
type MaintenanceStatus = 'scheduled' | 'in_progress' | 'done' | 'cancelled'
interface MaintenanceRecord {
id: string
type: MaintenanceTarget
targetId: string // roomId or serviceId
targetLabel: string // "Номер 205" or "Бассейн"
startDate: string
endDate: string
startTime?: string // HH:MM
endTime?: string
reason: string
notes: string
status: MaintenanceStatus
assignedTo?: string
}
interface ServiceDef {
id: string
label: string
icon: React.ElementType
}
// ─── Mock Data ────────────────────────────────────────────────────────────────
const MOCK_ROOMS = [
{ id: 'r101', label: 'Номер 101 — Стандарт' },
{ id: 'r102', label: 'Номер 102 — Стандарт' },
{ id: 'r205', label: 'Номер 205 — Делюкс' },
{ id: 'r301', label: 'Номер 301 — Сьют' },
{ id: 'r401', label: 'Номер 401 — Люкс' },
]
const SERVICES: ServiceDef[] = [
{ id: 'pool', label: 'Бассейн', icon: Waves },
{ id: 'gym', label: 'Тренажёрный зал', icon: Dumbbell },
{ id: 'restaurant', label: 'Ресторан', icon: UtensilsCrossed },
{ id: 'spa', label: 'СПА', icon: Sparkles },
{ id: 'wifi', label: 'Wi-Fi', icon: Wifi },
{ id: 'parking', label: 'Парковка', icon: Car },
{ id: 'laundry', label: 'Прачечная', icon: Package },
]
const MOCK_RECORDS: MaintenanceRecord[] = [
{
id: 'm-1',
type: 'room',
targetId: 'r205',
targetLabel: 'Номер 205 — Делюкс',
startDate: '2026-03-16',
endDate: '2026-03-18',
reason: 'Замена сантехники',
notes: 'Полная замена смесителей, ремонт душевой кабины. Подрядчик: ООО ТехСервис',
status: 'scheduled',
assignedTo: 'Иванов А.А.',
},
{
id: 'm-2',
type: 'service',
targetId: 'pool',
targetLabel: 'Бассейн',
startDate: '2026-03-20',
endDate: '2026-03-22',
startTime: '08:00',
endTime: '20:00',
reason: 'Плановая чистка и обработка воды',
notes: 'Проводится раз в квартал. Замена фильтров.',
status: 'scheduled',
assignedTo: 'Технический отдел',
},
{
id: 'm-3',
type: 'room',
targetId: 'r101',
targetLabel: 'Номер 101 — Стандарт',
startDate: '2026-03-10',
endDate: '2026-03-14',
reason: 'Косметический ремонт: покраска стен',
notes: 'Перекраска стен, замена штор',
status: 'done',
assignedTo: 'Строительная бригада',
},
{
id: 'm-4',
type: 'service',
targetId: 'restaurant',
targetLabel: 'Ресторан',
startDate: '2026-03-15',
endDate: '2026-03-15',
startTime: '14:00',
endTime: '18:00',
reason: 'Санитарная обработка',
notes: 'Плановая дезинфекция кухни',
status: 'in_progress',
},
{
id: 'm-5',
type: 'room',
targetId: 'r401',
targetLabel: 'Номер 401 — Люкс',
startDate: '2026-03-25',
endDate: '2026-04-05',
reason: 'Капитальный ремонт',
notes: 'Полный ремонт: полы, потолки, мебель',
status: 'scheduled',
},
]
// ─── Status helpers ───────────────────────────────────────────────────────────
const STATUS_META: Record<MaintenanceStatus, { label: string; cls: string; icon: React.ElementType }> = {
scheduled: { label: 'Запланировано', cls: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', icon: Clock },
in_progress: { label: 'Выполняется', cls: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400', icon: Wrench },
done: { label: 'Завершено', cls: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400', icon: CheckCircle2 },
cancelled: { label: 'Отменено', cls: 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400', icon: X },
}
function formatDateRange(from: string, to: string, startTime?: string, endTime?: string) {
const f = new Date(from), t = new Date(to)
const sameDay = from === to
const fmt = (d: Date) => format(d, 'd MMM', { locale: ru })
if (sameDay) {
return `${fmt(f)}${startTime ? ` ${startTime}${endTime ?? '...'}` : ''}`
}
return `${fmt(f)}${fmt(t)}`
}
function daysCount(from: string, to: string) {
const n = Math.round((new Date(to).getTime() - new Date(from).getTime()) / 86400000) + 1
return `${n} ${n === 1 ? 'день' : n < 5 ? 'дня' : 'дней'}`
}
// ─── Modal ────────────────────────────────────────────────────────────────────
const EMPTY: Omit<MaintenanceRecord, 'id'> = {
type: 'room',
targetId: MOCK_ROOMS[0].id,
targetLabel: MOCK_ROOMS[0].label,
startDate: format(new Date(), 'yyyy-MM-dd'),
endDate: format(new Date(), 'yyyy-MM-dd'),
reason: '',
notes: '',
status: 'scheduled',
}
function MaintenanceModal({
record, onSave, onClose,
}: {
record?: MaintenanceRecord
onSave: (r: Omit<MaintenanceRecord, 'id'>) => void
onClose: () => void
}) {
const [form, setForm] = useState<Omit<MaintenanceRecord, 'id'>>(
record ? { ...record } : { ...EMPTY }
)
const set = <K extends keyof typeof form>(k: K, v: typeof form[K]) =>
setForm(prev => ({ ...prev, [k]: v }))
const targets = form.type === 'room' ? MOCK_ROOMS : SERVICES.map(s => ({ id: s.id, label: s.label }))
const handleTargetChange = (id: string) => {
const t = targets.find(t => t.id === id)
if (t) { set('targetId', id); set('targetLabel', t.label) }
}
return (
<Modal
open
onClose={onClose}
title={record ? 'Редактировать перерыв' : 'Добавить тех. перерыв'}
size="lg"
footer={
<>
<button onClick={onClose} className="btn-secondary">Отмена</button>
<button
onClick={() => { if (form.reason) onSave(form) }}
className="btn-primary"
disabled={!form.reason}
>
{record ? 'Сохранить' : 'Добавить'}
</button>
</>
}
>
<div className="space-y-4">
{/* Type selector */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Тип</label>
<div className="grid grid-cols-2 gap-2">
{([
{ id: 'room' as const, label: 'Номер', icon: BedDouble },
{ id: 'service' as const, label: 'Услуга', icon: Wrench },
]).map(t => (
<button
key={t.id}
type="button"
onClick={() => {
set('type', t.id)
const def = t.id === 'room' ? MOCK_ROOMS[0] : { id: SERVICES[0].id, label: SERVICES[0].label }
set('targetId', def.id); set('targetLabel', def.label)
}}
className={cn(
'flex items-center gap-2 justify-center py-2.5 rounded-xl border text-sm font-medium transition-colors',
form.type === t.id
? 'bg-brand-600 border-brand-600 text-white'
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-brand-400',
)}
>
<t.icon size={16} /> {t.label}
</button>
))}
</div>
</div>
{/* Target */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
{form.type === 'room' ? 'Номер' : 'Услуга / объект'}
</label>
<select className="input" value={form.targetId} onChange={e => handleTargetChange(e.target.value)}>
{targets.map(t => <option key={t.id} value={t.id}>{t.label}</option>)}
</select>
</div>
{/* Dates */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Начало</label>
<input type="date" className="input" value={form.startDate}
onChange={e => set('startDate', e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Конец</label>
<input type="date" className="input" value={form.endDate}
onChange={e => set('endDate', e.target.value)} />
</div>
</div>
{/* Time (for services) */}
{form.type === 'service' && (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Начало (время)</label>
<input type="time" className="input" value={form.startTime ?? ''}
onChange={e => set('startTime', e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Конец (время)</label>
<input type="time" className="input" value={form.endTime ?? ''}
onChange={e => set('endTime', e.target.value)} />
</div>
</div>
)}
{/* Reason */}
<div>
<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.reason} onChange={e => set('reason', e.target.value)} />
</div>
{/* Notes */}
<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.notes} onChange={e => set('notes', e.target.value)} />
</div>
{/* Assigned + Status */}
<div className="grid grid-cols-2 gap-3">
<div>
<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.assignedTo ?? ''} onChange={e => set('assignedTo', e.target.value)} />
</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.status}
onChange={e => set('status', e.target.value as MaintenanceStatus)}>
{(Object.entries(STATUS_META) as [MaintenanceStatus, { label: string }][]).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
</div>
</div>
</div>
</Modal>
)
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export function MaintenancePage() {
const [records, setRecords] = useState<MaintenanceRecord[]>(MOCK_RECORDS)
const [modal, setModal] = useState<'create' | MaintenanceRecord | null>(null)
const [delTarget, setDel] = useState<MaintenanceRecord | null>(null)
const [activeTab, setActiveTab] = useState<'all' | 'room' | 'service'>('all')
const save = (data: Omit<MaintenanceRecord, 'id'>) => {
if (typeof modal === 'object' && modal !== null) {
setRecords(prev => prev.map(r => r.id === modal.id ? { ...data, id: modal.id } : r))
} else {
setRecords(prev => [...prev, { ...data, id: `m-${Date.now()}` }])
}
setModal(null)
}
const updateStatus = (id: string, status: MaintenanceStatus) =>
setRecords(prev => prev.map(r => r.id === id ? { ...r, status } : r))
const filtered = records.filter(r => activeTab === 'all' || r.type === activeTab)
const active = records.filter(r => r.status !== 'done' && r.status !== 'cancelled')
return (
<div className="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-4 gap-4">
{[
{ label: 'Всего записей', value: records.length, cls: '' },
{ label: 'Активных', value: active.length, cls: 'text-amber-600 dark:text-amber-400' },
{ label: 'Номеров на ремонте', value: records.filter(r => r.type === 'room' && r.status !== 'done' && r.status !== 'cancelled').length, cls: 'text-red-600 dark:text-red-400' },
{ label: 'Услуг недоступно', value: records.filter(r => r.type === 'service' && r.status !== 'done' && r.status !== 'cancelled').length, cls: 'text-blue-600 dark:text-blue-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 text-slate-900 dark:text-slate-100', s.cls)}>{s.value}</p>
</div>
))}
</div>
{/* Warning about current issues */}
{active.length > 0 && (
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-800">
<AlertTriangle size={16} className="text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium text-amber-700 dark:text-amber-400">
{active.length} активных перерыва
</p>
<p className="text-xs text-amber-600 dark:text-amber-500 mt-0.5">
Недоступные объекты автоматически блокируются в календаре бронирований и на странице доступности.
</p>
</div>
</div>
)}
{/* Filter tabs */}
<div className="card p-1 inline-flex gap-1">
{([
{ id: 'all' as const, label: `Все (${records.length})` },
{ id: 'room' as const, label: `Номера (${records.filter(r => r.type === 'room').length})` },
{ id: 'service' as const, label: `Услуги (${records.filter(r => r.type === 'service').length})` },
]).map(t => (
<button
key={t.id}
onClick={() => setActiveTab(t.id)}
className={cn(
'px-4 py-2 rounded-xl text-sm font-medium transition-colors',
activeTab === t.id
? 'bg-brand-600 text-white'
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700',
)}
>
{t.label}
</button>
))}
</div>
{/* Records list */}
<div className="space-y-3">
{filtered.length === 0 && (
<div className="card p-10 text-center text-slate-400">Записей нет</div>
)}
{filtered.map(record => {
const smeta = STATUS_META[record.status]
const svc = record.type === 'service' ? SERVICES.find(s => s.id === record.targetId) : null
return (
<div
key={record.id}
className={cn(
'card p-4 flex items-start gap-4',
record.status === 'done' && 'opacity-60',
)}
>
{/* Icon */}
<div className={cn(
'w-10 h-10 rounded-xl flex items-center justify-center shrink-0',
record.type === 'room'
? 'bg-slate-100 dark:bg-slate-700'
: 'bg-blue-50 dark:bg-blue-900/20',
)}>
{record.type === 'room'
? <BedDouble size={18} className="text-slate-500 dark:text-slate-400" />
: svc ? <svc.icon size={18} className="text-blue-500" /> : <Wrench size={18} className="text-blue-500" />
}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-0.5">
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">{record.targetLabel}</span>
<span className={cn('inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium', smeta.cls)}>
<smeta.icon size={10} />
{smeta.label}
</span>
</div>
<p className="text-sm text-slate-700 dark:text-slate-300 mb-0.5">{record.reason}</p>
{record.notes && (
<p className="text-xs text-slate-500 dark:text-slate-400">{record.notes}</p>
)}
<div className="flex items-center gap-3 mt-1">
<span className="text-xs text-slate-500 dark:text-slate-400">
📅 {formatDateRange(record.startDate, record.endDate, record.startTime, record.endTime)}
{' · '}{daysCount(record.startDate, record.endDate)}
</span>
{record.assignedTo && (
<span className="text-xs text-slate-500 dark:text-slate-400">
👤 {record.assignedTo}
</span>
)}
</div>
</div>
{/* Quick status change */}
{record.status !== 'done' && record.status !== 'cancelled' && (
<select
className="input text-xs w-36 shrink-0"
value={record.status}
onChange={e => updateStatus(record.id, e.target.value as MaintenanceStatus)}
>
{(Object.entries(STATUS_META) as [MaintenanceStatus, { label: string }][]).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
)}
{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => setModal(record)}
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={() => setDel(record)}
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>
{modal && (
<MaintenanceModal
record={typeof modal === 'object' ? modal : undefined}
onSave={save}
onClose={() => setModal(null)}
/>
)}
{delTarget && (
<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 mb-4">«{delTarget.targetLabel} {delTarget.reason}» будет удалена.</p>
<div className="flex justify-end gap-2">
<button onClick={() => setDel(null)} className="btn-secondary">Отмена</button>
<button
onClick={() => { setRecords(prev => prev.filter(r => r.id !== delTarget.id)); setDel(null) }}
className="px-4 py-2 rounded-xl bg-red-600 text-white text-sm font-medium hover:bg-red-700"
>Удалить</button>
</div>
</div>
</div>
)}
</div>
)
}

572
src/pages/TariffsPage.tsx Normal file
View File

@@ -0,0 +1,572 @@
import { useState } from 'react'
import {
Plus, Edit2, Trash2, ToggleLeft, ToggleRight, Coffee, Utensils,
Wifi, Car, Waves, Sparkles, Plane, Package, Check, X, Info, Tag,
} from 'lucide-react'
import { Modal } from '../components/ui/Modal'
import { cn } from '../lib/utils'
// ─── Types ────────────────────────────────────────────────────────────────────
type MealPlan = 'no_meals' | 'breakfast' | 'hb' | 'fb' | 'ai'
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
}
// ─── Mock ─────────────────────────────────────────────────────────────────────
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,
}: {
tariff?: Tariff
onSave: (t: Omit<Tariff, 'id'>) => void
onClose: () => void
}) {
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 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 = MEAL_PLANS.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={
<>
<button onClick={onClose} className="btn-secondary">Отмена</button>
<button
onClick={() => { if (form.name && form.code) onSave(form) }}
className="btn-primary"
disabled={!form.name || !form.code}
>
{tariff ? 'Сохранить' : 'Создать тариф'}
</button>
</>
}
>
<div className="space-y-5">
{/* Name + Code */}
<div className="grid grid-cols-3 gap-3">
<div className="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 => set('name', e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Код *
</label>
<input type="text" className="input font-mono uppercase" placeholder="STD-BB"
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-5 gap-2">
{MEAL_PLANS.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-4 gap-2">
{ALL_INCLUSIONS.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-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 [tariffs, setTariffs] = useState<Tariff[]>(MOCK_TARIFFS)
const [modal, setModal] = useState<'create' | Tariff | null>(null)
const [deleteTarget, setDeleteTarget] = useState<Tariff | null>(null)
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))
} else {
setTariffs(prev => [...prev, { ...data, id: `t-${Date.now()}` }])
}
setModal(null)
}
const toggleActive = (id: string) =>
setTariffs(prev => prev.map(t => t.id === id ? { ...t, isActive: !t.isActive } : t))
const activeTariffs = tariffs.filter(t => t.isActive)
const inactiveTariffs = tariffs.filter(t => !t.isActive)
return (
<div className="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-4 gap-4">
{[
{ 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 = MEAL_PLANS.find(m => m.id === tariff.mealPlan)!
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 = ALL_INCLUSIONS.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 modifier */}
<div className="text-right shrink-0">
<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>
{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
<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>
{/* Tariff Modal */}
{modal && (
<TariffModal
tariff={typeof modal === 'object' ? modal : undefined}
onSave={save}
onClose={() => setModal(null)}
/>
)}
{/* 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={() => {
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>
)
}