From d661bbbc84d715ce2ea79a531acc1235615072b2 Mon Sep 17 00:00:00 2001
From: HotelSync
Date: Mon, 16 Mar 2026 20:13:22 +0300
Subject: [PATCH] Add role permissions, loyalty accrual rules, guest statuses,
and room service guest page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- UsersPage: add "Роли и права" tab with per-module permission editor per role; create/delete custom roles
- LoyaltyPage: add "Правила начисления" tab with togglable accrual rules editor; add "Ручное начисление" tab with guest search by name/phone/email, add/deduct points with reason and history; fix banner text (HotelSync is a PMS, not a hotel chain)
- GuestsPage: add "Статусы гостей" tab (moved from Settings)
- SettingsPage: remove Guests section; add compact calendar toggle in Appearance; toggle persists via localStorage
- BookingCalendar: read compact mode default from localStorage
- App.tsx: add public route /room-service/:slug → GuestRoomServicePage
- GuestRoomServicePage: new public guest-facing page for ordering room service with menu, cart, payment, and order status tracking
- RoomServicePage: QR/link sharing, payment mode toggles, new order notifications
- RentalPage/rentalData: buffer time between bookings (bufferMinutes field)
Co-Authored-By: Claude Sonnet 4.6
---
src/App.tsx | 2 +
src/components/calendar/BookingCalendar.tsx | 8 +-
src/data/rentalData.ts | 1 +
src/pages/GuestRoomServicePage.tsx | 420 ++++++++++
src/pages/GuestsPage.tsx | 118 ++-
src/pages/LoyaltyPage.tsx | 841 ++++++++++++++++----
src/pages/RentalPage.tsx | 29 +
src/pages/RoomServicePage.tsx | 162 +++-
src/pages/SettingsPage.tsx | 113 +--
src/pages/UsersPage.tsx | 795 +++++++++++++-----
10 files changed, 2002 insertions(+), 487 deletions(-)
create mode 100644 src/pages/GuestRoomServicePage.tsx
diff --git a/src/App.tsx b/src/App.tsx
index 0103b37..e56561c 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -35,6 +35,7 @@ import { LoyaltyPage } from './pages/LoyaltyPage'
import { MaintenancePage } from './pages/MaintenancePage'
import { DiscountsPage } from './pages/DiscountsPage'
import { GuestReviewPage } from './pages/GuestReviewPage'
+import { GuestRoomServicePage } from './pages/GuestRoomServicePage'
export default function App() {
return (
@@ -48,6 +49,7 @@ export default function App() {
{/* Public */}
} />
} />
+ } />
{/* App routes */}
}>
diff --git a/src/components/calendar/BookingCalendar.tsx b/src/components/calendar/BookingCalendar.tsx
index 29369f1..0c66fc7 100644
--- a/src/components/calendar/BookingCalendar.tsx
+++ b/src/components/calendar/BookingCalendar.tsx
@@ -74,8 +74,10 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
const [dragStart, setDragStart] = useState<{ roomId: string; dayIdx: number } | null>(null)
const [dragEnd, setDragEnd] = useState(null)
- // Compact mode
- const [compact, setCompact] = useState(false)
+ // Compact mode (default from Settings → Appearance)
+ const [compact, setCompact] = useState(
+ () => localStorage.getItem('calendarCompact') === 'true',
+ )
const rowHeight = compact ? ROW_HEIGHT_COMPACT : ROW_HEIGHT
// Modals
@@ -253,7 +255,7 @@ export function BookingCalendar({ rooms, bookings, onBookingCreate, onBookingUpd
setCompact(v => !v)}
+ onClick={() => setCompact(v => { const next = !v; localStorage.setItem('calendarCompact', String(next)); return next })}
title={compact ? 'Обычный вид' : 'Компактный вид'}
className={cn(
'btn-ghost flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium',
diff --git a/src/data/rentalData.ts b/src/data/rentalData.ts
index 6796b7e..908389d 100644
--- a/src/data/rentalData.ts
+++ b/src/data/rentalData.ts
@@ -11,6 +11,7 @@ export interface RentalObject {
openHour: number // 8
closeHour: number // 22
maxHoursPerSlot?: number
+ bufferMinutes?: number // tech break between bookings
}
export interface RentalBooking {
diff --git a/src/pages/GuestRoomServicePage.tsx b/src/pages/GuestRoomServicePage.tsx
new file mode 100644
index 0000000..9e4b1e3
--- /dev/null
+++ b/src/pages/GuestRoomServicePage.tsx
@@ -0,0 +1,420 @@
+import { useState } from 'react'
+import { useParams } from 'react-router-dom'
+import {
+ ShoppingCart, Plus, Minus, X, Clock, CheckCircle2,
+ ChefHat, Truck, CreditCard, Receipt, ArrowLeft,
+} from 'lucide-react'
+import { cn, formatCurrency } from '../lib/utils'
+
+// ── Types ─────────────────────────────────────────────────────────────────────
+
+interface MenuItem {
+ id: string
+ name: string
+ category: string
+ price: number
+ emoji: string
+ time: number
+ popular?: boolean
+ stopList?: boolean
+}
+
+type Step = 'menu' | 'cart' | 'payment' | 'status'
+type OrderStatus = 'new' | 'preparing' | 'ready' | 'delivered'
+
+// ── Mock data (same menu as staff side) ───────────────────────────────────────
+
+const MENU: MenuItem[] = [
+ { id: 'm1', name: 'Яичница с беконом', category: 'Завтраки', price: 450, emoji: '🍳', time: 15, popular: true },
+ { id: 'm2', name: 'Омлет с овощами', category: 'Завтраки', price: 380, emoji: '🥚', time: 12 },
+ { id: 'm3', name: 'Сырники', category: 'Завтраки', price: 320, emoji: '🧀', time: 10 },
+ { id: 'm4', name: 'Овсяная каша', category: 'Завтраки', price: 250, emoji: '🥣', time: 8 },
+ { id: 'm5', name: 'Стейк рибай', category: 'Основные', price: 1800, emoji: '🥩', time: 25, popular: true },
+ { id: 'm6', name: 'Куриная грудка', category: 'Основные', price: 750, emoji: '🍗', time: 20 },
+ { id: 'm7', name: 'Паста карбонара', category: 'Основные', price: 650, emoji: '🍝', time: 18 },
+ { id: 'm8', name: 'Лосось на гриле', category: 'Основные', price: 1200, emoji: '🐟', time: 22 },
+ { id: 'm9', name: 'Цезарь с курицей', category: 'Закуски', price: 550, emoji: '🥗', time: 10, popular: true },
+ { id: 'm10', name: 'Сэндвич клуб', category: 'Закуски', price: 420, emoji: '🥪', time: 8 },
+ { id: 'm11', name: 'Сырная тарелка', category: 'Закуски', price: 680, emoji: '🧀', time: 5 },
+ { id: 'm12', name: 'Кофе американо', category: 'Напитки', price: 180, emoji: '☕', time: 5, popular: true },
+ { id: 'm13', name: 'Капучино', category: 'Напитки', price: 220, emoji: '☕', time: 6 },
+ { id: 'm14', name: 'Свежевыжатый сок', category: 'Напитки', price: 280, emoji: '🍊', time: 5 },
+ { id: 'm15', name: 'Вино красное бокал', category: 'Напитки', price: 450, emoji: '🍷', time: 3 },
+ { id: 'm16', name: 'Тирамису', category: 'Десерты', price: 380, emoji: '🍮', time: 3 },
+ { id: 'm17', name: 'Чизкейк', category: 'Десерты', price: 350, emoji: '🍰', time: 3 },
+]
+
+const CATEGORIES = [...new Set(MENU.map(i => i.category))]
+
+const HOTEL_CONFIG: Record = {
+ 'grand-palace': { name: 'Гранд Палас', color: '#2563eb', serviceChargePct: 15 },
+}
+const DEFAULT_CONFIG = { name: 'Отель', color: '#2563eb', serviceChargePct: 15 }
+
+// ── Status display ─────────────────────────────────────────────────────────────
+
+const STATUS_STEPS: { key: OrderStatus; label: string; icon: React.ElementType; desc: string }[] = [
+ { key: 'new', label: 'Принят', icon: Receipt, desc: 'Заказ отправлен на кухню' },
+ { key: 'preparing', label: 'Готовится', icon: ChefHat, desc: 'Повар готовит ваш заказ' },
+ { key: 'ready', label: 'Готов', icon: CheckCircle2, desc: 'Заказ готов, передаём курьеру' },
+ { key: 'delivered', label: 'Доставлен', icon: Truck, desc: 'Курьер в пути к вашему номеру' },
+]
+
+// ── Component ─────────────────────────────────────────────────────────────────
+
+export function GuestRoomServicePage() {
+ const { slug } = useParams<{ slug: string }>()
+ const config = (slug ? HOTEL_CONFIG[slug] : null) ?? DEFAULT_CONFIG
+
+ const [category, setCategory] = useState(CATEGORIES[0])
+ const [cart, setCart] = useState>({})
+ const [step, setStep] = useState('menu')
+ const [roomNumber, setRoomNumber] = useState('')
+ const [notes, setNotes] = useState('')
+ const [payMode, setPayMode] = useState<'online' | 'bill'>('online')
+ const [orderStatus, setOrderStatus] = useState('new')
+
+ const cartItems = MENU.filter(i => (cart[i.id] ?? 0) > 0)
+ const subtotal = cartItems.reduce((s, i) => s + i.price * cart[i.id], 0)
+ const svcCharge = Math.round(subtotal * config.serviceChargePct / 100)
+ const total = subtotal + svcCharge
+ const cartCount = Object.values(cart).reduce((s, n) => s + n, 0)
+
+ const add = (id: string) => setCart(p => ({ ...p, [id]: (p[id] ?? 0) + 1 }))
+ const sub = (id: string) => setCart(p => { const n = Math.max(0, (p[id] ?? 0) - 1); return { ...p, [id]: n } })
+ const canOrder = cartCount > 0 && roomNumber.trim().length > 0
+
+ const placeOrder = () => {
+ // Simulate status progression
+ setStep('status')
+ setOrderStatus('new')
+ setTimeout(() => setOrderStatus('preparing'), 3000)
+ setTimeout(() => setOrderStatus('ready'), 8000)
+ setTimeout(() => setOrderStatus('delivered'), 13000)
+ }
+
+ const currentStatusIdx = STATUS_STEPS.findIndex(s => s.key === orderStatus)
+
+ return (
+
+
+ {/* Header */}
+
+
+ {step !== 'menu' && step !== 'status' && (
+
setStep(step === 'payment' ? 'cart' : 'menu')} className="text-white/80 hover:text-white">
+
+
+ )}
+
+
{config.name}
+
Room Service
+
+ {step === 'menu' && cartCount > 0 && (
+
setStep('cart')}
+ className="flex items-center gap-2 bg-white/20 hover:bg-white/30 text-white px-3 py-1.5 rounded-xl text-sm font-medium transition-colors"
+ >
+
+ {cartCount}
+ {formatCurrency(total)}
+
+ )}
+
+
+
+
+
+ {/* ── MENU ── */}
+ {step === 'menu' && (
+
+ {/* Category tabs */}
+
+ {CATEGORIES.map(cat => (
+ setCategory(cat)}
+ className={cn(
+ 'shrink-0 px-3 py-1.5 rounded-full text-sm font-medium border transition-colors',
+ category === cat
+ ? 'text-white border-transparent'
+ : 'bg-white text-slate-700 border-slate-200',
+ )}
+ style={category === cat ? { backgroundColor: config.color, borderColor: config.color } : {}}
+ >
+ {cat}
+
+ ))}
+
+
+ {/* Items */}
+
+ {MENU.filter(i => i.category === category && !i.stopList).map(item => (
+
+
{item.emoji}
+
+
+
{item.name}
+ {item.popular && (
+
Хит
+ )}
+
+
+ {item.time} мин
+
+
{formatCurrency(item.price)}
+
+ {/* Counter */}
+ {(cart[item.id] ?? 0) === 0 ? (
+
add(item.id)}
+ className="w-9 h-9 rounded-full flex items-center justify-center text-white shadow-md transition-transform active:scale-90"
+ style={{ backgroundColor: config.color }}
+ >
+
+
+ ) : (
+
+
sub(item.id)} className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center text-slate-700">
+
+
+
{cart[item.id]}
+
add(item.id)}
+ className="w-8 h-8 rounded-full flex items-center justify-center text-white"
+ style={{ backgroundColor: config.color }}
+ >
+
+
+
+ )}
+
+ ))}
+
+
+ {/* Sticky cart button */}
+ {cartCount > 0 && (
+
+ setStep('cart')}
+ className="w-full py-4 rounded-2xl text-white font-bold text-base shadow-xl transition-opacity hover:opacity-90 active:scale-95"
+ style={{ backgroundColor: config.color }}
+ >
+ Перейти к заказу · {formatCurrency(total)}
+
+
+ )}
+
+ )}
+
+ {/* ── CART ── */}
+ {step === 'cart' && (
+
+
Ваш заказ
+
+
+ {cartItems.map((item, i) => (
+
+
{item.emoji}
+
+
{item.name}
+
{formatCurrency(item.price)} × {cart[item.id]}
+
+
+
sub(item.id)} className="w-7 h-7 rounded-full bg-slate-100 flex items-center justify-center text-slate-600">
+ {cart[item.id] === 1 ? : }
+
+
{cart[item.id]}
+
add(item.id)}
+ className="w-7 h-7 rounded-full flex items-center justify-center text-white"
+ style={{ backgroundColor: config.color }}
+ >
+
+
+
+
{formatCurrency(item.price * cart[item.id])}
+
+ ))}
+
+
+
+ Сумма заказа
+ {formatCurrency(subtotal)}
+
+
+ Обслуживание {config.serviceChargePct}%
+ +{formatCurrency(svcCharge)}
+
+
+ Итого
+ {formatCurrency(total)}
+
+
+
+
+ {/* Room number */}
+
+
+ Номер комнаты *
+ setRoomNumber(e.target.value)}
+ />
+
+
+ Пожелания (необязательно)
+
+
+
+
setStep('payment')}
+ disabled={!canOrder}
+ className="w-full py-4 rounded-2xl text-white font-bold text-base disabled:opacity-40 transition-opacity hover:opacity-90"
+ style={{ backgroundColor: config.color }}
+ >
+ К оплате
+
+
+ )}
+
+ {/* ── PAYMENT ── */}
+ {step === 'payment' && (
+
+
Способ оплаты
+
+ {[
+ { key: 'online' as const, icon: CreditCard, label: 'Оплатить онлайн сейчас', desc: 'Банковской картой через защищённую форму' },
+ { key: 'bill' as const, icon: Receipt, label: 'Включить в счёт при выезде', desc: 'Оплата при расчёте в конце проживания' },
+ ].map(opt => (
+
setPayMode(opt.key)}
+ className={cn(
+ 'w-full flex items-center gap-4 p-4 rounded-2xl border-2 text-left bg-white transition-colors',
+ payMode === opt.key ? 'border-blue-500' : 'border-slate-200',
+ )}
+ style={payMode === opt.key ? { borderColor: config.color } : {}}
+ >
+
+
+
+
+
{opt.label}
+
{opt.desc}
+
+
+ {payMode === opt.key && (
+
+ )}
+
+
+ ))}
+
+ {/* Summary */}
+
+
Номер {roomNumber}
+ {cartItems.map(i => (
+
+ {i.emoji} {i.name} ×{cart[i.id]}
+ {formatCurrency(i.price * cart[i.id])}
+
+ ))}
+
+ Итого
+ {formatCurrency(total)}
+
+
+
+
+ {payMode === 'online' ? `Оплатить ${formatCurrency(total)}` : 'Оформить заказ'}
+
+
+ )}
+
+ {/* ── STATUS ── */}
+ {step === 'status' && (
+
+
+
+ 🎉
+
+
Заказ принят!
+
Номер {roomNumber} · {formatCurrency(total)}
+
+
+ {/* Progress */}
+
+
Статус заказа
+
+ {STATUS_STEPS.map((s, idx) => {
+ const done = idx <= currentStatusIdx
+ const current = idx === currentStatusIdx
+ const Icon = s.icon
+ return (
+
+
+
+
+
+
+ {s.label}
+
+ {current &&
{s.desc}
}
+
+ {current && (
+
+ {[0,1,2].map(i => (
+
+ ))}
+
+ )}
+
+ )
+ })}
+
+
+
+
{ setCart({}); setStep('menu'); setRoomNumber(''); setNotes('') }}
+ className="w-full py-3 rounded-2xl border-2 text-sm font-semibold transition-colors"
+ style={{ borderColor: config.color, color: config.color }}
+ >
+ Сделать ещё заказ
+
+
+ )}
+
+
+ )
+}
diff --git a/src/pages/GuestsPage.tsx b/src/pages/GuestsPage.tsx
index cd1b8af..f4e8833 100644
--- a/src/pages/GuestsPage.tsx
+++ b/src/pages/GuestsPage.tsx
@@ -2,7 +2,7 @@ import { useState } from 'react'
import {
Search, Star, Phone, Mail, User, TrendingUp, Award, Users,
Calendar, CreditCard, X, ChevronRight, MessageSquare, Tag,
- Repeat2, ShieldCheck,
+ Repeat2, ShieldCheck, Plus,
} from 'lucide-react'
import { cn } from '../lib/utils'
@@ -443,7 +443,100 @@ function GuestModal({ guest, onClose }: { guest: Guest; onClose: () => void }) {
// ─── Main Page ────────────────────────────────────────────────────────────────
+// ─── Guest Statuses Tab ───────────────────────────────────────────────────────
+
+function GuestStatusesTab() {
+ const [tags, setTags] = useState([
+ { id: 'vip', label: 'VIP', color: '#F59E0B' },
+ { id: 'regular', label: 'Постоянный гость', color: '#3B82F6' },
+ { id: 'corp', label: 'Корпоративный', color: '#8B5CF6' },
+ { id: 'honey', label: 'Медовый месяц', color: '#EC4899' },
+ { id: 'bday', label: 'День рождения', color: '#10B981' },
+ { id: 'special', label: 'Особые пожелания', color: '#64748B' },
+ { id: 'problem', label: 'Проблемный', color: '#EF4444' },
+ ])
+ const [newLabel, setNewLabel] = useState('')
+ const [newColor, setNewColor] = useState('#4F46E5')
+
+ const addTag = () => {
+ if (!newLabel.trim()) return
+ setTags(prev => [...prev, { id: `tag-${Date.now()}`, label: newLabel.trim(), color: newColor }])
+ setNewLabel('')
+ }
+
+ return (
+
+
+ Статусы отображаются в карточках гостей и при создании бронирования. Они помогают быстро идентифицировать тип гостя.
+
+
+ {/* Tag list */}
+
+
+ {/* Add new tag */}
+
+
+ )
+}
+
+// ─── Page ─────────────────────────────────────────────────────────────────────
+
+const PAGE_TABS = [
+ { id: 'list', label: 'База гостей' },
+ { id: 'statuses', label: 'Статусы гостей' },
+] as const
+type PageTab = typeof PAGE_TABS[number]['id']
+
export function GuestsPage() {
+ const [pageTab, setPageTab] = useState('list')
const [search, setSearch] = useState('')
const [filterTag, setFilterTag] = useState('all')
const [selectedGuest, setSelectedGuest] = useState(null)
@@ -472,6 +565,28 @@ export function GuestsPage() {
+ {/* Tabs */}
+
+ {PAGE_TABS.map(t => (
+ setPageTab(t.id)}
+ className={cn(
+ 'px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors',
+ pageTab === 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}
+
+ ))}
+
+
+ {pageTab === 'statuses' && }
+
+ {pageTab === 'list' && <>
+
{/* Stats */}
{[
@@ -641,6 +756,7 @@ export function GuestsPage() {
{selectedGuest && (
setSelectedGuest(null)} />
)}
+ >}
)
}
diff --git a/src/pages/LoyaltyPage.tsx b/src/pages/LoyaltyPage.tsx
index 7b412b6..6337cf4 100644
--- a/src/pages/LoyaltyPage.tsx
+++ b/src/pages/LoyaltyPage.tsx
@@ -2,7 +2,7 @@ import { useState } from 'react'
import {
Award, Star, Gift, Percent, CreditCard, Edit2, Save, Info,
Crown, Shield, User, ChevronRight, Smartphone, Mail, QrCode,
- TrendingUp,
+ TrendingUp, Search, Plus, Minus, Check, AlertCircle, Phone,
} from 'lucide-react'
import { cn } from '../lib/utils'
@@ -15,29 +15,51 @@ interface LoyaltyLevel {
name: string
minPoints: number
maxPoints: number | null
- discount: number // % discount on stay
- bonusPercent: number // % of spent amount returned as points
+ discount: number
+ bonusPercent: number
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
+ pointsPerRuble: number
+ pointValue: number
+ expiryMonths: number
isActive: boolean
}
+interface AccrualRule {
+ id: string
+ label: string
+ description: string
+ type: 'per_ruble' | 'per_night' | 'flat'
+ value: number
+ unit: string
+ enabled: boolean
+}
+
interface GuestPoints {
id: string
name: string
email: string
+ phone?: string
points: number
totalSpent: number
level: LevelId
}
+interface ManualTransaction {
+ id: string
+ guestId: string
+ guestName: string
+ amount: number
+ reason: string
+ notes: string
+ date: string
+ staff: string
+}
+
// ─── Mock Data ────────────────────────────────────────────────────────────────
const DEFAULT_LEVELS: LoyaltyLevel[] = [
@@ -94,14 +116,100 @@ const DEFAULT_SETTINGS: ProgramSettings = {
isActive: true,
}
+const INITIAL_ACCRUAL_RULES: AccrualRule[] = [
+ {
+ id: 'stay',
+ label: 'Проживание',
+ description: 'Баллы за каждую потраченную сумму на проживание',
+ type: 'per_ruble',
+ value: 1,
+ unit: 'балл / 1 ₽',
+ enabled: true,
+ },
+ {
+ id: 'night',
+ label: 'Ночи проживания',
+ description: 'Фиксированный бонус за каждую ночь',
+ type: 'per_night',
+ value: 50,
+ unit: 'баллов / ночь',
+ enabled: false,
+ },
+ {
+ id: 'room_service',
+ label: 'Сервис в номер',
+ description: 'Баллы за заказы еды и услуг в номер',
+ type: 'per_ruble',
+ value: 1,
+ unit: 'балл / 1 ₽',
+ enabled: true,
+ },
+ {
+ id: 'pos',
+ label: 'Ресторан и кафе (POS)',
+ description: 'Баллы за покупки через кассовый терминал',
+ type: 'per_ruble',
+ value: 1,
+ unit: 'балл / 1 ₽',
+ enabled: true,
+ },
+ {
+ id: 'rental',
+ label: 'Аренда объектов',
+ description: 'Баллы за аренду корта, бани, конференц-зала',
+ type: 'per_ruble',
+ value: 0.5,
+ unit: 'балла / 1 ₽',
+ enabled: true,
+ },
+ {
+ id: 'birthday',
+ label: 'День рождения',
+ description: 'Единоразовый бонус в день рождения гостя',
+ type: 'flat',
+ value: 200,
+ unit: 'баллов',
+ enabled: true,
+ },
+ {
+ id: 'direct',
+ label: 'Прямое бронирование',
+ description: 'Бонус за бронирование напрямую (не через OTA)',
+ type: 'flat',
+ value: 100,
+ unit: 'баллов',
+ enabled: true,
+ },
+ {
+ id: 'early',
+ label: 'Ранее бронирование (30+ дней)',
+ description: 'Бонус за бронирование заранее за 30 и более дней',
+ type: 'flat',
+ value: 150,
+ unit: 'баллов',
+ enabled: false,
+ },
+]
+
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' },
+ { id: 'g-1', name: 'Александр Петров', email: 'a.petrov@gmail.com', phone: '+7 999 123 45 67', points: 16250, totalSpent: 287400, level: 'platinum' },
+ { id: 'g-4', name: 'Елена Новикова', email: 'e.novikova@gmail.com', phone: '+7 912 000 11 22', points: 8100, totalSpent: 76500, level: 'gold' },
+ { id: 'g-6', name: 'Анна Лебедева', email: 'anna.l@mail.ru', phone: '+7 926 333 44 55', points: 3950, totalSpent: 132000, level: 'silver' },
+ { id: 'g-2', name: 'Мария Сидорова', email: 'm.sidorova@mail.ru', phone: '+7 903 555 66 77', points: 2200, totalSpent: 98200, level: 'silver' },
+ { id: 'g-5', name: 'Игорь Волков', email: 'i.volkov@corp.ru', phone: '+7 977 888 99 00', 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: '', phone: '+7 916 100 20 30', points: 120, totalSpent: 8400, level: 'bronze' },
+]
+
+const MANUAL_REASONS = [
+ 'Компенсация (жалоба гостя)',
+ 'Бонус за отзыв',
+ 'Ошибка начисления (корректировка)',
+ 'Акция / спецпредложение',
+ 'День рождения',
+ 'VIP-бонус',
+ 'Снятие (нарушение правил)',
+ 'Другое',
]
function levelBadge(levelId: LevelId) {
@@ -236,7 +344,6 @@ function GuestPortalPreview({ settings }: { settings: ProgramSettings }) {
- {/* Points balance */}
Накопленные баллы
{guest.points.toLocaleString('ru-RU')}
@@ -245,7 +352,6 @@ function GuestPortalPreview({ settings }: { settings: ProgramSettings }) {
- {/* Progress to next level */}
{nextLevel && (
@@ -261,7 +367,6 @@ function GuestPortalPreview({ settings }: { settings: ProgramSettings }) {
)}
- {/* Quick perks */}
{[
{ icon: Percent, label: `${level.discount}% скидка` },
@@ -287,9 +392,417 @@ function GuestPortalPreview({ settings }: { settings: ProgramSettings }) {
)
}
+// ─── Accrual Rules Tab ────────────────────────────────────────────────────────
+
+function AccrualRulesTab() {
+ const [rules, setRules] = useState
(INITIAL_ACCRUAL_RULES)
+ const [editingId, setEditingId] = useState(null)
+ const [editValue, setEditValue] = useState(0)
+
+ const toggleRule = (id: string) => {
+ setRules(prev => prev.map(r => r.id === id ? { ...r, enabled: !r.enabled } : r))
+ }
+
+ const startEdit = (rule: AccrualRule) => {
+ setEditingId(rule.id)
+ setEditValue(rule.value)
+ }
+
+ const saveEdit = (id: string) => {
+ setRules(prev => prev.map(r => r.id === id ? { ...r, value: editValue } : r))
+ setEditingId(null)
+ }
+
+ return (
+
+
+
+
+ Правила определяют, сколько баллов начисляется гостям за различные действия. Баллы начисляются автоматически при совершении соответствующей операции.
+
+
+
+
+ {rules.map(rule => (
+
+
+ {/* Toggle */}
+
toggleRule(rule.id)}
+ className={cn(
+ 'relative w-10 h-5 rounded-full transition-colors shrink-0 mt-0.5',
+ rule.enabled ? 'bg-brand-600' : 'bg-slate-200 dark:bg-slate-600',
+ )}
+ >
+
+
+
+ {/* Info */}
+
+
+
+
{rule.label}
+
{rule.description}
+
+
+ {/* Value editor */}
+
+ {editingId === rule.id ? (
+ <>
+ setEditValue(parseFloat(e.target.value) || 0)}
+ autoFocus
+ />
+ {rule.unit}
+ saveEdit(rule.id)}
+ className="p-1.5 rounded-lg bg-brand-600 text-white hover:bg-brand-700 transition-colors"
+ >
+
+
+ >
+ ) : (
+ <>
+
+ {rule.value} {rule.unit}
+
+ startEdit(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"
+ disabled={!rule.enabled}
+ >
+
+
+ >
+ )}
+
+
+
+ {/* Type badge */}
+
+
+ {rule.type === 'per_ruble' ? 'За сумму' : rule.type === 'per_night' ? 'За ночь' : 'Разовый'}
+
+
+
+
+
+ ))}
+
+
+ )
+}
+
+// ─── Manual Accrual Tab ───────────────────────────────────────────────────────
+
+function ManualAccrualTab() {
+ const [guests, setGuests] = useState(MOCK_GUESTS)
+ const [search, setSearch] = useState('')
+ const [selectedGuest, setSelectedGuest] = useState(null)
+ const [mode, setMode] = useState<'add' | 'subtract'>('add')
+ const [amount, setAmount] = useState('')
+ const [reason, setReason] = useState('')
+ const [notes, setNotes] = useState('')
+ const [transactions, setTransactions] = useState([])
+ const [success, setSuccess] = useState(false)
+
+ const searchResults = search.trim()
+ ? guests.filter(g =>
+ g.name.toLowerCase().includes(search.toLowerCase()) ||
+ g.email.toLowerCase().includes(search.toLowerCase()) ||
+ (g.phone ?? '').replace(/\D/g, '').includes(search.replace(/\D/g, ''))
+ )
+ : []
+
+ const handleSubmit = () => {
+ if (!selectedGuest || !amount || !reason) return
+ const pts = parseInt(amount)
+ if (isNaN(pts) || pts <= 0) return
+
+ const delta = mode === 'add' ? pts : -pts
+ setGuests(prev => prev.map(g =>
+ g.id === selectedGuest.id
+ ? { ...g, points: Math.max(0, g.points + delta) }
+ : g
+ ))
+
+ const tx: ManualTransaction = {
+ id: `mt-${Date.now()}`,
+ guestId: selectedGuest.id,
+ guestName: selectedGuest.name,
+ amount: delta,
+ reason,
+ notes,
+ date: new Date().toLocaleDateString('ru-RU'),
+ staff: 'Менеджер',
+ }
+ setTransactions(prev => [tx, ...prev])
+
+ // Update selected guest preview
+ setSelectedGuest(prev => prev
+ ? { ...prev, points: Math.max(0, prev.points + delta) }
+ : null
+ )
+
+ setAmount('')
+ setNotes('')
+ setReason('')
+ setSuccess(true)
+ setTimeout(() => setSuccess(false), 3000)
+ }
+
+ const updatedGuest = selectedGuest
+ ? guests.find(g => g.id === selectedGuest.id) ?? selectedGuest
+ : null
+
+ return (
+
+ {/* Left: search + form */}
+
+ {/* Guest search */}
+
+
Поиск гостя
+
+
+ { setSearch(e.target.value); setSelectedGuest(null) }}
+ />
+
+
+ {/* Search results dropdown */}
+ {searchResults.length > 0 && !selectedGuest && (
+
+ {searchResults.map(g => (
+
{ setSelectedGuest(g); setSearch(g.name) }}
+ className="w-full flex items-center justify-between px-3 py-2.5 hover:bg-slate-50 dark:hover:bg-slate-700/40 transition-colors text-left"
+ >
+
+
{g.name}
+
+ {g.phone ?? g.email ?? '—'}
+
+
+
+ {levelBadge(g.level)}
+
+ {g.points.toLocaleString('ru-RU')} б.
+
+
+
+ ))}
+
+ )}
+
+ {search.trim() && searchResults.length === 0 && (
+
Гости не найдены
+ )}
+
+ {/* Selected guest card */}
+ {selectedGuest && updatedGuest && (
+
+
+
+
+ {selectedGuest.name.split(' ').map(p => p[0]).join('').slice(0, 2)}
+
+
+
+
{selectedGuest.name}
+
{selectedGuest.email || selectedGuest.phone || '—'}
+
+
+
+
Текущий баланс
+
+ {updatedGuest.points.toLocaleString('ru-RU')} б.
+
+
+
+ )}
+
+
+ {/* Operation form */}
+ {selectedGuest && (
+
+
Операция с баллами
+
+ {/* Add / Subtract toggle */}
+
+
setMode('add')}
+ className={cn(
+ 'flex-1 flex items-center justify-center gap-2 py-2.5 rounded-xl border font-medium text-sm transition-colors',
+ mode === 'add'
+ ? 'bg-emerald-600 border-emerald-600 text-white'
+ : 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300',
+ )}
+ >
+
+ Начислить баллы
+
+
setMode('subtract')}
+ className={cn(
+ 'flex-1 flex items-center justify-center gap-2 py-2.5 rounded-xl border font-medium text-sm transition-colors',
+ mode === 'subtract'
+ ? 'bg-red-600 border-red-600 text-white'
+ : 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300',
+ )}
+ >
+
+ Снять баллы
+
+
+
+ {/* Amount */}
+
+
+ Количество баллов
+
+
setAmount(e.target.value)}
+ />
+ {amount && !isNaN(parseInt(amount)) && selectedGuest && (
+
+ Баланс после операции:{' '}
+
+ {Math.max(0, (updatedGuest?.points ?? 0) + (mode === 'add' ? 1 : -1) * (parseInt(amount) || 0)).toLocaleString('ru-RU')} б.
+
+
+ )}
+
+
+ {/* Reason */}
+
+
+ Причина *
+
+ setReason(e.target.value)}>
+ Выберите причину...
+ {MANUAL_REASONS.map(r => {r} )}
+
+
+
+ {/* Notes */}
+
+
+ Комментарий
+
+
+
+ {success && (
+
+
+
Операция выполнена успешно
+
+ )}
+
+
+ {mode === 'add' ? 'Начислить баллы' : 'Снять баллы'}
+
+
+ )}
+
+
+ {/* Right: recent transactions */}
+
+
+
+
История ручных операций
+
+ {transactions.length === 0 ? (
+
+ ) : (
+
+ {transactions.map(tx => (
+
+
+
+
{tx.guestName}
+
{tx.reason}
+ {tx.notes &&
{tx.notes}
}
+
+
+
0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400',
+ )}>
+ {tx.amount > 0 ? '+' : ''}{tx.amount.toLocaleString('ru-RU')} б.
+
+
{tx.date} · {tx.staff}
+
+
+
+ ))}
+
+ )}
+
+
+
+ )
+}
+
// ─── Main Page ────────────────────────────────────────────────────────────────
+const PAGE_TABS = [
+ { id: 'overview', label: 'Обзор' },
+ { id: 'accrual', label: 'Правила начисления' },
+ { id: 'manual', label: 'Ручное начисление' },
+] as const
+type PageTab = typeof PAGE_TABS[number]['id']
+
export function LoyaltyPage() {
+ const [tab, setTab] = useState('overview')
const [levels, setLevels] = useState(DEFAULT_LEVELS)
const [settings, setSettings] = useState(DEFAULT_SETTINGS)
const [editSettings, setEditSettings] = useState(false)
@@ -302,7 +815,7 @@ export function LoyaltyPage() {
const totalPoints = MOCK_GUESTS.reduce((s, g) => s + g.points, 0)
return (
-
+
{/* Header */}
@@ -326,10 +839,10 @@ export function LoyaltyPage() {
{/* Stats */}
{[
- { 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: 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' },
+ { 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 => (
@@ -343,148 +856,154 @@ export function LoyaltyPage() {
))}
-
-
- {/* Program settings */}
-
-
-
Настройки программы
- setEditSettings(!editSettings)} className="btn-secondary flex items-center gap-1.5 text-sm">
- {editSettings ? <> Сохранить> : <> Изменить>}
-
-
-
- {[
- {
- 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 => (
-
-
{item.label}
- {editSettings ? (
-
- setSettings(prev => ({
- ...prev,
- [item.field]: parseFloat(e.target.value) || 0,
- }))}
- />
- {item.unit}
-
- ) : (
-
{item.desc}
- )}
-
- ))}
-
-
-
- {/* Level cards */}
-
-
- Уровни программы
-
-
- {levels.map(level => (
-
- ))}
-
-
-
- {/* Top guests */}
-
-
-
Топ участников
-
-
-
-
- Гость
- Уровень
- Баллы
- Потрачено
-
-
-
- {MOCK_GUESTS.sort((a, b) => b.points - a.points).map(g => (
-
-
-
-
-
- {g.name.split(' ').map(p => p[0]).join('').slice(0, 2)}
-
-
-
{g.name}
-
-
- {levelBadge(g.level)}
-
-
- {g.points.toLocaleString('ru-RU')}
-
-
-
-
- {g.totalSpent.toLocaleString('ru-RU')} ₽
-
-
-
- ))}
-
-
-
-
-
- {/* Right column */}
-
- {/* Guest portal preview */}
-
-
- {/* Redemption info */}
-
-
Как работает программа
- {[
- { icon: CreditCard, text: 'При каждом бронировании гостю начисляются баллы пропорционально потраченной сумме' },
- { icon: Percent, text: 'Накопленные баллы можно списать при следующем бронировании' },
- { icon: Mail, text: 'После первого визита гость получает email с ссылкой на личный кабинет' },
- { icon: Award, text: 'При достижении нового уровня приходит уведомление с перечнем привилегий' },
- ].map((item, i) => (
-
- ))}
-
-
-
-
-
- В будущих версиях баллы будут работать во всех отелях сети HotelSync — единая программа для всего бренда.
-
-
-
+ {/* Tabs */}
+
+ {PAGE_TABS.map(t => (
+ 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}
+
+ ))}
+
+ {tab === 'overview' && (
+
+
+ {/* Program settings */}
+
+
+
Настройки программы
+ setEditSettings(!editSettings)} className="btn-secondary flex items-center gap-1.5 text-sm">
+ {editSettings ? <> Сохранить> : <> Изменить>}
+
+
+
+ {[
+ { 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 => (
+
+
{item.label}
+ {editSettings ? (
+
+ setSettings(prev => ({
+ ...prev,
+ [item.field]: parseFloat(e.target.value) || 0,
+ }))}
+ />
+ {item.unit}
+
+ ) : (
+
{item.desc}
+ )}
+
+ ))}
+
+
+
+ {/* Level cards */}
+
+
+ Уровни программы
+
+
+ {levels.map(level => (
+
+ ))}
+
+
+
+ {/* Top guests */}
+
+
+
Топ участников
+
+
+
+
+ Гость
+ Уровень
+ Баллы
+ Потрачено
+
+
+
+ {MOCK_GUESTS.sort((a, b) => b.points - a.points).map(g => (
+
+
+
+
+
+ {g.name.split(' ').map(p => p[0]).join('').slice(0, 2)}
+
+
+
{g.name}
+
+
+ {levelBadge(g.level)}
+
+
+ {g.points.toLocaleString('ru-RU')}
+
+
+
+
+ {g.totalSpent.toLocaleString('ru-RU')} ₽
+
+
+
+ ))}
+
+
+
+
+
+ {/* Right column */}
+
+
+
+
+
Как работает программа
+ {[
+ { icon: CreditCard, text: 'При каждом бронировании гостю начисляются баллы пропорционально потраченной сумме' },
+ { icon: Percent, text: 'Накопленные баллы можно списать при следующем бронировании' },
+ { icon: Mail, text: 'После первого визита гость получает email с ссылкой на личный кабинет' },
+ { icon: Award, text: 'При достижении нового уровня приходит уведомление с перечнем привилегий' },
+ ].map((item, i) => (
+
+ ))}
+
+
+
+
+
+ Программа лояльности работает в рамках вашего отеля. Баллы гостей хранятся в HotelSync и привязаны к конкретному объекту размещения.
+
+
+
+
+ )}
+
+ {tab === 'accrual' &&
}
+ {tab === 'manual' &&
}
)
}
diff --git a/src/pages/RentalPage.tsx b/src/pages/RentalPage.tsx
index 7c80bf0..dfc5a97 100644
--- a/src/pages/RentalPage.tsx
+++ b/src/pages/RentalPage.tsx
@@ -41,6 +41,7 @@ interface ObjectFormData {
openHour: number
closeHour: number
maxHoursPerSlot: number | ''
+ bufferMinutes: number
description: string
}
@@ -48,6 +49,7 @@ const EMPTY_FORM: ObjectFormData = {
name: '', icon: '🎾', color: 'bg-green-500',
pricePerHour: 1000, pricePerDay: 5000,
openHour: 9, closeHour: 21, maxHoursPerSlot: '',
+ bufferMinutes: 0,
description: '',
}
@@ -65,6 +67,7 @@ function ObjectFormModal({
pricePerHour: initial.pricePerHour, pricePerDay: initial.pricePerDay,
openHour: initial.openHour, closeHour: initial.closeHour,
maxHoursPerSlot: initial.maxHoursPerSlot ?? '',
+ bufferMinutes: initial.bufferMinutes ?? 0,
description: '',
}
: EMPTY_FORM,
@@ -86,6 +89,7 @@ function ObjectFormModal({
openHour: form.openHour,
closeHour: form.closeHour,
maxHoursPerSlot: form.maxHoursPerSlot !== '' ? Number(form.maxHoursPerSlot) : undefined,
+ bufferMinutes: form.bufferMinutes > 0 ? form.bufferMinutes : undefined,
})
}
@@ -171,6 +175,31 @@ function ObjectFormModal({
+ {/* Buffer time */}
+
+
+ Технический перерыв между бронями
+
+
set('bufferMinutes', parseInt(e.target.value))}
+ >
+ {[
+ { value: 0, label: 'Без перерыва' },
+ { value: 15, label: '15 минут' },
+ { value: 30, label: '30 минут' },
+ { value: 45, label: '45 минут' },
+ { value: 60, label: '1 час' },
+ { value: 90, label: '1,5 часа' },
+ { value: 120, label: '2 часа' },
+ ].map(o => (
+ {o.label}
+ ))}
+
+
Время для уборки СПА, подготовки корта и т.д.
+
+
{/* Hours */}
diff --git a/src/pages/RoomServicePage.tsx b/src/pages/RoomServicePage.tsx
index e5198c9..b2cd25c 100644
--- a/src/pages/RoomServicePage.tsx
+++ b/src/pages/RoomServicePage.tsx
@@ -1,11 +1,13 @@
-import { useState, useEffect } from 'react'
+import { useState, useEffect, useRef } from 'react'
import {
UtensilsCrossed, Clock, CheckCircle2, ChefHat, Bell, Plus,
XCircle, Flame, CreditCard, Percent, Home, Store,
+ QrCode, Copy, CheckCheck, ExternalLink,
} from 'lucide-react'
import { format } from 'date-fns'
import { cn, formatCurrency } from '../lib/utils'
import { Badge } from '../components/ui/Badge'
+import { useNotifications } from '../contexts/NotificationsContext'
// ── Types ─────────────────────────────────────────────────────────────────────
@@ -118,11 +120,18 @@ function elapsed(from: Date): string {
type PageTab = 'orders' | 'menu' | 'settings'
+const GUEST_LINK = 'https://app.hotelsync.ru/room-service/grand-palace'
+
export function RoomServicePage() {
+ const { addNotification } = useNotifications()
const [tab, setTab] = useState
('orders')
const [serviceChargePct, setServiceChargePct] = useState(DEFAULT_SERVICE_CHARGE_PCT)
const [menuCat, setMenuCat] = useState(CATEGORIES[0])
const [, setTick] = useState(0)
+ const [payOnline, setPayOnline] = useState(true)
+ const [payOnBill, setPayOnBill] = useState(true)
+ const [linkCopied, setLinkCopied] = useState(false)
+ const prevNewCount = useRef(0)
const [orders, setOrders] = useState(() => [
makeOrder('o1', '301', 'Наталья Александрова',
@@ -149,6 +158,29 @@ export function RoomServicePage() {
return () => clearInterval(id)
}, [])
+ // Notify on new orders
+ useEffect(() => {
+ const newCount = orders.filter(o => o.status === 'new').length
+ if (newCount > prevNewCount.current) {
+ const newest = orders.find(o => o.status === 'new')
+ if (newest) {
+ addNotification({
+ type: 'system',
+ title: `Новый заказ Room Service — №${newest.roomNumber}`,
+ body: newest.items.map(i => `${i.item.emoji} ${i.item.name} ×${i.qty}`).join(', '),
+ link: '/room-service',
+ })
+ }
+ }
+ prevNewCount.current = newCount
+ }, [orders, addNotification])
+
+ const copyLink = () => {
+ navigator.clipboard.writeText(GUEST_LINK).catch(() => {})
+ setLinkCopied(true)
+ setTimeout(() => setLinkCopied(false), 2000)
+ }
+
const activeOrders = orders.filter(o => o.status !== 'delivered' && o.status !== 'cancelled')
const doneOrders = orders.filter(o => o.status === 'delivered' || o.status === 'cancelled')
@@ -429,6 +461,53 @@ export function RoomServicePage() {
{/* ── Settings tab ── */}
{tab === 'settings' && (
+
+ {/* QR / Guest link */}
+
+
+
+ Страница гостя (QR-код)
+
+
+ Распечатайте QR-код и разместите в номерах. Гость откроет страницу, выберет блюда, оплатит и будет следить за статусом заказа.
+
+
+
{GUEST_LINK}
+
+
+
+
+ {linkCopied ? : }
+ {linkCopied ? 'Скопировано' : 'Копировать'}
+
+
+
+
+
+
QR-код для печати
+
+ Распечатайте и ламинируйте для каждого номера
+
+
+ Скачать PNG
+
+
+
+
+
+ При каждом новом заказе с этой страницы — уведомление в центре уведомлений
+
+
+
+ {/* Service settings */}
- {/* Payment types */}
+ {/* Payment modes */}
-
- Оплата доставки в номер
+
+ Способы оплаты для гостя
-
-
-
-
- Онлайн-эквайринг (через сайт room service)
-
-
-
-
- Включить в счёт при выезде
-
+
+
+
+
+
+
Оплата онлайн сразу
+
Гость оплачивает при заказе через эквайринг
+
+
+
setPayOnline(v => !v)}
+ className={cn('relative w-11 h-6 rounded-full transition-colors shrink-0', payOnline ? 'bg-brand-600' : 'bg-slate-300 dark:bg-slate-600')}
+ >
+
+
+
+
+
+
+
+
Включить в счёт при выезде
+
Заказ добавляется к итоговому счёту гостя
+
+
+
setPayOnBill(v => !v)}
+ className={cn('relative w-11 h-6 rounded-full transition-colors shrink-0', payOnBill ? 'bg-brand-600' : 'bg-slate-300 dark:bg-slate-600')}
+ >
+
+
+
+ {!payOnline && !payOnBill && (
+
Включите хотя бы один способ оплаты
+ )}
{/* Restaurant scheduling */}
-
- Ресторан
-
+
Ресторан
@@ -489,22 +584,11 @@ export function RoomServicePage() {
+
+ В будущих версиях: интеграция с термопринтером на кухне и кухонный экран на планшете
+
Сохранить настройки
-
-
-
-
-
-
Сайт Room Service
-
- В будущем гости смогут самостоятельно делать заказы через сайт
- roomservice.hotelsync.ru/grand-palace с оплатой онлайн через эквайринг.
- Заказ автоматически появится в этом интерфейсе.
-
-
-
-
)}
diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx
index a5ed24f..8668bb3 100644
--- a/src/pages/SettingsPage.tsx
+++ b/src/pages/SettingsPage.tsx
@@ -8,7 +8,6 @@ import { useTheme } from '../contexts/ThemeContext'
const SECTIONS = [
{ id: 'general', label: 'Основные', icon: Building2 },
{ id: 'booking', label: 'Бронирование', icon: BedDouble },
- { id: 'guests', label: 'Гости', icon: Users },
{ id: 'theme', label: 'Внешний вид', icon: Globe },
{ id: 'notify', label: 'Уведомления', icon: Bell },
{ id: 'security', label: 'Безопасность', icon: Shield },
@@ -32,6 +31,17 @@ export function SettingsPage() {
const hotel = MOCK_HOTELS[0]
const { theme, toggle } = useTheme()
+ const [calendarCompact, setCalendarCompact] = useState(
+ () => localStorage.getItem('calendarCompact') === 'true',
+ )
+ const toggleCalendarCompact = () => {
+ setCalendarCompact(v => {
+ const next = !v
+ localStorage.setItem('calendarCompact', String(next))
+ return next
+ })
+ }
+
const [form, setForm] = useState({
name: hotel.name,
address: hotel.address,
@@ -283,95 +293,6 @@ export function SettingsPage() {
>
)}
- {/* ── GUESTS ── */}
- {section === 'guests' && (
- <>
-
Статусы гостей
-
- Теги отображаются при создании и редактировании бронирования
-
-
- {/* Tag list */}
-
-
- {/* Add new tag */}
-
-
- {/* Preview */}
-
-
Предпросмотр тегов
-
- {guestTags.map(tag => (
-
- {tag.label}
-
- ))}
-
-
- >
- )}
-
{/* ── THEME ── */}
{section === 'theme' && (
<>
@@ -402,6 +323,18 @@ export function SettingsPage() {
))}
+
+ {/* Calendar compact mode */}
+
+
Настройки календаря
+
+
+
Компактный режим
+
Уменьшенные строки в сетке бронирований
+
+
+
+
>
)}
diff --git a/src/pages/UsersPage.tsx b/src/pages/UsersPage.tsx
index 588629b..b5c9fac 100644
--- a/src/pages/UsersPage.tsx
+++ b/src/pages/UsersPage.tsx
@@ -1,7 +1,8 @@
import { useState } from 'react'
import {
Plus, Pencil, Trash2, Search, Shield, User as UserIcon,
- Sparkles, Mail, Phone, X as XIcon, Eye, EyeOff, CheckCircle2, AlertCircle,
+ Sparkles, Mail, Phone, Eye, EyeOff, CheckCircle2, AlertCircle,
+ Lock, Check, X as XIcon,
} from 'lucide-react'
import { cn } from '../lib/utils'
import { Modal } from '../components/ui/Modal'
@@ -17,21 +18,35 @@ interface StaffUser {
email: string
phone?: string
role: StaffRole
- position: string // свободная должность, напр. "Старший администратор"
+ position: string
isActive: boolean
createdAt: string
lastLogin?: string
avatarColor: string
}
+interface ModulePermission {
+ key: string
+ label: string
+ description?: string
+}
+
+interface RolePermissions {
+ id: string
+ name: string
+ color: string
+ isSystem: boolean
+ permissions: Record
+}
+
// ── Constants ──────────────────────────────────────────────────────────────────
const ROLE_META: Record = {
- hotel_manager: { label: 'Менеджер', color: 'bg-brand-100 text-brand-700 dark:bg-brand-900/30 dark:text-brand-300', icon: Shield },
- receptionist: { label: 'Ресепшн', color: 'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300', icon: UserIcon },
- housekeeper: { label: 'Горничная', color: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300', icon: Sparkles },
- accountant: { label: 'Бухгалтер', color: 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300', icon: UserIcon },
- security: { label: 'Охрана', color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300', icon: Shield },
+ hotel_manager: { label: 'Менеджер', color: 'bg-brand-100 text-brand-700 dark:bg-brand-900/30 dark:text-brand-300', icon: Shield },
+ receptionist: { label: 'Ресепшн', color: 'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300', icon: UserIcon },
+ housekeeper: { label: 'Горничная', color: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300', icon: Sparkles },
+ accountant: { label: 'Бухгалтер', color: 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300', icon: UserIcon },
+ security: { label: 'Охрана', color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300', icon: Shield },
}
const AVATAR_COLORS = [
@@ -47,6 +62,112 @@ const DEFAULT_POSITIONS: Record = {
security: ['Начальник охраны', 'Охранник', 'Контролёр доступа'],
}
+const MODULE_GROUPS: { group: string; modules: ModulePermission[] }[] = [
+ {
+ group: 'Фронт-офис',
+ modules: [
+ { key: 'calendar', label: 'Календарь', description: 'Просмотр и управление сеткой бронирований' },
+ { key: 'bookings', label: 'Бронирования', description: 'Создание, редактирование, отмена броней' },
+ { key: 'guests', label: 'Гости', description: 'Карточки гостей, история, лояльность' },
+ { key: 'rooms', label: 'Номерной фонд', description: 'Номера, категории, тарифы' },
+ { key: 'availability',label: 'Доступность', description: 'Ограничения и стоп-продажи' },
+ ],
+ },
+ {
+ group: 'Операции',
+ modules: [
+ { key: 'housekeeping', label: 'Горничные', description: 'Задания на уборку, статусы номеров' },
+ { key: 'maintenance', label: 'Техобслуживание', description: 'Заявки на ремонт и обслуживание' },
+ { key: 'room_service', label: 'Сервис в номер', description: 'Заказы еды и услуг в номер' },
+ { key: 'rental', label: 'Аренда объектов', description: 'Корт, баня, конференц-залы' },
+ ],
+ },
+ {
+ group: 'Продажи и финансы',
+ modules: [
+ { key: 'pos', label: 'Кассовый терминал', description: 'POS, смены, оплаты' },
+ { key: 'tariffs', label: 'Тарифы', description: 'Тарифы и ценовые планы' },
+ { key: 'pricing', label: 'Динамические цены', description: 'Правила динамического ценообразования' },
+ { key: 'discounts', label: 'Скидки', description: 'Промокоды и скидочные кампании' },
+ { key: 'loyalty', label: 'Лояльность', description: 'Баллы, уровни, программа лояльности' },
+ { key: 'reports', label: 'Отчёты', description: 'Финансовые и операционные отчёты' },
+ ],
+ },
+ {
+ group: 'Маркетинг и каналы',
+ modules: [
+ { key: 'channels', label: 'Каналы продаж', description: 'OTA, менеджер каналов' },
+ { key: 'reviews', label: 'Отзывы', description: 'Управление отзывами гостей' },
+ { key: 'website', label: 'Сайт и виджет', description: 'Сайт отеля и виджет бронирования' },
+ ],
+ },
+ {
+ group: 'Администрирование',
+ modules: [
+ { key: 'users', label: 'Сотрудники', description: 'Управление пользователями и ролями' },
+ { key: 'documents', label: 'Документы', description: 'Шаблоны договоров и документов' },
+ { key: 'settings', label: 'Настройки', description: 'Настройки отеля и системы' },
+ ],
+ },
+]
+
+const ALL_MODULE_KEYS = MODULE_GROUPS.flatMap(g => g.modules.map(m => m.key))
+
+const allPerms = (v: boolean) =>
+ Object.fromEntries(ALL_MODULE_KEYS.map(k => [k, v]))
+
+const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [
+ {
+ id: 'rp_manager',
+ name: 'Менеджер',
+ color: '#4F46E5',
+ isSystem: true,
+ permissions: allPerms(true),
+ },
+ {
+ id: 'rp_receptionist',
+ name: 'Ресепшн',
+ color: '#0EA5E9',
+ isSystem: true,
+ permissions: {
+ ...allPerms(false),
+ calendar: true, bookings: true, guests: true, rooms: true,
+ housekeeping: true, room_service: true, rental: true,
+ pos: true, reviews: true,
+ },
+ },
+ {
+ id: 'rp_housekeeper',
+ name: 'Горничная',
+ color: '#10B981',
+ isSystem: true,
+ permissions: {
+ ...allPerms(false),
+ housekeeping: true, rooms: true, maintenance: true,
+ },
+ },
+ {
+ id: 'rp_accountant',
+ name: 'Бухгалтер',
+ color: '#7C3AED',
+ isSystem: true,
+ permissions: {
+ ...allPerms(false),
+ reports: true, pos: true, discounts: true, tariffs: true, pricing: true, loyalty: true,
+ },
+ },
+ {
+ id: 'rp_security',
+ name: 'Охрана',
+ color: '#F97316',
+ isSystem: true,
+ permissions: {
+ ...allPerms(false),
+ calendar: true, bookings: true,
+ },
+ },
+]
+
// ── Mock data ──────────────────────────────────────────────────────────────────
const MOCK_USERS: StaffUser[] = [
@@ -98,13 +219,13 @@ function UserModal({
onSave: (u: StaffUser) => void
}) {
const [form, setForm] = useState({
- firstName: user?.firstName ?? '',
- lastName: user?.lastName ?? '',
- email: user?.email ?? '',
- phone: user?.phone ?? '',
- role: (user?.role ?? 'receptionist') as StaffRole,
- position: user?.position ?? '',
- isActive: user?.isActive ?? true,
+ firstName: user?.firstName ?? '',
+ lastName: user?.lastName ?? '',
+ email: user?.email ?? '',
+ phone: user?.phone ?? '',
+ role: (user?.role ?? 'receptionist') as StaffRole,
+ position: user?.position ?? '',
+ isActive: user?.isActive ?? true,
avatarColor: user?.avatarColor ?? AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)],
})
const [password, setPassword] = useState('')
@@ -296,9 +417,272 @@ function UserModal({
)
}
+// ── Roles & Permissions Tab ────────────────────────────────────────────────────
+
+function RolesTab() {
+ const [roles, setRoles] = useState(INITIAL_ROLE_PERMISSIONS)
+ const [selectedRoleId, setSelectedRoleId] = useState(INITIAL_ROLE_PERMISSIONS[0].id)
+ const [newRoleName, setNewRoleName] = useState('')
+ const [addingRole, setAddingRole] = useState(false)
+ const [deleteId, setDeleteId] = useState(null)
+
+ const selectedRole = roles.find(r => r.id === selectedRoleId) ?? roles[0]
+
+ const togglePerm = (key: string) => {
+ setRoles(prev => prev.map(r =>
+ r.id === selectedRoleId
+ ? { ...r, permissions: { ...r.permissions, [key]: !r.permissions[key] } }
+ : r
+ ))
+ }
+
+ const setAllPerms = (v: boolean) => {
+ setRoles(prev => prev.map(r =>
+ r.id === selectedRoleId
+ ? { ...r, permissions: allPerms(v) }
+ : r
+ ))
+ }
+
+ const createRole = () => {
+ const name = newRoleName.trim()
+ if (!name) return
+ const colors = ['#EC4899', '#14B8A6', '#F59E0B', '#6366F1', '#84CC16']
+ const newRole: RolePermissions = {
+ id: `rp_${Date.now()}`,
+ name,
+ color: colors[roles.length % colors.length],
+ isSystem: false,
+ permissions: allPerms(false),
+ }
+ setRoles(prev => [...prev, newRole])
+ setSelectedRoleId(newRole.id)
+ setNewRoleName('')
+ setAddingRole(false)
+ }
+
+ const deleteRole = (id: string) => {
+ setRoles(prev => prev.filter(r => r.id !== id))
+ setDeleteId(null)
+ if (selectedRoleId === id) {
+ setSelectedRoleId(roles.find(r => r.id !== id)?.id ?? '')
+ }
+ }
+
+ const activeCount = Object.values(selectedRole?.permissions ?? {}).filter(Boolean).length
+ const totalCount = ALL_MODULE_KEYS.length
+
+ return (
+
+ {/* Role list */}
+
+
+
+
+ {roles.map(role => (
+
setSelectedRoleId(role.id)}
+ className={cn(
+ 'flex items-center justify-between px-3 py-2.5 cursor-pointer transition-colors',
+ selectedRoleId === role.id
+ ? 'bg-brand-50 dark:bg-brand-900/20'
+ : 'hover:bg-slate-50 dark:hover:bg-slate-700/30',
+ )}
+ >
+
+
+ {role.isSystem && (
+
+ )}
+ {!role.isSystem && (
+ { e.stopPropagation(); setDeleteId(role.id) }}
+ className="p-0.5 rounded text-slate-300 hover:text-red-500 transition-colors"
+ >
+
+
+ )}
+
+
+ ))}
+
+
+ {/* Add role */}
+
+ {addingRole ? (
+
+ setNewRoleName(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Enter') createRole(); if (e.key === 'Escape') setAddingRole(false) }}
+ />
+
+
+
+ setAddingRole(false)} className="p-1.5 rounded-lg bg-slate-100 dark:bg-slate-700 text-slate-500 hover:text-slate-700 transition-colors shrink-0">
+
+
+
+ ) : (
+
setAddingRole(true)}
+ className="flex items-center gap-1.5 text-xs text-brand-600 dark:text-brand-400 hover:text-brand-700 transition-colors w-full"
+ >
+
+ Добавить роль
+
+ )}
+
+
+
+
+ {/* Permissions editor */}
+ {selectedRole && (
+
+
+ {/* Header */}
+
+
+
+
{selectedRole.name}
+
+ {activeCount} из {totalCount} разделов
+
+
+
+ setAllPerms(false)}
+ className="text-xs text-slate-500 dark:text-slate-400 hover:text-red-600 transition-colors px-2 py-1 rounded hover:bg-red-50 dark:hover:bg-red-900/20"
+ >
+ Снять все
+
+ setAllPerms(true)}
+ className="text-xs text-slate-500 dark:text-slate-400 hover:text-brand-600 transition-colors px-2 py-1 rounded hover:bg-brand-50 dark:hover:bg-brand-900/20"
+ >
+ Выбрать все
+
+
+
+
+ {/* Module groups */}
+
+ {MODULE_GROUPS.map(group => (
+
+
+ {group.group}
+
+
+ {group.modules.map(mod => {
+ const enabled = selectedRole.permissions[mod.key] ?? false
+ return (
+
togglePerm(mod.key)}
+ className={cn(
+ 'flex items-start gap-3 p-3 rounded-xl border text-left transition-all',
+ enabled
+ ? 'bg-brand-50 dark:bg-brand-900/20 border-brand-200 dark:border-brand-800'
+ : 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600',
+ )}
+ >
+
+ {enabled && }
+
+
+
+ {mod.label}
+
+ {mod.description && (
+
+ {mod.description}
+
+ )}
+
+
+ )
+ })}
+
+
+ ))}
+
+
+ {selectedRole.isSystem && (
+
+
+
+
+ Системная роль — изменения применяются немедленно для всех сотрудников с этой ролью
+
+
+
+ )}
+
+
+ )}
+
+ {/* Delete role confirm */}
+ {deleteId && (
+
+
+
Удалить роль?
+
+ Сотрудники с этой ролью потеряют связанные права доступа. Действие нельзя отменить.
+
+
+ setDeleteId(null)} className="btn-secondary flex-1 justify-center">Отмена
+ deleteRole(deleteId)}
+ className="flex-1 justify-center py-2 px-4 rounded-xl bg-red-600 hover:bg-red-700 text-white text-sm font-medium transition-colors"
+ >
+ Удалить
+
+
+
+
+ )}
+
+ )
+}
+
// ── Page ───────────────────────────────────────────────────────────────────────
+const PAGE_TABS = [
+ { id: 'staff', label: 'Сотрудники' },
+ { id: 'roles', label: 'Роли и права' },
+] as const
+type PageTab = typeof PAGE_TABS[number]['id']
+
export function UsersPage() {
+ const [tab, setTab] = useState('staff')
const [users, setUsers] = useState(MOCK_USERS)
const [search, setSearch] = useState('')
const [roleFilter, setRoleFilter] = useState('all')
@@ -333,9 +717,8 @@ export function UsersPage() {
}
const stats = {
- total: users.length,
- active: users.filter(u => u.isActive).length,
- managers: users.filter(u => u.role === 'hotel_manager').length,
+ total: users.length,
+ active: users.filter(u => u.isActive).length,
}
return (
@@ -346,191 +729,217 @@ export function UsersPage() {
Сотрудники
{stats.total} пользователей · {stats.active} активных
-
-
- Добавить сотрудника
-
-
-
- {/* Stats */}
-
- {(Object.entries(ROLE_META) as [StaffRole, typeof ROLE_META[StaffRole]][]).map(([role, meta]) => {
- const count = users.filter(u => u.role === role).length
- return (
-
setRoleFilter(roleFilter === role ? 'all' : role)}>
-
-
-
- {meta.label}
-
-
-
{count}
-
- )
- })}
-
-
- {/* Filters */}
-
-
-
- setSearch(e.target.value)} />
-
-
-
setRoleFilter('all')}
- className={cn('px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
- roleFilter === 'all'
- ? 'bg-brand-600 text-white border-brand-600'
- : 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600')}
- >
- Все
+ {tab === 'staff' && (
+
+
+ Добавить сотрудника
- {(Object.entries(ROLE_META) as [StaffRole, typeof ROLE_META[StaffRole]][]).map(([role, meta]) => (
- setRoleFilter(roleFilter === role ? 'all' : role)}
- className={cn('px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
- roleFilter === role
- ? 'bg-brand-600 text-white border-brand-600'
- : 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600')}
- >
- {meta.label}
-
- ))}
-
+ )}
- {/* Table */}
-
-
-
-
-
- Сотрудник
- Роль / Должность
- Контакты
- Последний вход
- Статус
-
-
-
-
- {filtered.map(u => {
- const meta = ROLE_META[u.role]
- return (
-
-
-
-
- {u.firstName[0]?.toUpperCase()}
-
-
-
- {u.lastName} {u.firstName}
-
-
{u.email}
-
-
-
-
-
- {meta.label}
-
- {u.position}
-
-
-
-
- {u.email}
-
- {u.phone && (
-
- )}
-
-
-
-
- {u.lastLogin
- ? new Date(u.lastLogin).toLocaleDateString('ru-RU')
- : '—'}
-
-
-
-
- {u.isActive
- ? <> Активен>
- : <> Неактивен>}
-
-
-
-
-
openEdit(u)}
- 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"
- >
-
-
-
setDeleteId(u.id)}
- className="p-1.5 rounded-lg text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
- >
-
-
-
-
-
- )
- })}
-
-
+ {/* Tabs */}
+
+ {PAGE_TABS.map(t => (
+ 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}
+
+ ))}
+
- {filtered.length === 0 && (
-
-
-
Сотрудники не найдены
+ {tab === 'staff' && (
+ <>
+ {/* Stats */}
+
+ {(Object.entries(ROLE_META) as [StaffRole, typeof ROLE_META[StaffRole]][]).map(([role, meta]) => {
+ const count = users.filter(u => u.role === role).length
+ return (
+
setRoleFilter(roleFilter === role ? 'all' : role)}>
+
+
+
+ {meta.label}
+
+
+
{count}
+
+ )
+ })}
+
+
+ {/* Filters */}
+
+
+
+ setSearch(e.target.value)} />
- )}
-
-
-
- {/* User modal */}
-
setModalOpen(false)}
- onSave={handleSave}
- />
-
- {/* Delete confirm */}
- {deleteId && (
-
-
-
Удалить сотрудника?
-
- Сотрудник потеряет доступ к системе. Историю его действий можно будет просмотреть в журнале.
-
-
-
setDeleteId(null)} className="btn-secondary flex-1 justify-center">Отмена
-
handleDelete(deleteId)} className="flex-1 justify-center py-2 px-4 rounded-xl bg-red-600 hover:bg-red-700 text-white text-sm font-medium transition-colors">
- Удалить
+
+ setRoleFilter('all')}
+ className={cn('px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
+ roleFilter === 'all'
+ ? 'bg-brand-600 text-white border-brand-600'
+ : 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600')}
+ >
+ Все
+ {(Object.entries(ROLE_META) as [StaffRole, typeof ROLE_META[StaffRole]][]).map(([role, meta]) => (
+ setRoleFilter(roleFilter === role ? 'all' : role)}
+ className={cn('px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
+ roleFilter === role
+ ? 'bg-brand-600 text-white border-brand-600'
+ : 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-600')}
+ >
+ {meta.label}
+
+ ))}
-
+
+ {/* Table */}
+
+
+
+
+
+ Сотрудник
+ Роль / Должность
+ Контакты
+ Последний вход
+ Статус
+
+
+
+
+ {filtered.map(u => {
+ const meta = ROLE_META[u.role]
+ return (
+
+
+
+
+ {u.firstName[0]?.toUpperCase()}
+
+
+
+ {u.lastName} {u.firstName}
+
+
{u.email}
+
+
+
+
+
+ {meta.label}
+
+ {u.position}
+
+
+
+
+ {u.email}
+
+ {u.phone && (
+
+ )}
+
+
+
+
+ {u.lastLogin
+ ? new Date(u.lastLogin).toLocaleDateString('ru-RU')
+ : '—'}
+
+
+
+
+ {u.isActive
+ ? <> Активен>
+ : <> Неактивен>}
+
+
+
+
+
openEdit(u)}
+ 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"
+ >
+
+
+
setDeleteId(u.id)}
+ className="p-1.5 rounded-lg text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
+ >
+
+
+
+
+
+ )
+ })}
+
+
+
+ {filtered.length === 0 && (
+
+
+
Сотрудники не найдены
+
+ )}
+
+
+
+ {/* User modal */}
+
setModalOpen(false)}
+ onSave={handleSave}
+ />
+
+ {/* Delete confirm */}
+ {deleteId && (
+
+
+
Удалить сотрудника?
+
+ Сотрудник потеряет доступ к системе. Историю его действий можно будет просмотреть в журнале.
+
+
+ setDeleteId(null)} className="btn-secondary flex-1 justify-center">Отмена
+ handleDelete(deleteId)} className="flex-1 justify-center py-2 px-4 rounded-xl bg-red-600 hover:bg-red-700 text-white text-sm font-medium transition-colors">
+ Удалить
+
+
+
+
+ )}
+ >
)}
+
+ {tab === 'roles' && }
)
}