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 { TvWelcomePage } from './pages/TvWelcomePage'
|
||||||
import { TechnicalPage } from './pages/TechnicalPage'
|
import { TechnicalPage } from './pages/TechnicalPage'
|
||||||
import { SchedulePage } from './pages/SchedulePage'
|
import { SchedulePage } from './pages/SchedulePage'
|
||||||
|
import { BillingPage } from './pages/BillingPage'
|
||||||
|
import { ModuleGuard } from './components/ModuleGuard'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -62,8 +64,8 @@ export default function App() {
|
|||||||
<Route path="/calendar" element={<CalendarPage />} />
|
<Route path="/calendar" element={<CalendarPage />} />
|
||||||
<Route path="/bookings" element={<BookingsPage />} />
|
<Route path="/bookings" element={<BookingsPage />} />
|
||||||
<Route path="/rooms" element={<RoomsPage />} />
|
<Route path="/rooms" element={<RoomsPage />} />
|
||||||
<Route path="/housekeeping" element={<HousekeepingPage />} />
|
<Route path="/housekeeping" element={<ModuleGuard moduleId="housekeeping"><HousekeepingPage /></ModuleGuard>} />
|
||||||
<Route path="/channels" element={<ChannelsPage />} />
|
<Route path="/channels" element={<ModuleGuard moduleId="channel-manager"><ChannelsPage /></ModuleGuard>} />
|
||||||
<Route path="/api-docs" element={<ApiDocsPage />} />
|
<Route path="/api-docs" element={<ApiDocsPage />} />
|
||||||
<Route path="/modules" element={<ModulesPage />} />
|
<Route path="/modules" element={<ModulesPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
@@ -73,10 +75,10 @@ export default function App() {
|
|||||||
<Route path="/availability" element={<AvailabilityPage />} />
|
<Route path="/availability" element={<AvailabilityPage />} />
|
||||||
<Route path="/floor-map" element={<FloorMapPage />} />
|
<Route path="/floor-map" element={<FloorMapPage />} />
|
||||||
<Route path="/migration" element={<MigrationPage />} />
|
<Route path="/migration" element={<MigrationPage />} />
|
||||||
<Route path="/pos" element={<PosPage />} />
|
<Route path="/pos" element={<ModuleGuard moduleId="pos"><PosPage /></ModuleGuard>} />
|
||||||
<Route path="/reviews" element={<ReviewsPage />} />
|
<Route path="/reviews" element={<ModuleGuard moduleId="reviews"><ReviewsPage /></ModuleGuard>} />
|
||||||
<Route path="/room-service" element={<RoomServicePage />} />
|
<Route path="/room-service" element={<ModuleGuard moduleId="room-service"><RoomServicePage /></ModuleGuard>} />
|
||||||
<Route path="/rental" element={<RentalPage />} />
|
<Route path="/rental" element={<ModuleGuard moduleId="rental"><RentalPage /></ModuleGuard>} />
|
||||||
<Route path="/room-categories" element={<RoomCategoriesPage />} />
|
<Route path="/room-categories" element={<RoomCategoriesPage />} />
|
||||||
<Route path="/documents" element={<DocumentsPage />} />
|
<Route path="/documents" element={<DocumentsPage />} />
|
||||||
<Route path="/users" element={<UsersPage />} />
|
<Route path="/users" element={<UsersPage />} />
|
||||||
@@ -89,6 +91,7 @@ export default function App() {
|
|||||||
<Route path="/discounts" element={<DiscountsPage />} />
|
<Route path="/discounts" element={<DiscountsPage />} />
|
||||||
<Route path="/tv-welcome" element={<TvWelcomePage />} />
|
<Route path="/tv-welcome" element={<TvWelcomePage />} />
|
||||||
<Route path="/technical" element={<TechnicalPage />} />
|
<Route path="/technical" element={<TechnicalPage />} />
|
||||||
|
<Route path="/billing" element={<BillingPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
<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 {
|
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, CalendarClock, Zap, ChevronRight, Star,
|
TrendingUp, Tag, Award, Wrench, Utensils, CalendarClock, Zap, ChevronRight, Star, CreditCard,
|
||||||
} 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'
|
||||||
@@ -185,7 +185,7 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
|||||||
prices: ['/tariffs', '/dynamic-pricing', '/discounts', '/rental'],
|
prices: ['/tariffs', '/dynamic-pricing', '/discounts', '/rental'],
|
||||||
service: ['/housekeeping', '/technical', ...activeModuleItems.map(m => m.sidebarItem!.path)],
|
service: ['/housekeeping', '/technical', ...activeModuleItems.map(m => m.sidebarItem!.path)],
|
||||||
management: ['/users', '/schedule', '/loyalty', '/maintenance', '/floor-map', '/channels'],
|
management: ['/users', '/schedule', '/loyalty', '/maintenance', '/floor-map', '/channels'],
|
||||||
settingsGroup:['/modules', '/settings'],
|
settingsGroup:['/modules', '/settings', '/billing'],
|
||||||
devGroup: ['/api-docs'],
|
devGroup: ['/api-docs'],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,6 +412,7 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
|||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<NavItem to="/modules" icon={Puzzle} label="Модули" {...navItemProps} />
|
<NavItem to="/modules" icon={Puzzle} label="Модули" {...navItemProps} />
|
||||||
<NavItem to="/settings" icon={Settings} label="Настройки" {...navItemProps} />
|
<NavItem to="/settings" icon={Settings} label="Настройки" {...navItemProps} />
|
||||||
|
<NavItem to="/billing" icon={CreditCard} label="Тарифы и оплата" {...navItemProps} />
|
||||||
</div>
|
</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