Add role permissions, loyalty accrual rules, guest statuses, and room service guest page
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/review/:slug" element={<GuestReviewPage />} />
|
||||
<Route path="/room-service/:slug" element={<GuestRoomServicePage />} />
|
||||
|
||||
{/* App routes */}
|
||||
<Route element={<AppLayout />}>
|
||||
|
||||
@@ -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<number | null>(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
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => 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',
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface RentalObject {
|
||||
openHour: number // 8
|
||||
closeHour: number // 22
|
||||
maxHoursPerSlot?: number
|
||||
bufferMinutes?: number // tech break between bookings
|
||||
}
|
||||
|
||||
export interface RentalBooking {
|
||||
|
||||
420
src/pages/GuestRoomServicePage.tsx
Normal file
420
src/pages/GuestRoomServicePage.tsx
Normal file
@@ -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<string, { name: string; color: string; serviceChargePct: number }> = {
|
||||
'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<Record<string, number>>({})
|
||||
const [step, setStep] = useState<Step>('menu')
|
||||
const [roomNumber, setRoomNumber] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
const [payMode, setPayMode] = useState<'online' | 'bill'>('online')
|
||||
const [orderStatus, setOrderStatus] = useState<OrderStatus>('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 (
|
||||
<div className="min-h-screen bg-slate-50" style={{ fontFamily: 'system-ui, sans-serif' }}>
|
||||
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-20 shadow-sm" style={{ backgroundColor: config.color }}>
|
||||
<div className="max-w-lg mx-auto px-4 py-3 flex items-center gap-3">
|
||||
{step !== 'menu' && step !== 'status' && (
|
||||
<button onClick={() => setStep(step === 'payment' ? 'cart' : 'menu')} className="text-white/80 hover:text-white">
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className="text-white font-bold text-base leading-tight">{config.name}</p>
|
||||
<p className="text-white/70 text-xs">Room Service</p>
|
||||
</div>
|
||||
{step === 'menu' && cartCount > 0 && (
|
||||
<button
|
||||
onClick={() => 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"
|
||||
>
|
||||
<ShoppingCart size={14} />
|
||||
{cartCount}
|
||||
<span className="font-bold">{formatCurrency(total)}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-lg mx-auto px-4 py-4">
|
||||
|
||||
{/* ── MENU ── */}
|
||||
{step === 'menu' && (
|
||||
<div className="space-y-4">
|
||||
{/* Category tabs */}
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 -mx-4 px-4">
|
||||
{CATEGORIES.map(cat => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => 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}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<div className="space-y-2">
|
||||
{MENU.filter(i => i.category === category && !i.stopList).map(item => (
|
||||
<div key={item.id} className="bg-white rounded-2xl p-4 shadow-sm flex items-center gap-4">
|
||||
<span className="text-3xl shrink-0">{item.emoji}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<p className="font-semibold text-slate-900 text-sm">{item.name}</p>
|
||||
{item.popular && (
|
||||
<span className="text-[10px] bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded-full font-medium">Хит</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 flex items-center gap-1 mt-0.5">
|
||||
<Clock size={10} />{item.time} мин
|
||||
</p>
|
||||
<p className="font-bold text-slate-900 mt-1">{formatCurrency(item.price)}</p>
|
||||
</div>
|
||||
{/* Counter */}
|
||||
{(cart[item.id] ?? 0) === 0 ? (
|
||||
<button
|
||||
onClick={() => 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 }}
|
||||
>
|
||||
<Plus size={18} />
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => sub(item.id)} className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center text-slate-700">
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<span className="text-sm font-bold w-4 text-center">{cart[item.id]}</span>
|
||||
<button
|
||||
onClick={() => add(item.id)}
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-white"
|
||||
style={{ backgroundColor: config.color }}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Sticky cart button */}
|
||||
{cartCount > 0 && (
|
||||
<div className="sticky bottom-4">
|
||||
<button
|
||||
onClick={() => 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)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── CART ── */}
|
||||
{step === 'cart' && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="font-bold text-slate-900 text-lg">Ваш заказ</h2>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm overflow-hidden">
|
||||
{cartItems.map((item, i) => (
|
||||
<div key={item.id} className={cn('flex items-center gap-3 px-4 py-3', i < cartItems.length - 1 && 'border-b border-slate-100')}>
|
||||
<span className="text-2xl">{item.emoji}</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-slate-800">{item.name}</p>
|
||||
<p className="text-xs text-slate-500">{formatCurrency(item.price)} × {cart[item.id]}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => 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 ? <X size={12} /> : <Minus size={12} />}
|
||||
</button>
|
||||
<span className="text-sm font-bold w-4 text-center">{cart[item.id]}</span>
|
||||
<button
|
||||
onClick={() => add(item.id)}
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center text-white"
|
||||
style={{ backgroundColor: config.color }}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-slate-800 w-16 text-right">{formatCurrency(item.price * cart[item.id])}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="px-4 py-3 bg-slate-50 space-y-1.5">
|
||||
<div className="flex justify-between text-sm text-slate-600">
|
||||
<span>Сумма заказа</span>
|
||||
<span>{formatCurrency(subtotal)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-slate-600">
|
||||
<span>Обслуживание {config.serviceChargePct}%</span>
|
||||
<span>+{formatCurrency(svcCharge)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between font-bold text-slate-900 text-base pt-1 border-t border-slate-200">
|
||||
<span>Итого</span>
|
||||
<span>{formatCurrency(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Room number */}
|
||||
<div className="bg-white rounded-2xl shadow-sm p-4 space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Номер комнаты *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:border-blue-400"
|
||||
placeholder="Например: 301"
|
||||
value={roomNumber}
|
||||
onChange={e => setRoomNumber(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Пожелания (необязательно)</label>
|
||||
<textarea
|
||||
className="w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm resize-none focus:outline-none focus:border-blue-400"
|
||||
rows={2}
|
||||
placeholder="Без лука, аллергия на орехи..."
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => 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 }}
|
||||
>
|
||||
К оплате
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── PAYMENT ── */}
|
||||
{step === 'payment' && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="font-bold text-slate-900 text-lg">Способ оплаты</h2>
|
||||
|
||||
{[
|
||||
{ key: 'online' as const, icon: CreditCard, label: 'Оплатить онлайн сейчас', desc: 'Банковской картой через защищённую форму' },
|
||||
{ key: 'bill' as const, icon: Receipt, label: 'Включить в счёт при выезде', desc: 'Оплата при расчёте в конце проживания' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.key}
|
||||
onClick={() => 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 } : {}}
|
||||
>
|
||||
<div
|
||||
className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
|
||||
style={payMode === opt.key ? { backgroundColor: `${config.color}20` } : { backgroundColor: '#f1f5f9' }}
|
||||
>
|
||||
<opt.icon size={18} style={payMode === opt.key ? { color: config.color } : { color: '#64748b' }} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900 text-sm">{opt.label}</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">{opt.desc}</p>
|
||||
</div>
|
||||
<div className={cn(
|
||||
'ml-auto w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0',
|
||||
payMode === opt.key ? 'border-blue-500' : 'border-slate-300',
|
||||
)} style={payMode === opt.key ? { borderColor: config.color } : {}}>
|
||||
{payMode === opt.key && (
|
||||
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: config.color }} />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Summary */}
|
||||
<div className="bg-white rounded-2xl shadow-sm p-4">
|
||||
<p className="text-sm font-semibold text-slate-700 mb-2">Номер {roomNumber}</p>
|
||||
{cartItems.map(i => (
|
||||
<div key={i.id} className="flex justify-between text-sm text-slate-600 py-0.5">
|
||||
<span>{i.emoji} {i.name} ×{cart[i.id]}</span>
|
||||
<span>{formatCurrency(i.price * cart[i.id])}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-between font-bold text-slate-900 text-base mt-2 pt-2 border-t border-slate-100">
|
||||
<span>Итого</span>
|
||||
<span>{formatCurrency(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={placeOrder}
|
||||
className="w-full py-4 rounded-2xl text-white font-bold text-base transition-opacity hover:opacity-90"
|
||||
style={{ backgroundColor: config.color }}
|
||||
>
|
||||
{payMode === 'online' ? `Оплатить ${formatCurrency(total)}` : 'Оформить заказ'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── STATUS ── */}
|
||||
{step === 'status' && (
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="text-center">
|
||||
<div
|
||||
className="w-20 h-20 rounded-full mx-auto flex items-center justify-center text-4xl mb-4"
|
||||
style={{ backgroundColor: `${config.color}15` }}
|
||||
>
|
||||
🎉
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-slate-900">Заказ принят!</h2>
|
||||
<p className="text-slate-500 text-sm mt-1">Номер {roomNumber} · {formatCurrency(total)}</p>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="bg-white rounded-2xl shadow-sm p-5">
|
||||
<p className="text-sm font-semibold text-slate-700 mb-4">Статус заказа</p>
|
||||
<div className="space-y-4">
|
||||
{STATUS_STEPS.map((s, idx) => {
|
||||
const done = idx <= currentStatusIdx
|
||||
const current = idx === currentStatusIdx
|
||||
const Icon = s.icon
|
||||
return (
|
||||
<div key={s.key} className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
'w-9 h-9 rounded-full flex items-center justify-center shrink-0 transition-colors',
|
||||
done ? 'text-white' : 'bg-slate-100 text-slate-400',
|
||||
)} style={done ? { backgroundColor: config.color } : {}}>
|
||||
<Icon size={16} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className={cn('text-sm font-semibold', done ? 'text-slate-900' : 'text-slate-400')}>
|
||||
{s.label}
|
||||
</p>
|
||||
{current && <p className="text-xs text-slate-500 mt-0.5">{s.desc}</p>}
|
||||
</div>
|
||||
{current && (
|
||||
<div className="flex gap-0.5">
|
||||
{[0,1,2].map(i => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-1.5 h-1.5 rounded-full animate-bounce"
|
||||
style={{ backgroundColor: config.color, animationDelay: `${i * 0.15}s` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => { 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 }}
|
||||
>
|
||||
Сделать ещё заказ
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-4 max-w-lg">
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
Статусы отображаются в карточках гостей и при создании бронирования. Они помогают быстро идентифицировать тип гостя.
|
||||
</p>
|
||||
|
||||
{/* Tag list */}
|
||||
<div className="card overflow-hidden divide-y divide-slate-100 dark:divide-slate-700">
|
||||
{tags.map(tag => (
|
||||
<div key={tag.id} className="flex items-center gap-3 px-4 py-3">
|
||||
<input
|
||||
type="color"
|
||||
value={tag.color}
|
||||
onChange={e => setTags(prev => prev.map(t => t.id === tag.id ? { ...t, color: e.target.value } : t))}
|
||||
className="w-7 h-7 rounded cursor-pointer border-0 bg-transparent shrink-0"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="input flex-1"
|
||||
value={tag.label}
|
||||
onChange={e => setTags(prev => prev.map(t => t.id === tag.id ? { ...t, label: e.target.value } : t))}
|
||||
/>
|
||||
<span
|
||||
className="px-2.5 py-1 rounded-lg text-xs font-medium text-white shrink-0"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
>
|
||||
{tag.label}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setTags(prev => prev.filter(t => t.id !== tag.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 shrink-0"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add new tag */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={newColor}
|
||||
onChange={e => setNewColor(e.target.value)}
|
||||
className="w-7 h-7 rounded cursor-pointer border-0 bg-transparent shrink-0"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="input flex-1"
|
||||
placeholder="Название нового статуса..."
|
||||
value={newLabel}
|
||||
onChange={e => setNewLabel(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') addTag() }}
|
||||
/>
|
||||
<button onClick={addTag} className="btn-primary shrink-0">
|
||||
<Plus size={14} />
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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<PageTab>('list')
|
||||
const [search, setSearch] = useState('')
|
||||
const [filterTag, setFilterTag] = useState<GuestTag | 'all'>('all')
|
||||
const [selectedGuest, setSelectedGuest] = useState<Guest | null>(null)
|
||||
@@ -472,6 +565,28 @@ export function GuestsPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-700 -mt-2">
|
||||
{PAGE_TABS.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => 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}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{pageTab === 'statuses' && <GuestStatusesTab />}
|
||||
|
||||
{pageTab === 'list' && <>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
@@ -641,6 +756,7 @@ export function GuestsPage() {
|
||||
{selectedGuest && (
|
||||
<GuestModal guest={selectedGuest} onClose={() => setSelectedGuest(null)} />
|
||||
)}
|
||||
</>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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-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: '', points: 120, totalSpent: 8400, 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 }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Points balance */}
|
||||
<div className="bg-white/10 rounded-xl p-3">
|
||||
<p className="text-xs text-slate-400 mb-0.5">Накопленные баллы</p>
|
||||
<p className="text-3xl font-bold">{guest.points.toLocaleString('ru-RU')}</p>
|
||||
@@ -245,7 +352,6 @@ function GuestPortalPreview({ settings }: { settings: ProgramSettings }) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Progress to next level */}
|
||||
{nextLevel && (
|
||||
<div>
|
||||
<div className="flex justify-between text-xs text-slate-400 mb-1">
|
||||
@@ -261,7 +367,6 @@ function GuestPortalPreview({ settings }: { settings: ProgramSettings }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick perks */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ icon: Percent, label: `${level.discount}% скидка` },
|
||||
@@ -287,9 +392,417 @@ function GuestPortalPreview({ settings }: { settings: ProgramSettings }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Accrual Rules Tab ────────────────────────────────────────────────────────
|
||||
|
||||
function AccrualRulesTab() {
|
||||
const [rules, setRules] = useState<AccrualRule[]>(INITIAL_ACCRUAL_RULES)
|
||||
const [editingId, setEditingId] = useState<string | null>(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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3 p-3 rounded-xl bg-slate-50 dark:bg-slate-700/40 border border-slate-200 dark:border-slate-700">
|
||||
<Info size={15} className="text-slate-500 dark:text-slate-400 mt-0.5 shrink-0" />
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Правила определяют, сколько баллов начисляется гостям за различные действия. Баллы начисляются автоматически при совершении соответствующей операции.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card overflow-hidden divide-y divide-slate-100 dark:divide-slate-700">
|
||||
{rules.map(rule => (
|
||||
<div key={rule.id} className={cn('p-4 transition-colors', !rule.enabled && 'opacity-60')}>
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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',
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'absolute top-0.5 w-4 h-4 rounded-full bg-white shadow-sm transition-transform',
|
||||
rule.enabled ? 'left-[22px]' : 'left-0.5',
|
||||
)} />
|
||||
</button>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{rule.label}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{rule.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Value editor */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{editingId === rule.id ? (
|
||||
<>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
min={0}
|
||||
className="input text-sm w-24 py-1"
|
||||
value={editValue}
|
||||
onChange={e => setEditValue(parseFloat(e.target.value) || 0)}
|
||||
autoFocus
|
||||
/>
|
||||
<span className="text-xs text-slate-500 whitespace-nowrap">{rule.unit}</span>
|
||||
<button
|
||||
onClick={() => saveEdit(rule.id)}
|
||||
className="p-1.5 rounded-lg bg-brand-600 text-white hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
<Check size={13} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm font-semibold text-brand-700 dark:text-brand-300 whitespace-nowrap">
|
||||
{rule.value} {rule.unit}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => 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}
|
||||
>
|
||||
<Edit2 size={13} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Type badge */}
|
||||
<div className="mt-1.5">
|
||||
<span className={cn(
|
||||
'text-[10px] font-medium px-1.5 py-0.5 rounded-full',
|
||||
rule.type === 'per_ruble' ? 'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-400' :
|
||||
rule.type === 'per_night' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' :
|
||||
'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400',
|
||||
)}>
|
||||
{rule.type === 'per_ruble' ? 'За сумму' : rule.type === 'per_night' ? 'За ночь' : 'Разовый'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Manual Accrual Tab ───────────────────────────────────────────────────────
|
||||
|
||||
function ManualAccrualTab() {
|
||||
const [guests, setGuests] = useState<GuestPoints[]>(MOCK_GUESTS)
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedGuest, setSelectedGuest] = useState<GuestPoints | null>(null)
|
||||
const [mode, setMode] = useState<'add' | 'subtract'>('add')
|
||||
const [amount, setAmount] = useState('')
|
||||
const [reason, setReason] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
const [transactions, setTransactions] = useState<ManualTransaction[]>([])
|
||||
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 (
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-5">
|
||||
{/* Left: search + form */}
|
||||
<div className="xl:col-span-2 space-y-4">
|
||||
{/* Guest search */}
|
||||
<div className="card p-4">
|
||||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3">Поиск гостя</h3>
|
||||
<div className="relative">
|
||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
className="input pl-9"
|
||||
placeholder="Имя, телефон или email..."
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); setSelectedGuest(null) }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Search results dropdown */}
|
||||
{searchResults.length > 0 && !selectedGuest && (
|
||||
<div className="mt-2 border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden divide-y divide-slate-100 dark:divide-slate-700">
|
||||
{searchResults.map(g => (
|
||||
<button
|
||||
key={g.id}
|
||||
type="button"
|
||||
onClick={() => { 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"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{g.name}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{g.phone ?? g.email ?? '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{levelBadge(g.level)}
|
||||
<span className="text-xs font-semibold text-slate-600 dark:text-slate-300">
|
||||
{g.points.toLocaleString('ru-RU')} б.
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{search.trim() && searchResults.length === 0 && (
|
||||
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">Гости не найдены</p>
|
||||
)}
|
||||
|
||||
{/* Selected guest card */}
|
||||
{selectedGuest && updatedGuest && (
|
||||
<div className="mt-3 p-3 rounded-xl bg-brand-50 dark:bg-brand-900/20 border border-brand-200 dark:border-brand-800 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-brand-200 dark:bg-brand-800 flex items-center justify-center shrink-0">
|
||||
<span className="text-sm font-bold text-brand-700 dark:text-brand-300">
|
||||
{selectedGuest.name.split(' ').map(p => p[0]).join('').slice(0, 2)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-brand-900 dark:text-brand-100">{selectedGuest.name}</p>
|
||||
<p className="text-xs text-brand-600 dark:text-brand-400">{selectedGuest.email || selectedGuest.phone || '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-brand-600 dark:text-brand-400">Текущий баланс</p>
|
||||
<p className="text-lg font-bold text-brand-700 dark:text-brand-300">
|
||||
{updatedGuest.points.toLocaleString('ru-RU')} б.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Operation form */}
|
||||
{selectedGuest && (
|
||||
<div className="card p-4 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300">Операция с баллами</h3>
|
||||
|
||||
{/* Add / Subtract toggle */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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',
|
||||
)}
|
||||
>
|
||||
<Plus size={15} />
|
||||
Начислить баллы
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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',
|
||||
)}
|
||||
>
|
||||
<Minus size={15} />
|
||||
Снять баллы
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Количество баллов
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className="input"
|
||||
placeholder="Например: 200"
|
||||
value={amount}
|
||||
onChange={e => setAmount(e.target.value)}
|
||||
/>
|
||||
{amount && !isNaN(parseInt(amount)) && selectedGuest && (
|
||||
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
Баланс после операции:{' '}
|
||||
<span className={cn(
|
||||
'font-semibold',
|
||||
mode === 'add' ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400',
|
||||
)}>
|
||||
{Math.max(0, (updatedGuest?.points ?? 0) + (mode === 'add' ? 1 : -1) * (parseInt(amount) || 0)).toLocaleString('ru-RU')} б.
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reason */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Причина *
|
||||
</label>
|
||||
<select className="input" value={reason} onChange={e => setReason(e.target.value)}>
|
||||
<option value="">Выберите причину...</option>
|
||||
{MANUAL_REASONS.map(r => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Комментарий
|
||||
</label>
|
||||
<textarea
|
||||
className="input resize-none"
|
||||
rows={2}
|
||||
placeholder="Дополнительные сведения (необязательно)"
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{success && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-xl bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800">
|
||||
<Check size={15} className="text-emerald-600 dark:text-emerald-400 shrink-0" />
|
||||
<p className="text-sm text-emerald-700 dark:text-emerald-300 font-medium">Операция выполнена успешно</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!amount || !reason}
|
||||
className={cn(
|
||||
'w-full py-2.5 rounded-xl font-medium text-sm transition-colors',
|
||||
mode === 'add'
|
||||
? 'bg-emerald-600 hover:bg-emerald-700 text-white disabled:bg-slate-200 disabled:text-slate-400'
|
||||
: 'bg-red-600 hover:bg-red-700 text-white disabled:bg-slate-200 disabled:text-slate-400',
|
||||
)}
|
||||
>
|
||||
{mode === 'add' ? 'Начислить баллы' : 'Снять баллы'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: recent transactions */}
|
||||
<div>
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-slate-200 dark:border-slate-700">
|
||||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300">История ручных операций</h3>
|
||||
</div>
|
||||
{transactions.length === 0 ? (
|
||||
<div className="py-10 text-center">
|
||||
<AlertCircle size={28} className="mx-auto text-slate-300 dark:text-slate-600 mb-2" />
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Операций пока нет</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-100 dark:divide-slate-700">
|
||||
{transactions.map(tx => (
|
||||
<div key={tx.id} className="px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">{tx.guestName}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">{tx.reason}</p>
|
||||
{tx.notes && <p className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{tx.notes}</p>}
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<p className={cn(
|
||||
'text-sm font-bold',
|
||||
tx.amount > 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400',
|
||||
)}>
|
||||
{tx.amount > 0 ? '+' : ''}{tx.amount.toLocaleString('ru-RU')} б.
|
||||
</p>
|
||||
<p className="text-[10px] text-slate-400">{tx.date} · {tx.staff}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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<PageTab>('overview')
|
||||
const [levels, setLevels] = useState<LoyaltyLevel[]>(DEFAULT_LEVELS)
|
||||
const [settings, setSettings] = useState<ProgramSettings>(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 (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -329,7 +842,7 @@ export function LoyaltyPage() {
|
||||
{ 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 => (
|
||||
<div key={s.label} className="card p-4 flex items-center gap-3">
|
||||
<div className={cn('w-10 h-10 rounded-xl flex items-center justify-center shrink-0', s.bg)}>
|
||||
@@ -343,6 +856,25 @@ export function LoyaltyPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-700">
|
||||
{PAGE_TABS.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>
|
||||
|
||||
{tab === 'overview' && (
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
<div className="xl:col-span-2 space-y-5">
|
||||
{/* Program settings */}
|
||||
@@ -355,24 +887,9 @@ export function LoyaltyPage() {
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{[
|
||||
{
|
||||
label: 'Начисление',
|
||||
desc: `${settings.pointsPerRuble} балл за 1 ₽`,
|
||||
field: 'pointsPerRuble' as const,
|
||||
unit: 'балл/₽',
|
||||
},
|
||||
{
|
||||
label: 'Ценность балла',
|
||||
desc: `1 балл = ${settings.pointValue} ₽`,
|
||||
field: 'pointValue' as const,
|
||||
unit: '₽',
|
||||
},
|
||||
{
|
||||
label: 'Срок действия',
|
||||
desc: `${settings.expiryMonths} месяцев`,
|
||||
field: 'expiryMonths' as const,
|
||||
unit: 'мес.',
|
||||
},
|
||||
{ label: 'Начисление', desc: `${settings.pointsPerRuble} балл за 1 ₽`, field: 'pointsPerRuble' as const, unit: 'балл/₽' },
|
||||
{ label: 'Ценность', desc: `1 балл = ${settings.pointValue} ₽`, field: 'pointValue' as const, unit: '₽' },
|
||||
{ label: 'Срок действия', desc: `${settings.expiryMonths} месяцев`, field: 'expiryMonths' as const, unit: 'мес.' },
|
||||
].map(item => (
|
||||
<div key={item.field}>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">{item.label}</p>
|
||||
@@ -456,10 +973,8 @@ export function LoyaltyPage() {
|
||||
|
||||
{/* Right column */}
|
||||
<div className="space-y-4">
|
||||
{/* Guest portal preview */}
|
||||
<GuestPortalPreview settings={settings} />
|
||||
|
||||
{/* Redemption info */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300">Как работает программа</h3>
|
||||
{[
|
||||
@@ -477,14 +992,18 @@ export function LoyaltyPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 p-3 rounded-xl bg-brand-50 dark:bg-brand-900/10 border border-brand-200 dark:border-brand-800">
|
||||
<Info size={14} className="text-brand-600 dark:text-brand-400 mt-0.5 shrink-0" />
|
||||
<p className="text-xs text-brand-700 dark:text-brand-400">
|
||||
В будущих версиях баллы будут работать во всех отелях сети HotelSync — единая программа для всего бренда.
|
||||
<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">
|
||||
<Info 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">
|
||||
Программа лояльности работает в рамках вашего отеля. Баллы гостей хранятся в HotelSync и привязаны к конкретному объекту размещения.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'accrual' && <AccrualRulesTab />}
|
||||
{tab === 'manual' && <ManualAccrualTab />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buffer time */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Технический перерыв между бронями
|
||||
</label>
|
||||
<select
|
||||
className="input"
|
||||
value={form.bufferMinutes}
|
||||
onChange={e => 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 => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-slate-400 mt-1">Время для уборки СПА, подготовки корта и т.д.</p>
|
||||
</div>
|
||||
|
||||
{/* Hours */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
|
||||
@@ -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<PageTab>('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<RoomOrder[]>(() => [
|
||||
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' && (
|
||||
<div className="max-w-lg space-y-5">
|
||||
|
||||
{/* QR / Guest link */}
|
||||
<div className="card p-5 space-y-4">
|
||||
<h3 className="font-semibold text-slate-900 dark:text-slate-100 flex items-center gap-2">
|
||||
<QrCode size={16} className="text-brand-600" />
|
||||
Страница гостя (QR-код)
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
Распечатайте QR-код и разместите в номерах. Гость откроет страницу, выберет блюда, оплатит и будет следить за статусом заказа.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 p-2.5 rounded-xl bg-slate-50 dark:bg-slate-700/50 border border-slate-200 dark:border-slate-600">
|
||||
<span className="text-xs font-mono text-slate-700 dark:text-slate-300 flex-1 truncate">{GUEST_LINK}</span>
|
||||
<a
|
||||
href={GUEST_LINK}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="shrink-0 p-1.5 rounded-lg hover:bg-slate-200 dark:hover:bg-slate-600 text-slate-500 transition-colors"
|
||||
>
|
||||
<ExternalLink size={13} />
|
||||
</a>
|
||||
<button
|
||||
onClick={copyLink}
|
||||
className="shrink-0 flex items-center gap-1 px-2.5 py-1.5 rounded-lg bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 text-xs font-medium hover:border-brand-400 transition-colors"
|
||||
>
|
||||
{linkCopied ? <CheckCheck size={11} className="text-emerald-600" /> : <Copy size={11} />}
|
||||
{linkCopied ? 'Скопировано' : 'Копировать'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 rounded-xl bg-slate-100 dark:bg-slate-700/50 border border-slate-200 dark:border-slate-600">
|
||||
<QrCode size={64} className="text-slate-700 dark:text-slate-300 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">QR-код для печати</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||||
Распечатайте и ламинируйте для каждого номера
|
||||
</p>
|
||||
<button className="mt-2 text-xs text-brand-600 dark:text-brand-400 hover:underline font-medium">
|
||||
Скачать PNG
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 flex items-center gap-1">
|
||||
<Bell size={10} />
|
||||
При каждом новом заказе с этой страницы — уведомление в центре уведомлений
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Service settings */}
|
||||
<div className="card p-5 space-y-4">
|
||||
<h3 className="font-semibold text-slate-900 dark:text-slate-100">Настройки обслуживания</h3>
|
||||
|
||||
@@ -439,44 +518,60 @@ export function RoomServicePage() {
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={50}
|
||||
className="input w-24"
|
||||
type="number" min={0} max={50} className="input w-24"
|
||||
value={serviceChargePct}
|
||||
onChange={e => setServiceChargePct(Math.max(0, Math.min(50, parseInt(e.target.value) || 0)))}
|
||||
/>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">%</span>
|
||||
<span className="text-xs text-slate-400">
|
||||
(0 = без доплаты, начисляется только для доставки в номер)
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">(0 = без доплаты)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment types */}
|
||||
{/* Payment modes */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Оплата доставки в номер
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<input type="checkbox" defaultChecked className="rounded" />
|
||||
<CreditCard size={14} className="text-emerald-600" />
|
||||
Онлайн-эквайринг (через сайт room service)
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<input type="checkbox" defaultChecked className="rounded" />
|
||||
<UtensilsCrossed size={14} className="text-slate-500" />
|
||||
Включить в счёт при выезде
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Способы оплаты для гостя
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-3 rounded-xl border border-slate-200 dark:border-slate-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<CreditCard size={16} className="text-emerald-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">Оплата онлайн сразу</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">Гость оплачивает при заказе через эквайринг</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => 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')}
|
||||
>
|
||||
<div className={cn('absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform', payOnline ? 'left-[22px]' : 'left-1')} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 rounded-xl border border-slate-200 dark:border-slate-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<UtensilsCrossed size={16} className="text-violet-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">Включить в счёт при выезде</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">Заказ добавляется к итоговому счёту гостя</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => 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')}
|
||||
>
|
||||
<div className={cn('absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform', payOnBill ? 'left-[22px]' : 'left-1')} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{!payOnline && !payOnBill && (
|
||||
<p className="text-xs text-red-500 mt-2">Включите хотя бы один способ оплаты</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Restaurant scheduling */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||
Ресторан
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Ресторан</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<input type="checkbox" defaultChecked className="rounded" />
|
||||
@@ -489,21 +584,10 @@ export function RoomServicePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="btn-primary">Сохранить настройки</button>
|
||||
</div>
|
||||
|
||||
<div className="card p-5 border-blue-200 dark:border-blue-700/50 bg-blue-50/50 dark:bg-blue-900/10">
|
||||
<div className="flex items-start gap-3">
|
||||
<Plus size={16} className="text-blue-600 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900 dark:text-slate-100 text-sm">Сайт Room Service</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
||||
В будущем гости смогут самостоятельно делать заказы через сайт
|
||||
<strong> roomservice.hotelsync.ru/grand-palace</strong> с оплатой онлайн через эквайринг.
|
||||
Заказ автоматически появится в этом интерфейсе.
|
||||
<p className="text-xs text-slate-400 border-t border-slate-100 dark:border-slate-700 pt-3">
|
||||
В будущих версиях: интеграция с термопринтером на кухне и кухонный экран на планшете
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn-primary">Сохранить настройки</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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' && (
|
||||
<>
|
||||
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Статусы гостей</h2>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
Теги отображаются при создании и редактировании бронирования
|
||||
</p>
|
||||
|
||||
{/* Tag list */}
|
||||
<div className="space-y-2">
|
||||
{guestTags.map(tag => (
|
||||
<div
|
||||
key={tag.id}
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-slate-50 dark:bg-slate-700/40"
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
value={tag.color}
|
||||
onChange={e => setGuestTags(prev => prev.map(t => t.id === tag.id ? { ...t, color: e.target.value } : t))}
|
||||
className="w-7 h-7 rounded cursor-pointer border-0 bg-transparent"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="input flex-1"
|
||||
value={tag.label}
|
||||
onChange={e => setGuestTags(prev => prev.map(t => t.id === tag.id ? { ...t, label: e.target.value } : t))}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setGuestTags(prev => prev.filter(t => t.id !== tag.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"
|
||||
>
|
||||
<XIcon size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add new tag */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<input
|
||||
type="color"
|
||||
value={newTagColor}
|
||||
onChange={e => setNewTagColor(e.target.value)}
|
||||
className="w-7 h-7 rounded cursor-pointer border-0 bg-transparent shrink-0"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="input flex-1"
|
||||
placeholder="Название нового статуса..."
|
||||
value={newTagLabel}
|
||||
onChange={e => setNewTagLabel(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && newTagLabel.trim()) {
|
||||
setGuestTags(prev => [...prev, { id: `tag-${Date.now()}`, label: newTagLabel.trim(), color: newTagColor }])
|
||||
setNewTagLabel('')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!newTagLabel.trim()) return
|
||||
setGuestTags(prev => [...prev, { id: `tag-${Date.now()}`, label: newTagLabel.trim(), color: newTagColor }])
|
||||
setNewTagLabel('')
|
||||
}}
|
||||
className="btn-primary"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="pt-2 border-t border-slate-100 dark:border-slate-700">
|
||||
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2">Предпросмотр тегов</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{guestTags.map(tag => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="px-2.5 py-1 rounded-lg text-xs font-medium text-white"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
>
|
||||
{tag.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── THEME ── */}
|
||||
{section === 'theme' && (
|
||||
<>
|
||||
@@ -402,6 +323,18 @@ export function SettingsPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar compact mode */}
|
||||
<div className="pt-3 border-t border-slate-100 dark:border-slate-700">
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Настройки календаря</p>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-slate-50 dark:bg-slate-700/40">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">Компактный режим</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">Уменьшенные строки в сетке бронирований</p>
|
||||
</div>
|
||||
<Toggle on={calendarCompact} onChange={toggleCalendarCompact} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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,13 +18,27 @@ 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<string, boolean>
|
||||
}
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const ROLE_META: Record<StaffRole, { label: string; color: string; icon: React.ElementType }> = {
|
||||
@@ -47,6 +62,112 @@ const DEFAULT_POSITIONS: Record<StaffRole, string[]> = {
|
||||
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[] = [
|
||||
@@ -296,9 +417,272 @@ function UserModal({
|
||||
)
|
||||
}
|
||||
|
||||
// ── Roles & Permissions Tab ────────────────────────────────────────────────────
|
||||
|
||||
function RolesTab() {
|
||||
const [roles, setRoles] = useState<RolePermissions[]>(INITIAL_ROLE_PERMISSIONS)
|
||||
const [selectedRoleId, setSelectedRoleId] = useState<string>(INITIAL_ROLE_PERMISSIONS[0].id)
|
||||
const [newRoleName, setNewRoleName] = useState('')
|
||||
const [addingRole, setAddingRole] = useState(false)
|
||||
const [deleteId, setDeleteId] = useState<string | null>(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 (
|
||||
<div className="flex gap-5 items-start">
|
||||
{/* Role list */}
|
||||
<div className="w-52 shrink-0">
|
||||
<div className="card overflow-hidden">
|
||||
<div className="p-3 border-b border-slate-100 dark:border-slate-700">
|
||||
<p className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">Роли</p>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100 dark:divide-slate-700">
|
||||
{roles.map(role => (
|
||||
<div
|
||||
key={role.id}
|
||||
onClick={() => 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',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: role.color }} />
|
||||
<span className={cn(
|
||||
'text-sm font-medium truncate',
|
||||
selectedRoleId === role.id
|
||||
? 'text-brand-700 dark:text-brand-300'
|
||||
: 'text-slate-700 dark:text-slate-300',
|
||||
)}>
|
||||
{role.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0 ml-1">
|
||||
{role.isSystem && (
|
||||
<Lock size={10} className="text-slate-300 dark:text-slate-600" />
|
||||
)}
|
||||
{!role.isSystem && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setDeleteId(role.id) }}
|
||||
className="p-0.5 rounded text-slate-300 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<XIcon size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add role */}
|
||||
<div className="p-3 border-t border-slate-100 dark:border-slate-700">
|
||||
{addingRole ? (
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
className="input text-xs py-1.5 px-2 flex-1 min-w-0"
|
||||
placeholder="Название роли"
|
||||
value={newRoleName}
|
||||
onChange={e => setNewRoleName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') createRole(); if (e.key === 'Escape') setAddingRole(false) }}
|
||||
/>
|
||||
<button onClick={createRole} className="p-1.5 rounded-lg bg-brand-600 text-white hover:bg-brand-700 transition-colors shrink-0">
|
||||
<Check size={12} />
|
||||
</button>
|
||||
<button onClick={() => 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">
|
||||
<XIcon size={12} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => 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"
|
||||
>
|
||||
<Plus size={12} />
|
||||
Добавить роль
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Permissions editor */}
|
||||
{selectedRole && (
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="card overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-slate-100 dark:border-slate-700 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: selectedRole.color }} />
|
||||
<h3 className="font-semibold text-slate-900 dark:text-slate-100">{selectedRole.name}</h3>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{activeCount} из {totalCount} разделов
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => 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"
|
||||
>
|
||||
Снять все
|
||||
</button>
|
||||
<button
|
||||
onClick={() => 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"
|
||||
>
|
||||
Выбрать все
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Module groups */}
|
||||
<div className="p-4 space-y-5">
|
||||
{MODULE_GROUPS.map(group => (
|
||||
<div key={group.group}>
|
||||
<p className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wide mb-2">
|
||||
{group.group}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{group.modules.map(mod => {
|
||||
const enabled = selectedRole.permissions[mod.key] ?? false
|
||||
return (
|
||||
<button
|
||||
key={mod.key}
|
||||
type="button"
|
||||
onClick={() => 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',
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'w-5 h-5 rounded-md border-2 flex items-center justify-center shrink-0 mt-0.5 transition-colors',
|
||||
enabled
|
||||
? 'bg-brand-600 border-brand-600'
|
||||
: 'border-slate-300 dark:border-slate-600',
|
||||
)}>
|
||||
{enabled && <Check size={11} className="text-white" />}
|
||||
</div>
|
||||
<div>
|
||||
<p className={cn(
|
||||
'text-sm font-medium',
|
||||
enabled
|
||||
? 'text-brand-700 dark:text-brand-300'
|
||||
: 'text-slate-700 dark:text-slate-300',
|
||||
)}>
|
||||
{mod.label}
|
||||
</p>
|
||||
{mod.description && (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5 leading-relaxed">
|
||||
{mod.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedRole.isSystem && (
|
||||
<div className="px-4 pb-4">
|
||||
<div className="flex items-center gap-2 p-3 rounded-xl bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800">
|
||||
<Lock size={14} className="text-amber-600 dark:text-amber-400 shrink-0" />
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300">
|
||||
Системная роль — изменения применяются немедленно для всех сотрудников с этой ролью
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete role confirm */}
|
||||
{deleteId && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-xl p-6 max-w-sm w-full">
|
||||
<h3 className="text-lg font-bold text-slate-900 dark:text-slate-100 mb-2">Удалить роль?</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mb-5">
|
||||
Сотрудники с этой ролью потеряют связанные права доступа. Действие нельзя отменить.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => setDeleteId(null)} className="btn-secondary flex-1 justify-center">Отмена</button>
|
||||
<button
|
||||
onClick={() => 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"
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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<PageTab>('staff')
|
||||
const [users, setUsers] = useState<StaffUser[]>(MOCK_USERS)
|
||||
const [search, setSearch] = useState('')
|
||||
const [roleFilter, setRoleFilter] = useState<StaffRole | 'all'>('all')
|
||||
@@ -335,7 +719,6 @@ export function UsersPage() {
|
||||
const stats = {
|
||||
total: users.length,
|
||||
active: users.filter(u => u.isActive).length,
|
||||
managers: users.filter(u => u.role === 'hotel_manager').length,
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -346,12 +729,34 @@ export function UsersPage() {
|
||||
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">Сотрудники</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">{stats.total} пользователей · {stats.active} активных</p>
|
||||
</div>
|
||||
{tab === 'staff' && (
|
||||
<button className="btn-primary" onClick={openCreate}>
|
||||
<Plus size={15} />
|
||||
<span className="hidden sm:inline">Добавить сотрудника</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-700">
|
||||
{PAGE_TABS.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>
|
||||
|
||||
{tab === 'staff' && (
|
||||
<>
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{(Object.entries(ROLE_META) as [StaffRole, typeof ROLE_META[StaffRole]][]).map(([role, meta]) => {
|
||||
@@ -531,6 +936,10 @@ export function UsersPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'roles' && <RolesTab />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user