Files
hotelsync/src/components/layout/Sidebar.tsx
HotelSync 78788f68b0 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>
2026-03-15 19:56:17 +03:00

201 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { NavLink, useNavigate } from 'react-router-dom'
import {
CalendarDays, BookOpen, BedDouble, Globe, Settings,
FileText, LayoutDashboard, Building2, Users, X, Hotel, Puzzle, CalendarRange, Map, LayoutGrid, UserCog, UsersRound,
TrendingUp, Tag, Award, Wrench, Utensils,
} from 'lucide-react'
import { useAuth } from '../../contexts/AuthContext'
import { useModules } from '../../contexts/ModulesContext'
import { MODULES_DATA } from '../../data/modulesData'
import { cn } from '../../lib/utils'
interface SidebarProps {
open: boolean
onClose: () => void
}
function NavItem({
to, icon: Icon, label, onClick,
}: {
to: string
icon: React.ElementType
label: string
onClick?: () => void
}) {
return (
<NavLink
to={to}
onClick={onClick}
className={({ isActive }) => cn('sidebar-link', isActive && 'active')}
>
<Icon size={18} />
<span>{label}</span>
</NavLink>
)
}
export function Sidebar({ open, onClose }: SidebarProps) {
const { user } = useAuth()
const { statuses } = useModules()
const navigate = useNavigate()
const isAdmin = user?.role === 'super_admin'
const isHousekeeper = user?.role === 'housekeeper'
const isModuleActive = (id: string) => statuses[id] === 'active' || statuses[id] === 'trial'
// Core modules with dedicated nav positions (not shown in generic module list)
const CORE_MODULE_IDS = ['channel-manager']
// Module items for the "Модули" sidebar section
const activeModuleItems = MODULES_DATA.filter(
m => m.sidebarItem &&
!m.sidebarItem.showForHousekeeper &&
!CORE_MODULE_IDS.includes(m.id) &&
isModuleActive(m.id),
)
return (
<>
{open && (
<div
className="fixed inset-0 bg-black/40 z-40 lg:hidden"
onClick={onClose}
/>
)}
<aside className={cn(
'fixed left-0 top-0 bottom-0 z-50 w-64 flex flex-col',
'bg-white dark:bg-slate-800',
'border-r border-slate-200 dark:border-slate-700',
'transition-transform duration-200 ease-out',
'lg:translate-x-0 lg:static lg:z-auto',
open ? 'translate-x-0' : '-translate-x-full',
)}>
{/* Logo */}
<div className="h-16 flex items-center justify-between px-4 border-b border-slate-200 dark:border-slate-700 shrink-0">
<div className="flex items-center gap-2.5 cursor-pointer" onClick={() => navigate('/')}>
<div className="w-8 h-8 rounded-lg bg-brand-600 flex items-center justify-center shrink-0">
<Hotel size={16} className="text-white" />
</div>
<span className="text-lg font-bold text-slate-900 dark:text-slate-100">
Hotel<span className="text-brand-600">Sync</span>
</span>
</div>
<button onClick={onClose} className="lg:hidden btn-ghost p-1.5">
<X size={16} />
</button>
</div>
{/* Hotel name */}
{!isAdmin && user?.hotelName && (
<div className="mx-3 mt-3 px-3 py-2.5 rounded-lg bg-slate-50 dark:bg-slate-700/50 border border-slate-200 dark:border-slate-600">
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium mb-0.5">Текущий отель</p>
<div className="flex items-center gap-1.5">
<Building2 size={14} className="text-brand-600 shrink-0" />
<span className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">
{user.hotelName}
</span>
</div>
</div>
)}
{/* Nav */}
<nav className="flex-1 overflow-y-auto px-3 py-3 space-y-0.5">
{isAdmin ? (
<>
<p className="px-3 pt-2 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
Администрирование
</p>
<NavItem to="/admin" icon={LayoutDashboard} label="Дашборд" onClick={onClose} />
<NavItem to="/admin/hotels" icon={Building2} label="Отели" onClick={onClose} />
<NavItem to="/admin/users" icon={Users} label="Пользователи" onClick={onClose} />
</>
) : (
<>
{/* ── Основное ── */}
<p className="px-3 pt-2 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
Основное
</p>
<NavItem to="/calendar" icon={CalendarDays} label="Шахматка" onClick={onClose} />
{!isHousekeeper && (
<>
<NavItem to="/bookings" icon={BookOpen} label="Бронирования" onClick={onClose} />
<NavItem to="/guests" icon={UsersRound} label="Гости" onClick={onClose} />
<NavItem to="/rooms" icon={BedDouble} label="Номера" onClick={onClose} />
<NavItem to="/room-categories" icon={LayoutGrid} 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} />
</>
)}
{/* ── Модули ── */}
{(isModuleActive('housekeeping') || (!isHousekeeper && activeModuleItems.length > 0)) && (
<p className="px-3 pt-3 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
Модули
</p>
)}
{isModuleActive('housekeeping') && (
<NavItem
to="/housekeeping"
icon={MODULES_DATA.find(m => m.id === 'housekeeping')!.sidebarItem!.icon}
label="Уборка"
onClick={onClose}
/>
)}
{!isHousekeeper && activeModuleItems.map(m => (
<NavItem
key={m.id}
to={m.sidebarItem!.path}
icon={m.sidebarItem!.icon}
label={m.sidebarItem!.label}
onClick={onClose}
/>
))}
{/* ── Управление ── */}
{!isHousekeeper && (
<>
<p className="px-3 pt-3 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
Управление
</p>
<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} />
{isModuleActive('channel-manager') && (
<NavItem
to="/channels"
icon={MODULES_DATA.find(m => m.id === 'channel-manager')!.sidebarItem!.icon}
label="Каналы"
onClick={onClose}
/>
)}
<NavItem to="/modules" icon={Puzzle} label="Модули" onClick={onClose} />
<NavItem to="/settings" icon={Settings} label="Настройки" onClick={onClose} />
<p className="px-3 pt-3 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
Разработчикам
</p>
<NavItem to="/api-docs" icon={FileText} label="API & Документация" onClick={onClose} />
</>
)}
</>
)}
</nav>
<div className="px-4 py-3 border-t border-slate-200 dark:border-slate-700 shrink-0">
<p className="text-xs text-slate-400 dark:text-slate-500">HotelSync v0.1.0 SaaS PMS</p>
</div>
</aside>
</>
)
}