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:
@@ -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 }) {
|
||||
</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>
|
||||
@@ -326,10 +839,10 @@ export function LoyaltyPage() {
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ 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 => (
|
||||
<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,148 +856,154 @@ export function LoyaltyPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
<div className="xl:col-span-2 space-y-5">
|
||||
{/* Program settings */}
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300">Настройки программы</h2>
|
||||
<button onClick={() => setEditSettings(!editSettings)} className="btn-secondary flex items-center gap-1.5 text-sm">
|
||||
{editSettings ? <><Save size={13} /> Сохранить</> : <><Edit2 size={13} /> Изменить</>}
|
||||
</button>
|
||||
</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: 'мес.',
|
||||
},
|
||||
].map(item => (
|
||||
<div key={item.field}>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">{item.label}</p>
|
||||
{editSettings ? (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<input
|
||||
type="number" step="0.1" min={0}
|
||||
className="input text-sm"
|
||||
value={settings[item.field]}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
[item.field]: parseFloat(e.target.value) || 0,
|
||||
}))}
|
||||
/>
|
||||
<span className="text-xs text-slate-400 shrink-0">{item.unit}</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-base font-semibold text-slate-900 dark:text-slate-100 mt-0.5">{item.desc}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Level cards */}
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-slate-600 dark:text-slate-400 uppercase tracking-wide mb-3">
|
||||
Уровни программы
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{levels.map(level => (
|
||||
<LevelCard key={level.id} level={level} onEdit={updateLevel} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top guests */}
|
||||
<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>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 dark:bg-slate-700/30">
|
||||
<th className="px-4 py-2 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">Гость</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">Уровень</th>
|
||||
<th className="px-4 py-2 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">Баллы</th>
|
||||
<th className="px-4 py-2 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">Потрачено</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 dark:divide-slate-700/50">
|
||||
{MOCK_GUESTS.sort((a, b) => b.points - a.points).map(g => (
|
||||
<tr key={g.id} className="hover:bg-slate-50 dark:hover:bg-slate-700/20 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center shrink-0">
|
||||
<span className="text-xs font-bold text-brand-600 dark:text-brand-400">
|
||||
{g.name.split(' ').map(p => p[0]).join('').slice(0, 2)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-slate-700 dark:text-slate-300">{g.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">{levelBadge(g.level)}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||
{g.points.toLocaleString('ru-RU')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{g.totalSpent.toLocaleString('ru-RU')} ₽
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
{[
|
||||
{ icon: CreditCard, text: 'При каждом бронировании гостю начисляются баллы пропорционально потраченной сумме' },
|
||||
{ icon: Percent, text: 'Накопленные баллы можно списать при следующем бронировании' },
|
||||
{ icon: Mail, text: 'После первого визита гость получает email с ссылкой на личный кабинет' },
|
||||
{ icon: Award, text: 'При достижении нового уровня приходит уведомление с перечнем привилегий' },
|
||||
].map((item, i) => (
|
||||
<div key={i} className="flex items-start gap-2.5">
|
||||
<div className="w-6 h-6 rounded-lg bg-brand-50 dark:bg-brand-900/20 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<item.icon size={12} className="text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<p className="text-xs text-slate-600 dark:text-slate-400">{item.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</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 — единая программа для всего бренда.
|
||||
</p>
|
||||
</div>
|
||||
</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 */}
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300">Настройки программы</h2>
|
||||
<button onClick={() => setEditSettings(!editSettings)} className="btn-secondary flex items-center gap-1.5 text-sm">
|
||||
{editSettings ? <><Save size={13} /> Сохранить</> : <><Edit2 size={13} /> Изменить</>}
|
||||
</button>
|
||||
</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: 'мес.' },
|
||||
].map(item => (
|
||||
<div key={item.field}>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">{item.label}</p>
|
||||
{editSettings ? (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<input
|
||||
type="number" step="0.1" min={0}
|
||||
className="input text-sm"
|
||||
value={settings[item.field]}
|
||||
onChange={e => setSettings(prev => ({
|
||||
...prev,
|
||||
[item.field]: parseFloat(e.target.value) || 0,
|
||||
}))}
|
||||
/>
|
||||
<span className="text-xs text-slate-400 shrink-0">{item.unit}</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-base font-semibold text-slate-900 dark:text-slate-100 mt-0.5">{item.desc}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Level cards */}
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-slate-600 dark:text-slate-400 uppercase tracking-wide mb-3">
|
||||
Уровни программы
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{levels.map(level => (
|
||||
<LevelCard key={level.id} level={level} onEdit={updateLevel} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top guests */}
|
||||
<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>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 dark:bg-slate-700/30">
|
||||
<th className="px-4 py-2 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">Гость</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">Уровень</th>
|
||||
<th className="px-4 py-2 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">Баллы</th>
|
||||
<th className="px-4 py-2 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">Потрачено</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 dark:divide-slate-700/50">
|
||||
{MOCK_GUESTS.sort((a, b) => b.points - a.points).map(g => (
|
||||
<tr key={g.id} className="hover:bg-slate-50 dark:hover:bg-slate-700/20 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center shrink-0">
|
||||
<span className="text-xs font-bold text-brand-600 dark:text-brand-400">
|
||||
{g.name.split(' ').map(p => p[0]).join('').slice(0, 2)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-slate-700 dark:text-slate-300">{g.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">{levelBadge(g.level)}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||
{g.points.toLocaleString('ru-RU')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{g.totalSpent.toLocaleString('ru-RU')} ₽
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column */}
|
||||
<div className="space-y-4">
|
||||
<GuestPortalPreview settings={settings} />
|
||||
|
||||
<div className="card p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300">Как работает программа</h3>
|
||||
{[
|
||||
{ icon: CreditCard, text: 'При каждом бронировании гостю начисляются баллы пропорционально потраченной сумме' },
|
||||
{ icon: Percent, text: 'Накопленные баллы можно списать при следующем бронировании' },
|
||||
{ icon: Mail, text: 'После первого визита гость получает email с ссылкой на личный кабинет' },
|
||||
{ icon: Award, text: 'При достижении нового уровня приходит уведомление с перечнем привилегий' },
|
||||
].map((item, i) => (
|
||||
<div key={i} className="flex items-start gap-2.5">
|
||||
<div className="w-6 h-6 rounded-lg bg-brand-50 dark:bg-brand-900/20 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<item.icon size={12} className="text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<p className="text-xs text-slate-600 dark:text-slate-400">{item.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user