Фаза 1: BullMQ + EventEmitter2 — QueueModule (webhook-delivery, push-notification, sync-1c), webhook delivery processor с HMAC-SHA256 и retry 3 попытки, webhook dispatch service с @OnEvent для 12 типов событий, эмиссия событий из бизнес-сервисов. Фаза 2: @nestjs/throttler rate limiting (1000 req/min, Redis), TOTP 2FA для суперадмина (otplib + qrcode), AuditModule с GET /admin/audit-logs. Фаза 3: 14 новых тестовых файлов (310 тестов) — auth, clients, schedule, funnel, sales, stats, notifications, webhooks, totp, metering, guards, middleware. Фаза 4: web-club-admin — 15 CRUD-диалогов (staff, departments, rooms, catalog, integrations, license, settings) + подключение к страницам. Фаза 5: web-platform-admin — create/edit club, issue license, club actions menu, CSV export audit logs + подключение к страницам. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
413 lines
14 KiB
TypeScript
413 lines
14 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useState, useCallback } from 'react';
|
||
import {
|
||
Search,
|
||
Plus,
|
||
Building2,
|
||
ChevronDown,
|
||
} from 'lucide-react';
|
||
import { cn } from '@/lib/cn';
|
||
import { api } from '@/lib/api';
|
||
import { CreateClubDialog } from '@/components/clubs/create-club-dialog';
|
||
import { EditClubDialog } from '@/components/clubs/edit-club-dialog';
|
||
import { ClubActionsMenu } from '@/components/clubs/club-actions-menu';
|
||
import { AlertDialog } from '@/components/ui/alert-dialog';
|
||
|
||
interface ApiClub {
|
||
id: string;
|
||
name: string;
|
||
slug: string;
|
||
address: string | null;
|
||
phone: string | null;
|
||
email: string | null;
|
||
logoUrl: string | null;
|
||
timezone: string;
|
||
isActive: boolean;
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
license: {
|
||
id: string;
|
||
type: 'STARTER' | 'STANDARD' | 'PREMIUM';
|
||
startDate: string;
|
||
endDate: string;
|
||
isActive: boolean;
|
||
gracePeriodEnd: string | null;
|
||
maxUsers: number;
|
||
maxClients: number;
|
||
} | null;
|
||
_count: {
|
||
users: number;
|
||
clients: number;
|
||
};
|
||
}
|
||
|
||
interface Club {
|
||
id: string;
|
||
name: string;
|
||
city: string;
|
||
license: 'Стартовая' | 'Стандартная' | 'Премиум';
|
||
users: number;
|
||
clients: number;
|
||
status: 'active' | 'grace' | 'blocked' | 'trial';
|
||
createdAt: string;
|
||
expiresAt: string;
|
||
}
|
||
|
||
const licenseTypeMap: Record<string, Club['license']> = {
|
||
STARTER: 'Стартовая',
|
||
STANDARD: 'Стандартная',
|
||
PREMIUM: 'Премиум',
|
||
};
|
||
|
||
function mapApiClubToClub(apiClub: ApiClub): Club {
|
||
let status: Club['status'] = 'active';
|
||
let expiresAt = '—';
|
||
let licenseLabel: Club['license'] = 'Стартовая';
|
||
|
||
if (apiClub.license) {
|
||
licenseLabel = licenseTypeMap[apiClub.license.type] || 'Стартовая';
|
||
const endDate = new Date(apiClub.license.endDate);
|
||
expiresAt = endDate.toLocaleDateString('ru-RU', {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric',
|
||
});
|
||
|
||
const now = new Date();
|
||
const daysUntilExpiry = Math.ceil(
|
||
(endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
|
||
);
|
||
|
||
if (!apiClub.isActive || !apiClub.license.isActive) {
|
||
status = 'blocked';
|
||
} else if (apiClub.license.gracePeriodEnd) {
|
||
status = 'grace';
|
||
} else if (daysUntilExpiry <= 0) {
|
||
status = 'blocked';
|
||
} else if (daysUntilExpiry <= 30 && daysUntilExpiry > 0) {
|
||
// Could be trial or active depending on license age
|
||
const startDate = new Date(apiClub.license.startDate);
|
||
const licenseDuration = Math.ceil(
|
||
(endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24),
|
||
);
|
||
if (licenseDuration <= 30) {
|
||
status = 'trial';
|
||
} else {
|
||
status = 'active';
|
||
}
|
||
} else {
|
||
status = 'active';
|
||
}
|
||
} else {
|
||
status = apiClub.isActive ? 'trial' : 'blocked';
|
||
}
|
||
|
||
const createdAt = new Date(apiClub.createdAt).toLocaleDateString('ru-RU', {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric',
|
||
});
|
||
|
||
return {
|
||
id: apiClub.id,
|
||
name: apiClub.name,
|
||
city: apiClub.slug,
|
||
license: licenseLabel,
|
||
users: apiClub._count.users,
|
||
clients: apiClub._count.clients,
|
||
status,
|
||
createdAt,
|
||
expiresAt,
|
||
};
|
||
}
|
||
|
||
const statusLabels: Record<Club['status'], string> = {
|
||
active: 'Активен',
|
||
grace: 'Grace period',
|
||
blocked: 'Заблокирован',
|
||
trial: 'Пробный',
|
||
};
|
||
|
||
const statusColors: Record<Club['status'], string> = {
|
||
active: 'bg-green-100 text-success',
|
||
grace: 'bg-orange-100 text-orange-700',
|
||
blocked: 'bg-red-100 text-error',
|
||
trial: 'bg-blue-100 text-blue-700',
|
||
};
|
||
|
||
const licenseColors: Record<Club['license'], string> = {
|
||
'Стартовая': 'bg-gray-100 text-gray-700',
|
||
'Стандартная': 'bg-blue-100 text-blue-700',
|
||
'Премиум': 'bg-primary/10 text-primary',
|
||
};
|
||
|
||
export default function ClubsPage() {
|
||
const [clubs, setClubs] = useState<Club[]>([]);
|
||
const [totalCount, setTotalCount] = useState(0);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [search, setSearch] = useState('');
|
||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||
|
||
// Dialog states
|
||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||
const [editClub, setEditClub] = useState<{ id: string; name: string; city: string } | null>(null);
|
||
const [deactivateClub, setDeactivateClub] = useState<{ id: string; name: string; status: string } | null>(null);
|
||
|
||
const fetchClubs = useCallback(async () => {
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const params = new URLSearchParams();
|
||
params.set('limit', '100');
|
||
if (search) {
|
||
params.set('search', search);
|
||
}
|
||
if (statusFilter === 'blocked') {
|
||
params.set('isActive', 'false');
|
||
} else if (statusFilter !== 'all') {
|
||
params.set('isActive', 'true');
|
||
}
|
||
|
||
const res = await api.get<{ data: ApiClub[]; hasMore: boolean; nextCursor: string | null }>(
|
||
`/admin/clubs?${params.toString()}`,
|
||
);
|
||
const mapped = res.data.map(mapApiClubToClub);
|
||
setClubs(mapped);
|
||
setTotalCount(mapped.length);
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : 'Ошибка загрузки клубов';
|
||
setError(message);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [search, statusFilter]);
|
||
|
||
useEffect(() => {
|
||
const debounce = setTimeout(() => {
|
||
void fetchClubs();
|
||
}, 300);
|
||
return () => clearTimeout(debounce);
|
||
}, [fetchClubs]);
|
||
|
||
const handleDeactivateConfirm = async () => {
|
||
if (!deactivateClub) return;
|
||
const isBlocked = deactivateClub.status === 'blocked';
|
||
if (isBlocked) {
|
||
await api.patch(`/admin/clubs/${deactivateClub.id}`, { isActive: true });
|
||
} else {
|
||
await api.delete(`/admin/clubs/${deactivateClub.id}`);
|
||
}
|
||
void fetchClubs();
|
||
};
|
||
|
||
const filtered = clubs.filter((club) => {
|
||
// API already handles search, but we still filter status client-side
|
||
// since API only has isActive filter, not grace/trial distinction
|
||
const matchesStatus =
|
||
statusFilter === 'all' || club.status === statusFilter;
|
||
return matchesStatus;
|
||
});
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex items-center justify-center h-64">
|
||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="flex flex-col items-center justify-center h-64 gap-4">
|
||
<p className="text-sm text-error">{error}</p>
|
||
<button
|
||
onClick={() => void fetchClubs()}
|
||
className="text-sm text-primary hover:underline"
|
||
>
|
||
Попробовать снова
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<div className="flex items-center justify-between mb-6">
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-gray-900">
|
||
Управление клубами
|
||
</h1>
|
||
<p className="text-sm text-muted mt-1">
|
||
{totalCount} клубов на платформе
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setShowCreateDialog(true)}
|
||
className="flex items-center gap-2 bg-primary text-white px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-primary/90 transition-colors"
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
Добавить клуб
|
||
</button>
|
||
</div>
|
||
|
||
<div className="bg-card rounded-xl shadow-sm border border-border">
|
||
<div className="p-4 border-b border-border flex items-center gap-4">
|
||
<div className="relative flex-1 max-w-sm">
|
||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted" />
|
||
<input
|
||
type="text"
|
||
placeholder="Поиск по названию или городу..."
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
className="w-full pl-10 pr-4 py-2 border border-border rounded-lg text-sm bg-background focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
||
/>
|
||
</div>
|
||
<div className="relative">
|
||
<select
|
||
value={statusFilter}
|
||
onChange={(e) => setStatusFilter(e.target.value)}
|
||
className="appearance-none pl-3 pr-8 py-2 border border-border rounded-lg text-sm bg-background focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
||
>
|
||
<option value="all">Все статусы</option>
|
||
<option value="active">Активные</option>
|
||
<option value="trial">Пробные</option>
|
||
<option value="grace">Grace period</option>
|
||
<option value="blocked">Заблокированные</option>
|
||
</select>
|
||
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted pointer-events-none" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b border-border bg-background/50">
|
||
<th className="text-left px-4 py-3 text-muted font-medium">
|
||
Клуб
|
||
</th>
|
||
<th className="text-left px-4 py-3 text-muted font-medium">
|
||
Лицензия
|
||
</th>
|
||
<th className="text-right px-4 py-3 text-muted font-medium">
|
||
Пользователи
|
||
</th>
|
||
<th className="text-right px-4 py-3 text-muted font-medium">
|
||
Клиенты
|
||
</th>
|
||
<th className="text-left px-4 py-3 text-muted font-medium">
|
||
Статус
|
||
</th>
|
||
<th className="text-left px-4 py-3 text-muted font-medium">
|
||
Создан
|
||
</th>
|
||
<th className="text-left px-4 py-3 text-muted font-medium">
|
||
Истекает
|
||
</th>
|
||
<th className="px-4 py-3" />
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filtered.map((club) => (
|
||
<tr
|
||
key={club.id}
|
||
className="border-b border-border/50 hover:bg-background/30 transition-colors"
|
||
>
|
||
<td className="px-4 py-3">
|
||
<div className="flex items-center gap-3">
|
||
<div className="h-9 w-9 rounded-lg bg-sidebar-bg flex items-center justify-center">
|
||
<Building2 className="h-4 w-4 text-white" />
|
||
</div>
|
||
<div>
|
||
<p className="font-medium text-gray-900">
|
||
{club.name}
|
||
</p>
|
||
<p className="text-xs text-muted">{club.city}</p>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<span
|
||
className={cn(
|
||
'inline-block px-2.5 py-1 rounded-full text-xs font-medium',
|
||
licenseColors[club.license]
|
||
)}
|
||
>
|
||
{club.license}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-3 text-right text-gray-700">
|
||
{club.users}
|
||
</td>
|
||
<td className="px-4 py-3 text-right text-gray-700">
|
||
{club.clients.toLocaleString('ru-RU')}
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<span
|
||
className={cn(
|
||
'inline-block px-2.5 py-1 rounded-full text-xs font-medium',
|
||
statusColors[club.status]
|
||
)}
|
||
>
|
||
{statusLabels[club.status]}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-3 text-muted">{club.createdAt}</td>
|
||
<td className="px-4 py-3 text-muted">{club.expiresAt}</td>
|
||
<td className="px-4 py-3">
|
||
<ClubActionsMenu
|
||
club={{ id: club.id, name: club.name, status: club.status }}
|
||
onEdit={() => setEditClub({ id: club.id, name: club.name, city: club.city })}
|
||
onToggleActive={() => setDeactivateClub({ id: club.id, name: club.name, status: club.status })}
|
||
/>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div className="px-4 py-3 border-t border-border flex items-center justify-between">
|
||
<p className="text-sm text-muted">
|
||
Показано {filtered.length} из {totalCount} клубов
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<CreateClubDialog
|
||
open={showCreateDialog}
|
||
onClose={() => setShowCreateDialog(false)}
|
||
onCreated={() => void fetchClubs()}
|
||
/>
|
||
|
||
<EditClubDialog
|
||
open={editClub !== null}
|
||
onClose={() => setEditClub(null)}
|
||
onSaved={() => void fetchClubs()}
|
||
club={editClub}
|
||
/>
|
||
|
||
<AlertDialog
|
||
open={deactivateClub !== null}
|
||
onClose={() => setDeactivateClub(null)}
|
||
onConfirm={handleDeactivateConfirm}
|
||
title={
|
||
deactivateClub?.status === 'blocked'
|
||
? 'Активация клуба'
|
||
: 'Деактивация клуба'
|
||
}
|
||
description={
|
||
deactivateClub?.status === 'blocked'
|
||
? `Вы уверены, что хотите активировать клуб «${deactivateClub?.name ?? ''}»? Доступ к системе будет восстановлен.`
|
||
: `Вы уверены, что хотите деактивировать клуб «${deactivateClub?.name ?? ''}»? Пользователи клуба потеряют доступ к системе.`
|
||
}
|
||
confirmLabel={
|
||
deactivateClub?.status === 'blocked' ? 'Активировать' : 'Деактивировать'
|
||
}
|
||
variant={deactivateClub?.status === 'blocked' ? 'warning' : 'danger'}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|