Files
fitcrm/apps/web-platform-admin/src/app/clubs/page.tsx
root 0f23e4fdce
Some checks failed
CI / Lint & Format (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Build All Apps (push) Has been cancelled
feat: BullMQ очереди, 2FA/TOTP, audit logs, rate limiting, 310 unit-тестов, CRUD-диалоги веб-панелей
Фаза 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>
2026-02-19 07:59:20 +00:00

413 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}