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:
10
src/App.tsx
10
src/App.tsx
@@ -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 */}
|
||||||
|
|||||||
@@ -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' ? '0–100 %' : '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')} ₽
|
||||||
|
|||||||
@@ -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
435
src/pages/DiscountsPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
637
src/pages/DynamicPricingPage.tsx
Normal file
637
src/pages/DynamicPricingPage.tsx
Normal 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: '+1–15%' },
|
||||||
|
{ cls: 'bg-orange-100 dark:bg-orange-900/40', label: '+16–40%' },
|
||||||
|
{ 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
490
src/pages/LoyaltyPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
511
src/pages/MaintenancePage.tsx
Normal file
511
src/pages/MaintenancePage.tsx
Normal 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
572
src/pages/TariffsPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user