- LoyaltyPage: levelBadge() now falls back to bronze for unknown tiers (DB had loyalty_tier='standard' from old seed data) - migration 040: deduplicate general chat rooms + add unique index, normalize loyalty_tier to bronze for non-standard values - chat.ts: ON CONFLICT clause now targets the new partial unique index - Sidebar: DEFAULT_GROUPS only opens 'basics' by default; bump localStorage key to sidebarGroups_v2 to clear stale stored state Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1019 lines
46 KiB
TypeScript
1019 lines
46 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react'
|
||
import {
|
||
Award, Star, Gift, Percent, CreditCard, Edit2, Save, Info,
|
||
Crown, Shield, User, ChevronRight, Smartphone, Mail, QrCode,
|
||
TrendingUp, Search, Plus, Minus, Check, AlertCircle, Phone,
|
||
} from 'lucide-react'
|
||
import { cn } from '../lib/utils'
|
||
import { api, type LoyaltyGuestApi, type LoyaltyTransaction } from '../lib/api'
|
||
import { useAuth } from '../contexts/AuthContext'
|
||
|
||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||
|
||
type LevelId = 'bronze' | 'silver' | 'gold' | 'platinum'
|
||
|
||
interface LoyaltyLevel {
|
||
id: LevelId
|
||
name: string
|
||
minPoints: number
|
||
maxPoints: number | null
|
||
discount: number
|
||
bonusPercent: number
|
||
perks: string[]
|
||
color: string
|
||
icon: React.ElementType
|
||
}
|
||
|
||
interface ProgramSettings {
|
||
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[] = [
|
||
{
|
||
id: 'bronze',
|
||
name: 'Бронза',
|
||
minPoints: 0,
|
||
maxPoints: 999,
|
||
discount: 0,
|
||
bonusPercent: 3,
|
||
perks: ['Накопление бонусных баллов 3%', 'Приоритет на бесплатный Wi-Fi', 'Поздний выезд по запросу'],
|
||
color: 'text-amber-700 dark:text-amber-500',
|
||
icon: User,
|
||
},
|
||
{
|
||
id: 'silver',
|
||
name: 'Серебро',
|
||
minPoints: 1000,
|
||
maxPoints: 4999,
|
||
discount: 5,
|
||
bonusPercent: 5,
|
||
perks: ['Скидка 5% на проживание', 'Накопление 5%', 'Бесплатный ранний заезд', 'Комплимент от отеля'],
|
||
color: 'text-slate-500 dark:text-slate-400',
|
||
icon: Shield,
|
||
},
|
||
{
|
||
id: 'gold',
|
||
name: 'Золото',
|
||
minPoints: 5000,
|
||
maxPoints: 14999,
|
||
discount: 10,
|
||
bonusPercent: 8,
|
||
perks: ['Скидка 10% на проживание', 'Накопление 8%', 'Гарантированный апгрейд номера', 'Приветственный напиток'],
|
||
color: 'text-yellow-500 dark:text-yellow-400',
|
||
icon: Award,
|
||
},
|
||
{
|
||
id: 'platinum',
|
||
name: 'Платина',
|
||
minPoints: 15000,
|
||
maxPoints: null,
|
||
discount: 15,
|
||
bonusPercent: 12,
|
||
perks: ['Скидка 15% на проживание', 'Накопление 12%', 'VIP-заезд/выезд', 'Персональный менеджер', 'Ежегодный бонус 500 баллов'],
|
||
color: 'text-violet-500 dark:text-violet-400',
|
||
icon: Crown,
|
||
},
|
||
]
|
||
|
||
const DEFAULT_SETTINGS: ProgramSettings = {
|
||
pointsPerRuble: 1,
|
||
pointValue: 0.5,
|
||
expiryMonths: 24,
|
||
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', 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: string) {
|
||
const level = DEFAULT_LEVELS.find(l => l.id === levelId) ?? DEFAULT_LEVELS[0]
|
||
const safeId = (DEFAULT_LEVELS.find(l => l.id === levelId)?.id ?? 'bronze') as LevelId
|
||
const bgs: Record<LevelId, string> = {
|
||
bronze: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
|
||
silver: 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300',
|
||
gold: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||
platinum: 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400',
|
||
}
|
||
return (
|
||
<span className={cn('inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold', bgs[safeId])}>
|
||
<level.icon size={10} />
|
||
{level.name}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
// ─── Level Editor ─────────────────────────────────────────────────────────────
|
||
|
||
function LevelCard({ level, onEdit }: { level: LoyaltyLevel; onEdit: (l: LoyaltyLevel) => void }) {
|
||
const [editing, setEditing] = useState(false)
|
||
const [form, setForm] = useState(level)
|
||
|
||
const save = () => { onEdit(form); setEditing(false) }
|
||
|
||
const levelBg: Record<LevelId, string> = {
|
||
bronze: 'from-amber-50 to-orange-50 border-amber-200 dark:from-amber-900/10 dark:to-orange-900/10 dark:border-amber-800',
|
||
silver: 'from-slate-50 to-slate-100 border-slate-200 dark:from-slate-800/50 dark:to-slate-700/50 dark:border-slate-600',
|
||
gold: 'from-yellow-50 to-amber-50 border-yellow-200 dark:from-yellow-900/10 dark:to-amber-900/10 dark:border-yellow-800',
|
||
platinum: 'from-violet-50 to-purple-50 border-violet-200 dark:from-violet-900/10 dark:to-purple-900/10 dark:border-violet-800',
|
||
}
|
||
|
||
return (
|
||
<div className={cn('rounded-2xl border bg-gradient-to-br p-4', levelBg[level.id])}>
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className="flex items-center gap-2">
|
||
<level.icon size={20} className={level.color} />
|
||
<span className={cn('font-bold text-base', level.color)}>{level.name}</span>
|
||
</div>
|
||
<button
|
||
onClick={() => setEditing(!editing)}
|
||
className="p-1.5 rounded-lg text-slate-400 hover:text-brand-600 hover:bg-white/60 transition-colors"
|
||
>
|
||
{editing ? <Save size={15} /> : <Edit2 size={15} />}
|
||
</button>
|
||
</div>
|
||
|
||
{editing ? (
|
||
<div className="space-y-2">
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<label className="block text-xs text-slate-500 mb-1">Мин. баллов</label>
|
||
<input type="number" className="input text-sm" value={form.minPoints}
|
||
onChange={e => setForm(p => ({ ...p, minPoints: parseInt(e.target.value) || 0 }))} />
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs text-slate-500 mb-1">Скидка %</label>
|
||
<input type="number" min={0} max={50} className="input text-sm" value={form.discount}
|
||
onChange={e => setForm(p => ({ ...p, discount: parseInt(e.target.value) || 0 }))} />
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs text-slate-500 mb-1">Начисление баллов %</label>
|
||
<input type="number" min={0} max={20} className="input text-sm" value={form.bonusPercent}
|
||
onChange={e => setForm(p => ({ ...p, bonusPercent: parseInt(e.target.value) || 0 }))} />
|
||
</div>
|
||
<button onClick={save} className="btn-primary w-full text-sm py-1.5">Сохранить</button>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-1.5">
|
||
<div className="flex justify-between text-xs">
|
||
<span className="text-slate-500">Порог баллов</span>
|
||
<span className="font-semibold text-slate-700 dark:text-slate-300">
|
||
{level.minPoints.toLocaleString('ru-RU')}
|
||
{level.maxPoints ? ` — ${level.maxPoints.toLocaleString('ru-RU')}` : '+'}
|
||
</span>
|
||
</div>
|
||
<div className="flex justify-between text-xs">
|
||
<span className="text-slate-500">Скидка на проживание</span>
|
||
<span className={cn('font-bold', level.color)}>{level.discount}%</span>
|
||
</div>
|
||
<div className="flex justify-between text-xs">
|
||
<span className="text-slate-500">Начисление баллов</span>
|
||
<span className="font-semibold text-slate-700 dark:text-slate-300">{level.bonusPercent}% от суммы</span>
|
||
</div>
|
||
<div className="mt-2 pt-2 border-t border-white/50 dark:border-slate-600/50 space-y-1">
|
||
{level.perks.map((perk, i) => (
|
||
<div key={i} className="flex items-start gap-1.5 text-xs text-slate-600 dark:text-slate-300">
|
||
<span className="text-emerald-500 mt-0.5 shrink-0">✓</span>
|
||
{perk}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Guest Portal Preview ─────────────────────────────────────────────────────
|
||
|
||
function GuestPortalPreview({ settings }: { settings: ProgramSettings }) {
|
||
const guest = MOCK_GUESTS[0]
|
||
const level = DEFAULT_LEVELS.find(l => l.id === guest.level)!
|
||
const nextLevel = DEFAULT_LEVELS.find(l => l.minPoints > guest.points)
|
||
const progressToNext = nextLevel
|
||
? ((guest.points - level.minPoints) / (nextLevel.minPoints - level.minPoints)) * 100
|
||
: 100
|
||
|
||
return (
|
||
<div className="rounded-2xl border-2 border-dashed border-slate-300 dark:border-slate-600 p-4">
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<Smartphone size={14} className="text-slate-400" />
|
||
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wide">
|
||
Личный кабинет гостя (превью)
|
||
</p>
|
||
</div>
|
||
|
||
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl p-4 text-white space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-xs text-slate-400">Добро пожаловать,</p>
|
||
<p className="font-bold text-sm">{guest.name.split(' ')[0]}</p>
|
||
</div>
|
||
<div className="text-right">
|
||
<p className="text-xs text-slate-400">Уровень</p>
|
||
<div className="flex items-center gap-1">
|
||
<level.icon size={12} className={level.color} />
|
||
<p className={cn('text-sm font-bold', level.color)}>{level.name}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<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>
|
||
<p className="text-xs text-slate-400 mt-0.5">
|
||
≈ {Math.round(guest.points * settings.pointValue).toLocaleString('ru-RU')} ₽ к оплате
|
||
</p>
|
||
</div>
|
||
|
||
{nextLevel && (
|
||
<div>
|
||
<div className="flex justify-between text-xs text-slate-400 mb-1">
|
||
<span>До {nextLevel.name}</span>
|
||
<span>{(nextLevel.minPoints - guest.points).toLocaleString('ru-RU')} баллов</span>
|
||
</div>
|
||
<div className="h-2 rounded-full bg-white/20">
|
||
<div
|
||
className="h-2 rounded-full bg-gradient-to-r from-brand-400 to-brand-600"
|
||
style={{ width: `${Math.min(100, progressToNext)}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-3 gap-2">
|
||
{[
|
||
{ icon: Percent, label: `${level.discount}% скидка` },
|
||
{ icon: Gift, label: 'Комплимент' },
|
||
{ icon: CreditCard, label: 'Оплата баллами' },
|
||
].map(p => (
|
||
<div key={p.label} className="bg-white/5 rounded-lg p-2 text-center">
|
||
<p.icon size={14} className="mx-auto text-brand-400 mb-1" />
|
||
<p className="text-[10px] text-slate-300">{p.label}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3 mt-3 pt-3 border-t border-slate-200 dark:border-slate-700">
|
||
<QrCode size={14} className="text-slate-400 shrink-0" />
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||
Гость получает ссылку на личный кабинет по email после первого бронирования.
|
||
Доступ — без пароля, по magic link.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── 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({ slug }: { slug: string }) {
|
||
const [searchResults, setSearchResults] = useState<LoyaltyGuestApi[]>([])
|
||
const [search, setSearch] = useState('')
|
||
const [selectedGuest, setSelectedGuest] = useState<LoyaltyGuestApi | 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<LoyaltyTransaction[]>([])
|
||
const [success, setSuccess] = useState(false)
|
||
const [submitting, setSubmitting] = useState(false)
|
||
|
||
const doSearch = useCallback(async (q: string) => {
|
||
if (!q.trim() || !slug) { setSearchResults([]); return }
|
||
try {
|
||
const data = await api.loyalty.listGuests(slug, q)
|
||
setSearchResults(data)
|
||
} catch { setSearchResults([]) }
|
||
}, [slug])
|
||
|
||
useEffect(() => {
|
||
const t = setTimeout(() => doSearch(search), 300)
|
||
return () => clearTimeout(t)
|
||
}, [search, doSearch])
|
||
|
||
useEffect(() => {
|
||
if (!slug) return
|
||
api.loyalty.listTransactions(slug, 20).then(setTransactions).catch(() => {})
|
||
}, [slug, success])
|
||
|
||
const handleSubmit = async () => {
|
||
if (!selectedGuest || !amount || !reason || submitting) return
|
||
const pts = parseInt(amount)
|
||
if (isNaN(pts) || pts <= 0) return
|
||
const delta = mode === 'add' ? pts : -pts
|
||
setSubmitting(true)
|
||
try {
|
||
const updated = await api.loyalty.addPoints(slug, selectedGuest.id, { amount: delta, reason, notes: notes || undefined })
|
||
setSelectedGuest(updated)
|
||
setAmount('')
|
||
setNotes('')
|
||
setReason('')
|
||
setSuccess(true)
|
||
setTimeout(() => setSuccess(false), 3000)
|
||
} catch { /* ignore */ }
|
||
finally { setSubmitting(false) }
|
||
}
|
||
|
||
const updatedGuest = selectedGuest
|
||
|
||
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.loyaltyTier as LevelId)}
|
||
<span className="text-xs font-semibold text-slate-600 dark:text-slate-300">
|
||
{g.loyaltyPoints.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.loyaltyPoints.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?.loyaltyPoints ?? 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={() => { void handleSubmit() }}
|
||
disabled={!amount || !reason || submitting}
|
||
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">{new Date(tx.createdAt).toLocaleDateString('ru-RU')}{tx.staffName ? ` · ${tx.staffName}` : ''}</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 { user } = useAuth()
|
||
const slug = user?.hotelSlug ?? ''
|
||
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)
|
||
const [guests, setGuests] = useState<LoyaltyGuestApi[]>([])
|
||
|
||
useEffect(() => {
|
||
if (!slug) return
|
||
api.loyalty.getSettings(slug).then(s => setSettings(s)).catch(() => {})
|
||
api.loyalty.listGuests(slug).then(g => setGuests(g)).catch(() => {})
|
||
}, [slug])
|
||
|
||
const saveSettings = async () => {
|
||
if (!slug) return
|
||
const saved = await api.loyalty.saveSettings(slug, settings).catch(() => null)
|
||
if (saved) setSettings(saved)
|
||
setEditSettings(false)
|
||
}
|
||
|
||
const updateLevel = (updated: LoyaltyLevel) =>
|
||
setLevels(prev => prev.map(l => l.id === updated.id ? updated : l))
|
||
|
||
const totalGuests = guests.length
|
||
const activeGuests = guests.filter(g => g.loyaltyPoints > 0).length
|
||
const totalPoints = guests.reduce((s, g) => s + g.loyaltyPoints, 0)
|
||
|
||
return (
|
||
<div className="p-4 md:p-6 space-y-5">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Программа лояльности</h1>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
|
||
Уровни, бонусные баллы, личный кабинет гостя
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<span className={cn(
|
||
'px-3 py-1.5 rounded-xl text-sm font-medium border',
|
||
settings.isActive
|
||
? 'bg-emerald-50 text-emerald-700 border-emerald-300 dark:bg-emerald-900/20 dark:text-emerald-400 dark:border-emerald-700'
|
||
: 'bg-slate-100 text-slate-500 border-slate-200 dark:bg-slate-700 dark:text-slate-400 dark:border-slate-600',
|
||
)}>
|
||
{settings.isActive ? '● Программа активна' : '○ Программа отключена'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Stats */}
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||
{[
|
||
{ 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: guests.filter(g => g.loyaltyTier === 'platinum').length, icon: Crown, color: 'text-violet-600 dark:text-violet-400', bg: 'bg-violet-50 dark:bg-violet-900/20' },
|
||
{ label: 'Средний балл / гость', value: totalGuests > 0 ? Math.round(totalPoints / totalGuests).toLocaleString('ru-RU') : '0', 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)}>
|
||
<s.icon size={18} className={s.color} />
|
||
</div>
|
||
<div>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 leading-tight">{s.label}</p>
|
||
<p className="text-xl font-bold text-slate-900 dark:text-slate-100 mt-0.5">{s.value}</p>
|
||
</div>
|
||
</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={() => editSettings ? saveSettings() : setEditSettings(true)} 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-1 sm: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">
|
||
{guests.slice(0, 10).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.loyaltyTier as LevelId)}</td>
|
||
<td className="px-4 py-3 text-right">
|
||
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||
{g.loyaltyPoints.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 slug={slug} />}
|
||
</div>
|
||
)
|
||
}
|