feat: collapsible sidebar groups + favorites, chat hide setting, billing page, module route guards
This commit is contained in:
15
src/App.tsx
15
src/App.tsx
@@ -40,6 +40,8 @@ import { ResetPasswordPage } from './pages/ResetPasswordPage'
|
||||
import { TvWelcomePage } from './pages/TvWelcomePage'
|
||||
import { TechnicalPage } from './pages/TechnicalPage'
|
||||
import { SchedulePage } from './pages/SchedulePage'
|
||||
import { BillingPage } from './pages/BillingPage'
|
||||
import { ModuleGuard } from './components/ModuleGuard'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -62,8 +64,8 @@ export default function App() {
|
||||
<Route path="/calendar" element={<CalendarPage />} />
|
||||
<Route path="/bookings" element={<BookingsPage />} />
|
||||
<Route path="/rooms" element={<RoomsPage />} />
|
||||
<Route path="/housekeeping" element={<HousekeepingPage />} />
|
||||
<Route path="/channels" element={<ChannelsPage />} />
|
||||
<Route path="/housekeeping" element={<ModuleGuard moduleId="housekeeping"><HousekeepingPage /></ModuleGuard>} />
|
||||
<Route path="/channels" element={<ModuleGuard moduleId="channel-manager"><ChannelsPage /></ModuleGuard>} />
|
||||
<Route path="/api-docs" element={<ApiDocsPage />} />
|
||||
<Route path="/modules" element={<ModulesPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
@@ -73,10 +75,10 @@ export default function App() {
|
||||
<Route path="/availability" element={<AvailabilityPage />} />
|
||||
<Route path="/floor-map" element={<FloorMapPage />} />
|
||||
<Route path="/migration" element={<MigrationPage />} />
|
||||
<Route path="/pos" element={<PosPage />} />
|
||||
<Route path="/reviews" element={<ReviewsPage />} />
|
||||
<Route path="/room-service" element={<RoomServicePage />} />
|
||||
<Route path="/rental" element={<RentalPage />} />
|
||||
<Route path="/pos" element={<ModuleGuard moduleId="pos"><PosPage /></ModuleGuard>} />
|
||||
<Route path="/reviews" element={<ModuleGuard moduleId="reviews"><ReviewsPage /></ModuleGuard>} />
|
||||
<Route path="/room-service" element={<ModuleGuard moduleId="room-service"><RoomServicePage /></ModuleGuard>} />
|
||||
<Route path="/rental" element={<ModuleGuard moduleId="rental"><RentalPage /></ModuleGuard>} />
|
||||
<Route path="/room-categories" element={<RoomCategoriesPage />} />
|
||||
<Route path="/documents" element={<DocumentsPage />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
@@ -89,6 +91,7 @@ export default function App() {
|
||||
<Route path="/discounts" element={<DiscountsPage />} />
|
||||
<Route path="/tv-welcome" element={<TvWelcomePage />} />
|
||||
<Route path="/technical" element={<TechnicalPage />} />
|
||||
<Route path="/billing" element={<BillingPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||
|
||||
14
src/components/ModuleGuard.tsx
Normal file
14
src/components/ModuleGuard.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useModules } from '../contexts/ModulesContext'
|
||||
|
||||
interface ModuleGuardProps {
|
||||
moduleId: string
|
||||
children: React.ReactNode
|
||||
redirectTo?: string
|
||||
}
|
||||
|
||||
export function ModuleGuard({ moduleId, children, redirectTo = '/modules' }: ModuleGuardProps) {
|
||||
const { isActive } = useModules()
|
||||
if (!isActive(moduleId)) return <Navigate to={redirectTo} replace />
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { useState, useEffect } from 'react'
|
||||
import {
|
||||
CalendarDays, BookOpen, BedDouble, Globe, Settings,
|
||||
FileText, LayoutDashboard, Building2, Users, X, Hotel, Puzzle, CalendarRange, Map, LayoutGrid, UserCog, UsersRound,
|
||||
TrendingUp, Tag, Award, Wrench, Utensils, CalendarClock, Zap, ChevronRight, Star,
|
||||
TrendingUp, Tag, Award, Wrench, Utensils, CalendarClock, Zap, ChevronRight, Star, CreditCard,
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
import { useModules } from '../../contexts/ModulesContext'
|
||||
@@ -185,7 +185,7 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
||||
prices: ['/tariffs', '/dynamic-pricing', '/discounts', '/rental'],
|
||||
service: ['/housekeeping', '/technical', ...activeModuleItems.map(m => m.sidebarItem!.path)],
|
||||
management: ['/users', '/schedule', '/loyalty', '/maintenance', '/floor-map', '/channels'],
|
||||
settingsGroup:['/modules', '/settings'],
|
||||
settingsGroup:['/modules', '/settings', '/billing'],
|
||||
devGroup: ['/api-docs'],
|
||||
}
|
||||
|
||||
@@ -410,8 +410,9 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
||||
<GroupHeader label="Настройки" isOpen={groups.settingsGroup} onToggle={() => toggleGroup('settingsGroup')} />
|
||||
{groups.settingsGroup && (
|
||||
<div className="space-y-0.5">
|
||||
<NavItem to="/modules" icon={Puzzle} label="Модули" {...navItemProps} />
|
||||
<NavItem to="/settings" icon={Settings} label="Настройки" {...navItemProps} />
|
||||
<NavItem to="/modules" icon={Puzzle} label="Модули" {...navItemProps} />
|
||||
<NavItem to="/settings" icon={Settings} label="Настройки" {...navItemProps} />
|
||||
<NavItem to="/billing" icon={CreditCard} label="Тарифы и оплата" {...navItemProps} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
561
src/pages/BillingPage.tsx
Normal file
561
src/pages/BillingPage.tsx
Normal file
@@ -0,0 +1,561 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Check, CreditCard, Building2, Zap, Crown, ChevronRight,
|
||||
Copy, CheckCircle2, AlertCircle, ExternalLink, ToggleLeft, ToggleRight,
|
||||
Receipt, ArrowRight, Lock,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '../lib/utils'
|
||||
import { MODULES_DATA } from '../data/modulesData'
|
||||
import { useModules } from '../contexts/ModulesContext'
|
||||
import type { ModuleStatus } from '../data/modulesData'
|
||||
|
||||
// ─── Plans ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Plan {
|
||||
id: string
|
||||
name: string
|
||||
price: number | null
|
||||
period: string
|
||||
rooms: string
|
||||
description: string
|
||||
features: string[]
|
||||
icon: React.ElementType
|
||||
color: string
|
||||
badge?: string
|
||||
popular?: boolean
|
||||
}
|
||||
|
||||
const PLANS: Plan[] = [
|
||||
{
|
||||
id: 'starter',
|
||||
name: 'Стартовый',
|
||||
price: 0,
|
||||
period: 'бесплатно',
|
||||
rooms: 'до 5 номеров',
|
||||
description: 'Идеально для небольших гостиниц и хостелов',
|
||||
icon: Zap,
|
||||
color: 'text-slate-600 dark:text-slate-400',
|
||||
features: [
|
||||
'Шахматка и бронирования',
|
||||
'Управление номерами',
|
||||
'До 3 сотрудников',
|
||||
'Базовые отчёты',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pro',
|
||||
name: 'Стандарт',
|
||||
price: 2990,
|
||||
period: 'в месяц',
|
||||
rooms: 'до 30 номеров',
|
||||
description: 'Для растущих отелей с командой',
|
||||
icon: Building2,
|
||||
color: 'text-brand-600 dark:text-brand-400',
|
||||
popular: true,
|
||||
badge: 'Популярный',
|
||||
features: [
|
||||
'Всё из Стартового',
|
||||
'До 30 номеров',
|
||||
'Неограниченно сотрудников',
|
||||
'Модуль уборки включён',
|
||||
'Управление гостями',
|
||||
'Email-уведомления',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'business',
|
||||
name: 'Профессиональный',
|
||||
price: 5990,
|
||||
period: 'в месяц',
|
||||
rooms: 'до 100 номеров',
|
||||
description: 'Полный набор инструментов для отелей',
|
||||
icon: Crown,
|
||||
color: 'text-violet-600 dark:text-violet-400',
|
||||
features: [
|
||||
'Всё из Стандарта',
|
||||
'До 100 номеров',
|
||||
'Менеджер каналов включён',
|
||||
'Динамическое ценообразование',
|
||||
'Программа лояльности',
|
||||
'Приоритетная поддержка',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'enterprise',
|
||||
name: 'Корпоративный',
|
||||
price: null,
|
||||
period: 'договорная цена',
|
||||
rooms: 'без ограничений',
|
||||
description: 'Для сетей отелей и крупных объектов',
|
||||
icon: CreditCard,
|
||||
color: 'text-amber-600 dark:text-amber-400',
|
||||
features: [
|
||||
'Всё из Профессионального',
|
||||
'Без ограничений по номерам',
|
||||
'White-label / ваш бренд',
|
||||
'API-интеграции',
|
||||
'Выделенный менеджер',
|
||||
'SLA-соглашение',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const CURRENT_PLAN_ID = 'business' // demo: all active
|
||||
|
||||
// ─── Invoice Modal ─────────────────────────────────────────────────────────────
|
||||
|
||||
function InvoiceModal({
|
||||
plan,
|
||||
modules,
|
||||
totalMonthly,
|
||||
onClose,
|
||||
}: {
|
||||
plan: Plan
|
||||
modules: { name: string; price: number }[]
|
||||
totalMonthly: number
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const BANK_DETAILS = `Получатель: ООО «ХотелСинк»
|
||||
ИНН: 7701234567
|
||||
КПП: 770101001
|
||||
Р/с: 40702810000000000001
|
||||
Банк: АО «Тинькофф Банк»
|
||||
БИК: 044525974
|
||||
К/с: 30101810145250000974
|
||||
Назначение: Оплата подписки HotelSync, ${plan.name}, ${totalMonthly.toLocaleString('ru-RU')} ₽/мес`
|
||||
|
||||
const copyDetails = () => {
|
||||
navigator.clipboard.writeText(BANK_DETAILS).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl w-full max-w-lg" onClick={e => e.stopPropagation()}>
|
||||
<div className="p-6 border-b border-slate-200 dark:border-slate-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
|
||||
<Receipt size={20} className="text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">Счёт на оплату</h2>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Банковский перевод</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Summary */}
|
||||
<div className="rounded-xl bg-slate-50 dark:bg-slate-700/50 p-4 space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-600 dark:text-slate-400">Тариф {plan.name}</span>
|
||||
<span className="font-medium text-slate-900 dark:text-slate-100">
|
||||
{plan.price ? `${plan.price.toLocaleString('ru-RU')} ₽` : 'Договорная'}
|
||||
</span>
|
||||
</div>
|
||||
{modules.map(m => (
|
||||
<div key={m.name} className="flex justify-between text-sm">
|
||||
<span className="text-slate-600 dark:text-slate-400">{m.name}</span>
|
||||
<span className="font-medium text-slate-900 dark:text-slate-100">{m.price.toLocaleString('ru-RU')} ₽</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="pt-2 border-t border-slate-200 dark:border-slate-600 flex justify-between font-bold">
|
||||
<span className="text-slate-900 dark:text-slate-100">Итого/месяц</span>
|
||||
<span className="text-brand-600 dark:text-brand-400">{totalMonthly.toLocaleString('ru-RU')} ₽</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bank details */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm font-semibold text-slate-700 dark:text-slate-300">Банковские реквизиты</p>
|
||||
<button
|
||||
onClick={copyDetails}
|
||||
className="flex items-center gap-1.5 text-xs text-brand-600 hover:text-brand-700 dark:text-brand-400 transition-colors"
|
||||
>
|
||||
{copied ? <CheckCircle2 size={13} /> : <Copy size={13} />}
|
||||
{copied ? 'Скопировано' : 'Копировать'}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="text-xs bg-slate-50 dark:bg-slate-700/50 rounded-xl p-4 text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-mono leading-relaxed border border-slate-200 dark:border-slate-600">
|
||||
{BANK_DETAILS}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 p-3 rounded-xl bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-800">
|
||||
<AlertCircle size={14} className="text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
|
||||
<p className="text-xs text-amber-700 dark:text-amber-400">
|
||||
После оплаты отправьте подтверждение на <strong>billing@hotelsync.ru</strong> — активация в течение 1 рабочего дня
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-slate-200 dark:border-slate-700 flex justify-end">
|
||||
<button onClick={onClose} className="btn-secondary">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Module Row ────────────────────────────────────────────────────────────────
|
||||
|
||||
function ModuleRow({
|
||||
mod,
|
||||
status,
|
||||
onToggle,
|
||||
}: {
|
||||
mod: (typeof MODULES_DATA)[0]
|
||||
status: ModuleStatus
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const active = status === 'active' || status === 'trial'
|
||||
const Icon = mod.icon
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex items-center gap-4 px-5 py-4 transition-colors',
|
||||
active ? 'hover:bg-slate-50 dark:hover:bg-slate-700/30' : 'opacity-60 hover:bg-slate-50 dark:hover:bg-slate-700/30',
|
||||
)}>
|
||||
<div className={cn('w-9 h-9 rounded-xl flex items-center justify-center shrink-0', mod.iconBg)}>
|
||||
<Icon size={18} className={mod.iconColor} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{mod.name}</p>
|
||||
{status === 'trial' && (
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400">
|
||||
Пробный
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 truncate">{mod.tagline}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 shrink-0">
|
||||
<span className={cn(
|
||||
'text-sm font-semibold',
|
||||
mod.price === 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-slate-700 dark:text-slate-300',
|
||||
)}>
|
||||
{mod.price === 0 ? 'Включён' : `${mod.price.toLocaleString('ru-RU')} ₽/мес`}
|
||||
</span>
|
||||
{mod.price > 0 ? (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="relative shrink-0 transition-colors"
|
||||
title={active ? 'Отключить' : 'Подключить'}
|
||||
>
|
||||
{active
|
||||
? <ToggleRight size={28} className="text-brand-600 dark:text-brand-400" />
|
||||
: <ToggleLeft size={28} className="text-slate-300 dark:text-slate-600" />
|
||||
}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-7 h-4 flex items-center justify-center">
|
||||
<Check size={14} className="text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main Page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type PageTab = 'plans' | 'modules' | 'history'
|
||||
|
||||
export function BillingPage() {
|
||||
const [tab, setTab] = useState<PageTab>('plans')
|
||||
const [showInvoice, setShowInvoice] = useState(false)
|
||||
const [invoicePlan, setInvoicePlan] = useState<Plan | null>(null)
|
||||
const { statuses, setStatus } = useModules()
|
||||
|
||||
const currentPlan = PLANS.find(p => p.id === CURRENT_PLAN_ID)!
|
||||
|
||||
const toggleModule = (id: string) => {
|
||||
const cur = statuses[id] ?? 'inactive'
|
||||
const next: ModuleStatus = (cur === 'active' || cur === 'trial') ? 'inactive' : 'active'
|
||||
setStatus(id, next)
|
||||
}
|
||||
|
||||
const paidActiveModules = MODULES_DATA.filter(m => {
|
||||
const active = statuses[m.id] === 'active' || statuses[m.id] === 'trial'
|
||||
return active && m.price > 0
|
||||
})
|
||||
|
||||
const totalMonthly = (currentPlan.price ?? 0) + paidActiveModules.reduce((s, m) => s + m.price, 0)
|
||||
|
||||
const openInvoice = (plan: Plan) => {
|
||||
setInvoicePlan(plan)
|
||||
setShowInvoice(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-5 max-w-5xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-start 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">
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">Ежемесячный платёж</p>
|
||||
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{totalMonthly > 0 ? `${totalMonthly.toLocaleString('ru-RU')} ₽` : 'Бесплатно'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => currentPlan && openInvoice(currentPlan)}
|
||||
className="btn-secondary flex items-center gap-2"
|
||||
>
|
||||
<Receipt size={14} />
|
||||
Выставить счёт
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current plan banner */}
|
||||
<div className="card p-4 flex items-center gap-4 border-brand-200 dark:border-brand-800 bg-gradient-to-r from-brand-50 to-transparent dark:from-brand-900/10 dark:to-transparent">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-600 flex items-center justify-center shrink-0">
|
||||
<currentPlan.icon size={20} className="text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-brand-600 dark:text-brand-400 font-medium">Текущий тариф</p>
|
||||
<p className="text-lg font-bold text-slate-900 dark:text-slate-100">{currentPlan.name}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">Следующее списание</p>
|
||||
<p className="text-sm font-semibold text-slate-700 dark:text-slate-300">1 апреля 2026</p>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<span className="px-3 py-1 rounded-full text-xs font-semibold bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400">
|
||||
Активен
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-700">
|
||||
{([
|
||||
{ id: 'plans', label: 'Тарифные планы' },
|
||||
{ id: 'modules', label: 'Модули' },
|
||||
{ id: 'history', label: 'История платежей' },
|
||||
] as { id: PageTab; label: string }[]).map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={cn(
|
||||
'px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||
tab === t.id
|
||||
? 'border-brand-600 text-brand-600 dark:text-brand-400 dark:border-brand-400'
|
||||
: 'border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300',
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Plans Tab ── */}
|
||||
{tab === 'plans' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
{PLANS.map(plan => {
|
||||
const isCurrent = plan.id === CURRENT_PLAN_ID
|
||||
const Icon = plan.icon
|
||||
return (
|
||||
<div
|
||||
key={plan.id}
|
||||
className={cn(
|
||||
'card flex flex-col overflow-hidden transition-shadow hover:shadow-lg',
|
||||
isCurrent && 'ring-2 ring-brand-500 dark:ring-brand-400',
|
||||
)}
|
||||
>
|
||||
{plan.popular && (
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-500 to-brand-400" />
|
||||
)}
|
||||
<div className="p-5 flex flex-col flex-1 gap-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Icon size={20} className={plan.color} />
|
||||
{isCurrent && (
|
||||
<span className="px-2 py-0.5 rounded-full text-[10px] font-semibold bg-brand-100 text-brand-700 dark:bg-brand-900/40 dark:text-brand-400">
|
||||
Текущий
|
||||
</span>
|
||||
)}
|
||||
{plan.badge && !isCurrent && (
|
||||
<span className="px-2 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400">
|
||||
{plan.badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-base font-bold text-slate-900 dark:text-slate-100">{plan.name}</h3>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{plan.description}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
{plan.price !== null ? (
|
||||
<>
|
||||
<span className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{plan.price === 0 ? 'Бесплатно' : `${plan.price.toLocaleString('ru-RU')} ₽`}
|
||||
</span>
|
||||
{plan.price > 0 && <span className="text-sm text-slate-500 dark:text-slate-400">/мес</span>}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-lg font-bold text-slate-900 dark:text-slate-100">Договорная</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{plan.rooms}</p>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-1.5 flex-1">
|
||||
{plan.features.map((f, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-xs text-slate-600 dark:text-slate-400">
|
||||
<Check size={12} className="text-emerald-500 mt-0.5 shrink-0" />
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="space-y-2 pt-2">
|
||||
{isCurrent ? (
|
||||
<div className="text-center py-2 text-xs font-medium text-brand-600 dark:text-brand-400">
|
||||
Текущий тариф ✓
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => openInvoice(plan)}
|
||||
className="w-full btn-secondary text-sm flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<Receipt size={13} />
|
||||
Выставить счёт
|
||||
</button>
|
||||
{plan.id === 'enterprise' ? (
|
||||
<button className="w-full flex items-center justify-center gap-1.5 py-2 rounded-xl bg-amber-600 hover:bg-amber-700 text-white text-sm font-medium transition-colors">
|
||||
Связаться с нами <ArrowRight size={13} />
|
||||
</button>
|
||||
) : (
|
||||
<button className="w-full flex items-center justify-center gap-1.5 py-2 rounded-xl bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium transition-colors">
|
||||
Перейти на тариф <ChevronRight size={13} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Modules Tab ── */}
|
||||
{tab === 'modules' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3 p-3 rounded-xl bg-sky-50 dark:bg-sky-900/10 border border-sky-200 dark:border-sky-800">
|
||||
<AlertCircle size={14} className="text-sky-600 dark:text-sky-400 mt-0.5 shrink-0" />
|
||||
<p className="text-xs text-sky-700 dark:text-sky-400">
|
||||
Подключённые модули автоматически отображаются в меню. Отключённые модули скрываются и становятся недоступны для всех сотрудников.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-5 py-3 bg-slate-50 dark:bg-slate-700/30 border-b border-slate-200 dark:border-slate-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">Модуль</p>
|
||||
<p className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">Стоимость</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100 dark:divide-slate-700/50">
|
||||
{MODULES_DATA.map(mod => (
|
||||
<ModuleRow
|
||||
key={mod.id}
|
||||
mod={mod}
|
||||
status={statuses[mod.id] ?? 'inactive'}
|
||||
onToggle={() => toggleModule(mod.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="px-5 py-3 bg-slate-50 dark:bg-slate-700/30 border-t border-slate-200 dark:border-slate-700 flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-slate-600 dark:text-slate-400">Итого за модули</p>
|
||||
<p className="text-sm font-bold text-slate-900 dark:text-slate-100">
|
||||
{paidActiveModules.reduce((s, m) => s + m.price, 0).toLocaleString('ru-RU')} ₽/мес
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* YuKassa placeholder */}
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-8 h-8 rounded-lg bg-violet-100 dark:bg-violet-900/30 flex items-center justify-center">
|
||||
<Lock size={16} className="text-violet-600 dark:text-violet-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">Онлайн-оплата через ЮKassa</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">Карты, СБП, Яндекс Пэй — скоро</p>
|
||||
</div>
|
||||
<span className="ml-auto px-2.5 py-1 rounded-full text-xs font-medium bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400">
|
||||
Скоро
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 rounded-xl bg-slate-50 dark:bg-slate-700/50 border border-dashed border-slate-300 dark:border-slate-600">
|
||||
<ExternalLink size={14} className="text-slate-400 shrink-0" />
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Интеграция с ЮKassa позволит оплачивать подписку картой или через СБП прямо в личном кабинете
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── History Tab ── */}
|
||||
{tab === 'history' && (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-5 py-3 bg-slate-50 dark:bg-slate-700/30 border-b border-slate-200 dark:border-slate-700">
|
||||
<p className="text-sm font-semibold text-slate-700 dark:text-slate-300">История платежей</p>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100 dark:divide-slate-700/50">
|
||||
{[
|
||||
{ date: '01.03.2026', desc: 'Тариф Профессиональный', amount: 5990, status: 'paid' },
|
||||
{ date: '01.02.2026', desc: 'Тариф Профессиональный', amount: 5990, status: 'paid' },
|
||||
{ date: '01.01.2026', desc: 'Тариф Стандарт + Менеджер каналов', amount: 5490, status: 'paid' },
|
||||
{ date: '01.12.2025', desc: 'Тариф Стандарт', amount: 2990, status: 'paid' },
|
||||
].map((item, i) => (
|
||||
<div key={i} className="px-5 py-3.5 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{item.desc}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">{item.date}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||
{item.amount.toLocaleString('ru-RU')} ₽
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded-full text-[10px] font-medium bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400">
|
||||
Оплачен
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Invoice Modal */}
|
||||
{showInvoice && invoicePlan && (
|
||||
<InvoiceModal
|
||||
plan={invoicePlan}
|
||||
modules={paidActiveModules.map(m => ({ name: m.name, price: m.price }))}
|
||||
totalMonthly={totalMonthly}
|
||||
onClose={() => setShowInvoice(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user