feat: API-клиент + интеграция веб-панелей + доработка metering/provisioning
Some checks failed
CI / Lint & Format (push) Has been cancelled
CI / Lint & Format (pull_request) Has been cancelled
PR Check / Lint & Typecheck (pull_request) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Build All Apps (push) Has been cancelled
CI / Backend Tests (pull_request) Has been cancelled
CI / Build All Apps (pull_request) Has been cancelled

- Реализован packages/api-client: HTTP-клиент, типы, аутентификация
- Все веб-панели (web-admin, web-club-admin, web-platform-admin) переведены на реальный API-клиент вместо моковых данных
- Добавлены lib/api.ts и lib/auth.ts для club-admin и platform-admin
- metering.service: лимиты модулей теперь берутся из club_modules.limits_json
- provisioning.service: рефакторинг под strategy pattern

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-02-18 12:23:22 +00:00
parent 5feb32e803
commit ff43106037
27 changed files with 2863 additions and 726 deletions

View File

@@ -104,7 +104,9 @@ export class MeteringService {
}
/**
* Check all resource limits for a club against its license.
* Check all resource limits for a club against its license and module limits.
* Base limits (users, clients) come from the licenses table.
* Module-specific limits (webhooks, push, call storage) come from club_modules.limits_json.
*/
async checkLimits(clubId: string): Promise<LimitCheck[]> {
const periodStart = this.getCurrentPeriodStart();
@@ -115,22 +117,11 @@ export class MeteringService {
usage = await this.aggregateUsage(clubId);
}
// Get license limits
// Get base limits from license (max_users, max_clients)
const licenses = await this.prisma.$queryRawUnsafe<
{
maxUsers: number;
maxClients: number;
maxCallStorageGb: number;
maxWebhooks: number;
maxPush: number;
maxApiRequests: number;
}[]
{ maxUsers: number; maxClients: number }[]
>(
`SELECT max_users AS "maxUsers", max_clients AS "maxClients",
COALESCE(max_call_storage_gb, 0) AS "maxCallStorageGb",
COALESCE(max_webhooks, 10000) AS "maxWebhooks",
COALESCE(max_push, 5000) AS "maxPush",
COALESCE(max_api_requests, 50000) AS "maxApiRequests"
`SELECT max_users AS "maxUsers", max_clients AS "maxClients"
FROM licenses
WHERE club_id = $1 AND is_active = true
LIMIT 1`,
@@ -141,44 +132,69 @@ export class MeteringService {
return [];
}
const limits = licenses[0];
const license = licenses[0];
// Get module-specific limits from club_modules.limits_json
const moduleLimits = await this.prisma.$queryRawUnsafe<
{ moduleId: string; limitsJson: Record<string, number> | null }[]
>(
`SELECT module_id AS "moduleId", limits_json AS "limitsJson"
FROM club_modules
WHERE club_id = $1 AND enabled = true
AND module_id IN ('webhooks', 'push', 'sip_recording')`,
clubId,
);
const moduleLimitsMap = new Map(
moduleLimits.map((m) => [m.moduleId, m.limitsJson ?? {}]),
);
const webhookLimits = moduleLimitsMap.get('webhooks') ?? {};
const pushLimits = moduleLimitsMap.get('push') ?? {};
const sipRecordingLimits = moduleLimitsMap.get('sip_recording') ?? {};
return [
{
resource: 'active_users',
current: usage.activeUsers,
limit: limits.maxUsers,
exceeded: usage.activeUsers > limits.maxUsers,
limit: license.maxUsers,
exceeded: usage.activeUsers > license.maxUsers,
},
{
resource: 'clients',
current: usage.clients,
limit: limits.maxClients,
exceeded: usage.clients > limits.maxClients,
limit: license.maxClients,
exceeded: usage.clients > license.maxClients,
},
{
resource: 'call_storage_gb',
current: usage.callStorageGb,
limit: limits.maxCallStorageGb,
exceeded: usage.callStorageGb > limits.maxCallStorageGb,
limit: (sipRecordingLimits as Record<string, number>).max_storage_gb ?? 0,
exceeded:
usage.callStorageGb >
((sipRecordingLimits as Record<string, number>).max_storage_gb ?? 0),
},
{
resource: 'webhooks_sent',
current: usage.webhooksSent,
limit: limits.maxWebhooks,
exceeded: usage.webhooksSent > limits.maxWebhooks,
limit: (webhookLimits as Record<string, number>).max_events ?? 10000,
exceeded:
usage.webhooksSent >
((webhookLimits as Record<string, number>).max_events ?? 10000),
},
{
resource: 'push_sent',
current: usage.pushSent,
limit: limits.maxPush,
exceeded: usage.pushSent > limits.maxPush,
limit: (pushLimits as Record<string, number>).max_messages ?? 5000,
exceeded:
usage.pushSent >
((pushLimits as Record<string, number>).max_messages ?? 5000),
},
{
resource: 'api_requests',
current: usage.apiRequests,
limit: limits.maxApiRequests,
exceeded: usage.apiRequests > limits.maxApiRequests,
limit: 50000,
exceeded: usage.apiRequests > 50000,
},
];
}
@@ -252,15 +268,12 @@ export class MeteringService {
/**
* Sum call recording storage in GB for a club.
* Note: Call recording is not implemented in MVP (sip_calls stores metadata only).
* Returns 0 until a call_recordings table is added.
*/
private async sumCallStorage(clubId: string): Promise<number> {
const rows = await this.prisma.$queryRawUnsafe<{ total: number }[]>(
`SELECT COALESCE(SUM(file_size_bytes), 0) / (1024.0 * 1024.0 * 1024.0) AS total
FROM call_recordings
WHERE club_id = $1`,
clubId,
);
return Math.round((rows[0]?.total ?? 0) * 100) / 100;
private async sumCallStorage(_clubId: string): Promise<number> {
// TODO: Query actual call recording storage when sip_recording module is implemented
return 0;
}
/**
@@ -271,7 +284,7 @@ export class MeteringService {
since: Date,
): Promise<number> {
const rows = await this.prisma.$queryRawUnsafe<{ count: bigint }[]>(
`SELECT COUNT(*)::bigint AS count FROM webhook_deliveries
`SELECT COUNT(*)::bigint AS count FROM webhook_logs
WHERE club_id = $1 AND created_at >= $2`,
clubId,
since,
@@ -284,7 +297,7 @@ export class MeteringService {
*/
private async countPushSent(clubId: string, since: Date): Promise<number> {
const rows = await this.prisma.$queryRawUnsafe<{ count: bigint }[]>(
`SELECT COUNT(*)::bigint AS count FROM push_notifications
`SELECT COUNT(*)::bigint AS count FROM notifications
WHERE club_id = $1 AND created_at >= $2`,
clubId,
since,

View File

@@ -102,8 +102,8 @@ export class ProvisioningService {
// 2. Create the license
await tx.$executeRawUnsafe(
`INSERT INTO licenses (club_id, type, max_users, max_clients, is_active, expires_at, created_at, updated_at)
VALUES ($1, $2, $3, $4, true, NOW() + INTERVAL '1 year', NOW(), NOW())`,
`INSERT INTO licenses (club_id, type, max_users, max_clients, is_active, start_date, end_date, created_at, updated_at)
VALUES ($1, $2, $3, $4, true, NOW(), NOW() + INTERVAL '1 year', NOW(), NOW())`,
club.id,
licenseType,
maxUsers,
@@ -123,24 +123,28 @@ export class ProvisioningService {
);
}
// 4. Create default notification settings
await tx.$executeRawUnsafe(
`INSERT INTO notification_settings (club_id, settings_json, created_at, updated_at)
VALUES ($1, $2::jsonb, NOW(), NOW())
ON CONFLICT (club_id) DO NOTHING`,
club.id,
JSON.stringify({
new_client_assigned: true,
service_booking: true,
payment_received: true,
package_topup: true,
booking_cancelled: true,
training_deducted: true,
trainings_ending: true,
client_returned: true,
client_birthday: true,
}),
);
// 4. Create default notification settings (one row per type)
const notificationTypes = [
'CLIENT_ASSIGNED',
'SERVICE_BOOKED',
'SERVICE_PAID',
'PACKAGE_TOPUP',
'BOOKING_CANCELLED',
'TRAINING_DEDUCTED',
'TRAININGS_ENDING',
'CLIENT_RETURNED',
'CLIENT_BIRTHDAY',
];
for (const notificationType of notificationTypes) {
await tx.$executeRawUnsafe(
`INSERT INTO notification_settings (id, club_id, notification_type, enabled, created_at, updated_at)
VALUES (gen_random_uuid(), $1, $2::"NotificationType", true, NOW(), NOW())
ON CONFLICT (club_id, notification_type) DO NOTHING`,
club.id,
notificationType,
);
}
return club;
});

View File

@@ -1,10 +1,35 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { UserPlus, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { api } from '@/lib/api';
interface FunnelClient {
id: string;
firstName: string;
lastName: string;
phone: string;
}
interface FunnelEntry {
id: string;
stage: string;
createdAt: string;
comment: string | null;
client: FunnelClient;
}
interface TrainerUser {
id: string;
firstName: string;
lastName: string;
phone: string;
role: string;
isActive: boolean;
}
interface UnassignedClient {
id: string;
@@ -22,69 +47,87 @@ interface Trainer {
capacity: number;
}
const placeholderClients: UnassignedClient[] = [
{
id: '1',
name: 'Кузнецов Артём',
phone: '+7 (999) 111-22-33',
registeredAt: '2026-02-17',
source: 'Сайт',
},
{
id: '2',
name: 'Морозова Елена',
phone: '+7 (999) 222-33-44',
registeredAt: '2026-02-16',
source: 'Звонок',
},
{
id: '3',
name: 'Волков Сергей',
phone: '+7 (999) 333-44-55',
registeredAt: '2026-02-16',
source: 'Рецепция',
},
];
function mapFunnelEntryToClient(entry: FunnelEntry): UnassignedClient {
return {
id: entry.client.id,
name: `${entry.client.lastName} ${entry.client.firstName}`,
phone: entry.client.phone,
registeredAt: new Date(entry.createdAt).toISOString().split('T')[0] ?? '',
source: 'Воронка',
};
}
const placeholderTrainers: Trainer[] = [
{
id: 't1',
name: 'Петров А.С.',
department: 'Тренажёрный зал',
activeClients: 12,
function mapUserToTrainer(user: TrainerUser): Trainer {
return {
id: user.id,
name: `${user.lastName} ${user.firstName.charAt(0)}.`,
department: '',
activeClients: 0,
capacity: 20,
},
{
id: 't2',
name: 'Козлова Е.В.',
department: 'Групповые программы',
activeClients: 8,
capacity: 15,
},
{
id: 't3',
name: 'Смирнова И.П.',
department: 'Тренажёрный зал',
activeClients: 15,
capacity: 20,
},
];
};
}
export default function DistributionPage() {
const [selectedClient, setSelectedClient] = useState<string | null>(null);
const [selectedTrainer, setSelectedTrainer] = useState<string | null>(null);
const [clients, setClients] = useState<UnassignedClient[]>([]);
const [trainers, setTrainers] = useState<Trainer[]>([]);
const [loading, setLoading] = useState(true);
const [assigning, setAssigning] = useState(false);
const fetchData = () => {
setLoading(true);
void Promise.all([
api
.get<{ data: FunnelEntry[]; hasMore: boolean; nextCursor: string | null }>(
'/funnel?stage=NEW&limit=50',
)
.then((res) => res.data.map(mapFunnelEntryToClient))
.catch(() => [] as UnassignedClient[]),
api
.get<TrainerUser[]>('/users/trainers')
.then((res) => res.map(mapUserToTrainer))
.catch(() => [] as Trainer[]),
])
.then(([clientsData, trainersData]) => {
setClients(clientsData);
setTrainers(trainersData);
})
.finally(() => setLoading(false));
};
useEffect(() => {
fetchData();
}, []);
const handleAssign = () => {
if (selectedClient && selectedTrainer) {
// Placeholder: will call API
alert(
`Клиент назначен тренеру (client: ${selectedClient}, trainer: ${selectedTrainer})`,
);
setSelectedClient(null);
setSelectedTrainer(null);
setAssigning(true);
api
.post('/funnel/assign', {
clientId: selectedClient,
trainerId: selectedTrainer,
})
.then(() => {
setSelectedClient(null);
setSelectedTrainer(null);
fetchData();
})
.catch(() => {
alert('Ошибка при назначении тренера');
})
.finally(() => setAssigning(false));
}
};
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-[#FF6B00]" />
</div>
);
}
return (
<div className="space-y-6">
{/* Stats */}
@@ -93,7 +136,7 @@ export default function DistributionPage() {
<CardContent className="p-4">
<p className="text-sm text-[#6B7280]">Нераспределённые</p>
<p className="text-2xl font-bold text-[#FF6B00]">
{placeholderClients.length}
{clients.length}
</p>
</CardContent>
</Card>
@@ -101,14 +144,14 @@ export default function DistributionPage() {
<CardContent className="p-4">
<p className="text-sm text-[#6B7280]">Тренеров доступно</p>
<p className="text-2xl font-bold text-gray-900">
{placeholderTrainers.length}
{trainers.length}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-sm text-[#6B7280]">Распределено сегодня</p>
<p className="text-2xl font-bold text-[#43A047]">4</p>
<p className="text-2xl font-bold text-[#43A047]">--</p>
</CardContent>
</Card>
</div>
@@ -120,7 +163,7 @@ export default function DistributionPage() {
<CardTitle>Нераспределённые клиенты</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{placeholderClients.map((client) => (
{clients.map((client) => (
<button
key={client.id}
onClick={() => setSelectedClient(client.id)}
@@ -143,7 +186,7 @@ export default function DistributionPage() {
</div>
</button>
))}
{placeholderClients.length === 0 && (
{clients.length === 0 && (
<p className="py-8 text-center text-[#6B7280]">
Все клиенты распределены
</p>
@@ -157,7 +200,7 @@ export default function DistributionPage() {
<CardTitle>Выберите тренера</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{placeholderTrainers.map((trainer) => (
{trainers.map((trainer) => (
<button
key={trainer.id}
onClick={() => setSelectedTrainer(trainer.id)}
@@ -170,7 +213,7 @@ export default function DistributionPage() {
<div>
<p className="font-medium text-gray-900">{trainer.name}</p>
<p className="text-sm text-[#6B7280]">
{trainer.department}
{trainer.department || 'Тренер'}
</p>
</div>
<div className="text-right">
@@ -181,6 +224,11 @@ export default function DistributionPage() {
</div>
</button>
))}
{trainers.length === 0 && (
<p className="py-8 text-center text-[#6B7280]">
Тренеры не найдены
</p>
)}
</CardContent>
</Card>
</div>
@@ -188,9 +236,9 @@ export default function DistributionPage() {
{/* Assign button */}
{selectedClient && selectedTrainer && (
<div className="flex justify-end">
<Button onClick={handleAssign} size="lg">
<Button onClick={handleAssign} size="lg" disabled={assigning}>
<UserPlus className="mr-2 h-5 w-5" />
Назначить тренера
{assigning ? 'Назначение...' : 'Назначить тренера'}
</Button>
</div>
)}

View File

@@ -1,10 +1,40 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Search, Plus, Clock, Phone, User } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { api } from '@/lib/api';
interface TrainingClient {
id: string;
firstName: string;
lastName: string;
phone: string;
}
interface TrainingTrainer {
id: string;
firstName: string;
lastName: string;
}
interface TrainingService {
id: string;
name: string;
duration: number;
}
interface Training {
id: string;
scheduledAt: string;
duration: number;
status: string;
client: TrainingClient;
trainer: TrainingTrainer;
service: TrainingService | null;
}
interface Appointment {
id: string;
@@ -33,58 +63,61 @@ const statusVariants: Record<
cancelled: 'error',
};
const placeholderAppointments: Appointment[] = [
{
id: '1',
clientName: 'Иванова Мария',
clientPhone: '+7 (999) 123-45-67',
time: '09:00',
trainer: 'Петров А.С.',
type: 'ВПТ',
status: 'confirmed',
},
{
id: '2',
clientName: 'Сидоров Алексей',
clientPhone: '+7 (999) 234-56-78',
time: '10:00',
trainer: 'Козлова Е.В.',
type: 'Персональная',
status: 'planned',
},
{
id: '3',
clientName: 'Петрова Ольга',
clientPhone: '+7 (999) 345-67-89',
time: '11:30',
trainer: 'Петров А.С.',
type: 'ВПТ',
status: 'planned',
},
{
id: '4',
clientName: 'Козлов Дмитрий',
clientPhone: '+7 (999) 456-78-90',
time: '13:00',
trainer: 'Смирнова И.П.',
type: 'Персональная',
status: 'completed',
},
{
id: '5',
clientName: 'Новикова Анна',
clientPhone: '+7 (999) 567-89-01',
time: '15:00',
trainer: 'Козлова Е.В.',
type: 'ВПТ',
status: 'cancelled',
},
];
function mapTrainingStatus(status: string): Appointment['status'] {
const statusMap: Record<string, Appointment['status']> = {
PLANNED: 'planned',
CONFIRMED: 'confirmed',
COMPLETED: 'completed',
CANCELLED: 'cancelled',
};
return statusMap[status] ?? 'planned';
}
function formatTime(isoString: string): string {
const date = new Date(isoString);
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
}
function mapTrainingToAppointment(training: Training): Appointment {
return {
id: training.id,
clientName: `${training.client.lastName} ${training.client.firstName}`,
clientPhone: training.client.phone,
time: formatTime(training.scheduledAt),
trainer: `${training.trainer.lastName} ${training.trainer.firstName.charAt(0)}.`,
type: training.service?.name ?? 'Тренировка',
status: mapTrainingStatus(training.status),
};
}
export default function ReceptionPage() {
const [search, setSearch] = useState('');
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [loading, setLoading] = useState(true);
const filteredAppointments = placeholderAppointments.filter(
useEffect(() => {
api
.get<{ data: Training[]; hasMore: boolean; nextCursor: string | null }>(
'/trainings?limit=50',
)
.then((res) => {
setAppointments(res.data.map(mapTrainingToAppointment));
})
.catch(() => {
setAppointments([]);
})
.finally(() => setLoading(false));
}, []);
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-[#FF6B00]" />
</div>
);
}
const filteredAppointments = appointments.filter(
(a) =>
a.clientName.toLowerCase().includes(search.toLowerCase()) ||
a.clientPhone.includes(search),
@@ -116,7 +149,7 @@ export default function ReceptionPage() {
<CardContent className="p-4">
<p className="text-sm text-[#6B7280]">Всего записей</p>
<p className="text-2xl font-bold text-gray-900">
{placeholderAppointments.length}
{appointments.length}
</p>
</CardContent>
</Card>
@@ -124,7 +157,7 @@ export default function ReceptionPage() {
<CardContent className="p-4">
<p className="text-sm text-[#6B7280]">Подтверждено</p>
<p className="text-2xl font-bold text-[#43A047]">
{placeholderAppointments.filter((a) => a.status === 'confirmed').length}
{appointments.filter((a) => a.status === 'confirmed').length}
</p>
</CardContent>
</Card>
@@ -132,7 +165,7 @@ export default function ReceptionPage() {
<CardContent className="p-4">
<p className="text-sm text-[#6B7280]">Ожидает</p>
<p className="text-2xl font-bold text-[#FF9800]">
{placeholderAppointments.filter((a) => a.status === 'planned').length}
{appointments.filter((a) => a.status === 'planned').length}
</p>
</CardContent>
</Card>
@@ -140,7 +173,7 @@ export default function ReceptionPage() {
<CardContent className="p-4">
<p className="text-sm text-[#6B7280]">Отменено</p>
<p className="text-2xl font-bold text-[#E53935]">
{placeholderAppointments.filter((a) => a.status === 'cancelled').length}
{appointments.filter((a) => a.status === 'cancelled').length}
</p>
</CardContent>
</Card>

View File

@@ -1,35 +1,110 @@
'use client';
import { useEffect, useState } from 'react';
import { FileText } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { api } from '@/lib/api';
interface ReportGeneratedBy {
id: string;
firstName: string;
lastName: string;
}
interface Report {
id: string;
type: string;
title: string;
status: string;
fileUrl: string | null;
createdAt: string;
generatedBy: ReportGeneratedBy | null;
}
const reportTypeLabels: Record<string, string> = {
CLIENT_STATUS: 'Текущее состояние клиентов',
TRAINER_STATS: 'Статистика по тренерам',
FUNNEL: 'Воронка продаж',
FINANCIAL: 'Финансовый отчёт',
};
const statusLabels: Record<string, string> = {
PENDING: 'Ожидает',
GENERATING: 'Генерируется',
COMPLETED: 'Готов',
FAILED: 'Ошибка',
};
const statusVariants: Record<string, 'warning' | 'success' | 'default' | 'error'> = {
PENDING: 'warning',
GENERATING: 'warning',
COMPLETED: 'success',
FAILED: 'error',
};
// Default report types to always show as available for generation
const defaultReportTypes = [
{
title: 'Текущее состояние клиентов',
description: 'Активные, спящие, ушедшие клиенты',
type: 'CLIENT_STATUS',
},
{
title: 'Статистика по тренерам',
description: 'Конверсия, нагрузка, рейтинг',
type: 'TRAINER_STATS',
},
{
title: 'Воронка продаж',
description: 'Конверсия по этапам воронки',
type: 'FUNNEL',
},
{
title: 'Финансовый отчёт',
description: 'Продажи, реализация, долги',
type: 'FINANCIAL',
},
];
export default function ReportsPage() {
const [reports, setReports] = useState<Report[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
api
.get<{ data: Report[]; hasMore: boolean; nextCursor: string | null }>(
'/reports?limit=50',
)
.then((res) => {
setReports(res.data);
})
.catch(() => {
setReports([]);
})
.finally(() => setLoading(false));
}, []);
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-[#FF6B00]" />
</div>
);
}
return (
<div className="space-y-6">
{/* Report generation buttons */}
<Card>
<CardHeader>
<CardTitle>Отчёты</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
{[
{
title: 'Текущее состояние клиентов',
description: 'Активные, спящие, ушедшие клиенты',
},
{
title: 'Статистика по тренерам',
description: 'Конверсия, нагрузка, рейтинг',
},
{
title: 'Воронка продаж',
description: 'Конверсия по этапам воронки',
},
{
title: 'Финансовый отчёт',
description: 'Продажи, реализация, долги',
},
].map((report) => (
{defaultReportTypes.map((report) => (
<button
key={report.title}
key={report.type}
className="flex items-start gap-4 rounded-xl border border-[#E5E7EB] p-4 text-left transition-colors hover:border-[#FF6B00] hover:bg-[#FF6B00]/5"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[#FF6B00]/10">
@@ -46,6 +121,73 @@ export default function ReportsPage() {
</div>
</CardContent>
</Card>
{/* Generated reports list */}
{reports.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Сгенерированные отчёты</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#E5E7EB]">
<th className="pb-3 text-left font-medium text-[#6B7280]">
Название
</th>
<th className="pb-3 text-left font-medium text-[#6B7280]">
Тип
</th>
<th className="pb-3 text-left font-medium text-[#6B7280]">
Автор
</th>
<th className="pb-3 text-left font-medium text-[#6B7280]">
Дата
</th>
<th className="pb-3 text-left font-medium text-[#6B7280]">
Статус
</th>
</tr>
</thead>
<tbody>
{reports.map((report) => (
<tr
key={report.id}
className="border-b border-[#E5E7EB] last:border-0"
>
<td className="py-3 font-medium text-gray-900">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-[#FF6B00]" />
{report.title}
</div>
</td>
<td className="py-3 text-[#6B7280]">
{reportTypeLabels[report.type] ?? report.type}
</td>
<td className="py-3 text-[#6B7280]">
{report.generatedBy
? `${report.generatedBy.lastName} ${report.generatedBy.firstName.charAt(0)}.`
: '—'}
</td>
<td className="py-3 text-[#6B7280]">
{new Date(report.createdAt).toLocaleDateString('ru-RU')}
</td>
<td className="py-3">
<Badge
variant={statusVariants[report.status] ?? 'default'}
>
{statusLabels[report.status] ?? report.status}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -1,7 +1,30 @@
'use client';
import { useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { api } from '@/lib/api';
interface RatingEntry {
trainerId: string;
trainerName: string;
department?: string;
activeClients: number;
conversionRate: number;
score: number;
}
interface TrainerUser {
id: string;
firstName: string;
lastName: string;
phone: string;
role: string;
isActive: boolean;
}
interface TrainerRow {
id: string;
name: string;
department: string;
activeClients: number;
@@ -10,42 +33,61 @@ interface TrainerRow {
status: 'online' | 'offline';
}
const placeholderTrainers: TrainerRow[] = [
{
name: 'Петров Андрей Сергеевич',
department: 'Тренажёрный зал',
activeClients: 12,
conversionRate: 78,
rating: 4.8,
status: 'online',
},
{
name: 'Козлова Екатерина Владимировна',
department: 'Групповые программы',
activeClients: 8,
conversionRate: 85,
rating: 4.9,
status: 'online',
},
{
name: 'Смирнова Ирина Павловна',
department: 'Тренажёрный зал',
activeClients: 15,
conversionRate: 72,
rating: 4.6,
status: 'offline',
},
{
name: 'Васильев Дмитрий Олегович',
department: 'Единоборства',
activeClients: 10,
conversionRate: 68,
rating: 4.5,
status: 'offline',
},
];
export default function TrainersPage() {
const [trainers, setTrainers] = useState<TrainerRow[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Try stats/rating first for rich data, fall back to users/trainers
api
.get<RatingEntry[]>('/stats/rating')
.then((res) => {
const rows: TrainerRow[] = (Array.isArray(res) ? res : []).map(
(entry) => ({
id: entry.trainerId,
name: entry.trainerName,
department: entry.department ?? '',
activeClients: entry.activeClients ?? 0,
conversionRate: Math.round(entry.conversionRate ?? 0),
rating: typeof entry.score === 'number' ? Math.round(entry.score * 10) / 10 : 0,
status: 'online' as const,
}),
);
setTrainers(rows);
})
.catch(() => {
// Fallback to basic trainers list
return api
.get<TrainerUser[]>('/users/trainers')
.then((res) => {
const rows: TrainerRow[] = (Array.isArray(res) ? res : []).map(
(user) => ({
id: user.id,
name: `${user.lastName} ${user.firstName}`,
department: '',
activeClients: 0,
conversionRate: 0,
rating: 0,
status: user.isActive ? ('online' as const) : ('offline' as const),
}),
);
setTrainers(rows);
})
.catch(() => {
setTrainers([]);
});
})
.finally(() => setLoading(false));
}, []);
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-[#FF6B00]" />
</div>
);
}
return (
<div className="space-y-6">
<Card>
@@ -53,61 +95,67 @@ export default function TrainersPage() {
<CardTitle>Статистика по тренерам</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#E5E7EB]">
<th className="pb-3 text-left font-medium text-[#6B7280]">
Тренер
</th>
<th className="pb-3 text-left font-medium text-[#6B7280]">
Департамент
</th>
<th className="pb-3 text-left font-medium text-[#6B7280]">
Клиенты
</th>
<th className="pb-3 text-left font-medium text-[#6B7280]">
Конверсия
</th>
<th className="pb-3 text-left font-medium text-[#6B7280]">
Рейтинг
</th>
<th className="pb-3 text-left font-medium text-[#6B7280]">
Статус
</th>
</tr>
</thead>
<tbody>
{placeholderTrainers.map((trainer) => (
<tr
key={trainer.name}
className="border-b border-[#E5E7EB] last:border-0"
>
<td className="py-3 font-medium text-gray-900">
{trainer.name}
</td>
<td className="py-3 text-[#6B7280]">
{trainer.department}
</td>
<td className="py-3">{trainer.activeClients}</td>
<td className="py-3">{trainer.conversionRate}%</td>
<td className="py-3">{trainer.rating}</td>
<td className="py-3">
<Badge
variant={
trainer.status === 'online' ? 'success' : 'default'
}
>
{trainer.status === 'online'
? 'На смене'
: 'Не на смене'}
</Badge>
</td>
{trainers.length === 0 ? (
<p className="py-8 text-center text-[#6B7280]">
Тренеры не найдены
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#E5E7EB]">
<th className="pb-3 text-left font-medium text-[#6B7280]">
Тренер
</th>
<th className="pb-3 text-left font-medium text-[#6B7280]">
Департамент
</th>
<th className="pb-3 text-left font-medium text-[#6B7280]">
Клиенты
</th>
<th className="pb-3 text-left font-medium text-[#6B7280]">
Конверсия
</th>
<th className="pb-3 text-left font-medium text-[#6B7280]">
Рейтинг
</th>
<th className="pb-3 text-left font-medium text-[#6B7280]">
Статус
</th>
</tr>
))}
</tbody>
</table>
</div>
</thead>
<tbody>
{trainers.map((trainer) => (
<tr
key={trainer.id}
className="border-b border-[#E5E7EB] last:border-0"
>
<td className="py-3 font-medium text-gray-900">
{trainer.name}
</td>
<td className="py-3 text-[#6B7280]">
{trainer.department || '—'}
</td>
<td className="py-3">{trainer.activeClients}</td>
<td className="py-3">{trainer.conversionRate}%</td>
<td className="py-3">{trainer.rating}</td>
<td className="py-3">
<Badge
variant={
trainer.status === 'online' ? 'success' : 'default'
}
>
{trainer.status === 'online'
? 'На смене'
: 'Не на смене'}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>

View File

@@ -1,7 +1,8 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Plus, Search, Package, Tag } from 'lucide-react';
import { api } from '@/lib/api';
type Service = {
id: string;
@@ -22,60 +23,45 @@ type ServicePackage = {
isActive: boolean;
};
const mockServices: Service[] = [
{ id: '1', name: 'Персональная тренировка', category: 'Тренажёрный зал', price: 3000, duration: 60, isActive: true },
{ id: '2', name: 'Сплит-тренировка (2 чел.)', category: 'Тренажёрный зал', price: 2000, duration: 60, isActive: true },
{ id: '3', name: 'Групповое занятие', category: 'Групповые', price: 800, duration: 55, isActive: true },
{ id: '4', name: 'Персональное плавание', category: 'Бассейн', price: 2500, duration: 45, isActive: true },
{ id: '5', name: 'ВПТ (вводная тренировка)', category: 'Тренажёрный зал', price: 0, duration: 60, isActive: true },
{ id: '6', name: 'Занятие по боксу', category: 'Единоборства', price: 2500, duration: 60, isActive: true },
{ id: '7', name: 'Стретчинг (индив.)', category: 'Групповые', price: 2000, duration: 45, isActive: false },
];
type ServicesResponse = {
data: Service[];
hasMore: boolean;
nextCursor: string | null;
};
const mockPackages: ServicePackage[] = [
{
id: '1',
name: 'Старт (8 тренировок)',
services: ['Персональная тренировка'],
totalPrice: 20000,
discountPercent: 17,
validDays: 30,
isActive: true,
},
{
id: '2',
name: 'Оптимал (12 тренировок)',
services: ['Персональная тренировка'],
totalPrice: 28800,
discountPercent: 20,
validDays: 45,
isActive: true,
},
{
id: '3',
name: 'Безлимит (месяц)',
services: ['Групповое занятие', 'Бассейн'],
totalPrice: 15000,
discountPercent: 0,
validDays: 30,
isActive: true,
},
{
id: '4',
name: 'Боец (10 занятий)',
services: ['Занятие по боксу'],
totalPrice: 20000,
discountPercent: 20,
validDays: 45,
isActive: true,
},
];
type PackagesResponse = {
data: ServicePackage[];
hasMore: boolean;
nextCursor: string | null;
};
export default function CatalogPage() {
const [tab, setTab] = useState<'services' | 'packages'>('services');
const [search, setSearch] = useState('');
const [services, setServices] = useState<Service[]>([]);
const [packages, setPackages] = useState<ServicePackage[]>([]);
const [loading, setLoading] = useState(true);
const filteredServices = mockServices.filter((s) =>
useEffect(() => {
void Promise.all([
api.get<ServicesResponse>('/catalog/services')
.then((res) => setServices(res.data ?? []))
.catch(() => setServices([])),
api.get<PackagesResponse>('/catalog/packages')
.then((res) => setPackages(res.data ?? []))
.catch(() => setPackages([])),
]).finally(() => setLoading(false));
}, []);
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>
);
}
const filteredServices = services.filter((s) =>
s.name.toLowerCase().includes(search.toLowerCase()) ||
s.category.toLowerCase().includes(search.toLowerCase())
);
@@ -100,7 +86,7 @@ export default function CatalogPage() {
}`}
>
<Tag className="h-4 w-4" />
Услуги ({mockServices.length})
Услуги ({services.length})
</button>
<button
onClick={() => setTab('packages')}
@@ -111,7 +97,7 @@ export default function CatalogPage() {
}`}
>
<Package className="h-4 w-4" />
Пакеты ({mockPackages.length})
Пакеты ({packages.length})
</button>
</div>
@@ -166,7 +152,7 @@ export default function CatalogPage() {
{tab === 'packages' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{mockPackages.map((pkg) => (
{packages.map((pkg) => (
<div key={pkg.id} className="bg-card rounded-xl p-6 shadow-sm border border-border">
<div className="flex items-start justify-between mb-3">
<h3 className="text-lg font-semibold text-gray-900">{pkg.name}</h3>

View File

@@ -1,5 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import {
Users,
UserCheck,
@@ -8,57 +9,22 @@ import {
DollarSign,
Activity,
} from 'lucide-react';
import { api } from '@/lib/api';
const stats = [
{
label: 'Сотрудники',
value: '24',
change: '+2 за месяц',
icon: Users,
color: 'text-blue-600',
bg: 'bg-blue-50',
},
{
label: 'Активные клиенты',
value: '1 248',
change: '+86 за месяц',
icon: UserCheck,
color: 'text-success',
bg: 'bg-green-50',
},
{
label: 'Выручка (месяц)',
value: '2 450 000 \u20BD',
change: '+12% к прошлому',
icon: DollarSign,
color: 'text-primary',
bg: 'bg-orange-50',
},
{
label: 'Тренировок (месяц)',
value: '3 672',
change: '+8% к прошлому',
icon: Calendar,
color: 'text-purple-600',
bg: 'bg-purple-50',
},
{
label: 'Конверсия воронки',
value: '34%',
change: '+2 п.п.',
icon: TrendingUp,
color: 'text-teal-600',
bg: 'bg-teal-50',
},
{
label: 'Загруженность залов',
value: '72%',
change: 'В рабочие часы',
icon: Activity,
color: 'text-rose-600',
bg: 'bg-rose-50',
},
];
type StatsSummary = {
employees?: number;
employeesChange?: string;
activeClients?: number;
activeClientsChange?: string;
revenue?: number;
revenueChange?: string;
trainings?: number;
trainingsChange?: string;
funnelConversion?: number;
funnelConversionChange?: string;
roomOccupancy?: number;
roomOccupancyNote?: string;
};
const recentActivity = [
{ time: '14:32', text: 'Иванов А. провёл тренировку с Петровой М.' },
@@ -68,7 +34,81 @@ const recentActivity = [
{ time: '12:55', text: 'Смена графика: Фёдоров И. — выходной 20.02' },
];
function formatNumber(num: number | undefined): string {
if (num === undefined) return '—';
return num.toLocaleString('ru-RU');
}
export default function DashboardPage() {
const [data, setData] = useState<StatsSummary | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.get<StatsSummary>('/stats/summary')
.then((res) => setData(res))
.catch(() => setData(null))
.finally(() => setLoading(false));
}, []);
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>
);
}
const stats = [
{
label: 'Сотрудники',
value: formatNumber(data?.employees),
change: data?.employeesChange ?? '—',
icon: Users,
color: 'text-blue-600',
bg: 'bg-blue-50',
},
{
label: 'Активные клиенты',
value: formatNumber(data?.activeClients),
change: data?.activeClientsChange ?? '—',
icon: UserCheck,
color: 'text-success',
bg: 'bg-green-50',
},
{
label: 'Выручка (месяц)',
value: data?.revenue !== undefined ? `${formatNumber(data.revenue)} \u20BD` : '—',
change: data?.revenueChange ?? '—',
icon: DollarSign,
color: 'text-primary',
bg: 'bg-orange-50',
},
{
label: 'Тренировок (месяц)',
value: formatNumber(data?.trainings),
change: data?.trainingsChange ?? '—',
icon: Calendar,
color: 'text-purple-600',
bg: 'bg-purple-50',
},
{
label: 'Конверсия воронки',
value: data?.funnelConversion !== undefined ? `${data.funnelConversion}%` : '—',
change: data?.funnelConversionChange ?? '—',
icon: TrendingUp,
color: 'text-teal-600',
bg: 'bg-teal-50',
},
{
label: 'Загруженность залов',
value: data?.roomOccupancy !== undefined ? `${data.roomOccupancy}%` : '—',
change: data?.roomOccupancyNote ?? '—',
icon: Activity,
color: 'text-rose-600',
bg: 'bg-rose-50',
},
];
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-6">Дашборд клуба</h1>

View File

@@ -1,6 +1,8 @@
'use client';
import { useEffect, useState } from 'react';
import { Plus, Users, UserCheck, Edit, Trash2 } from 'lucide-react';
import { api } from '@/lib/api';
type Department = {
id: string;
@@ -11,50 +13,31 @@ type Department = {
description: string;
};
const mockDepartments: Department[] = [
{
id: '1',
name: 'Тренажёрный зал',
head: 'Козлов Дмитрий',
trainersCount: 8,
activeClients: 420,
description: 'Силовые тренировки, функциональный тренинг, персональные занятия',
},
{
id: '2',
name: 'Групповые программы',
head: 'Петрова Мария',
trainersCount: 6,
activeClients: 310,
description: 'Йога, пилатес, степ-аэробика, стретчинг, танцы',
},
{
id: '3',
name: 'Бассейн',
head: 'Сидорова Анна',
trainersCount: 3,
activeClients: 185,
description: 'Плавание, аквааэробика, обучение плаванию',
},
{
id: '4',
name: 'Единоборства',
head: 'Волков Сергей',
trainersCount: 4,
activeClients: 156,
description: 'Бокс, кикбоксинг, MMA, самооборона',
},
{
id: '5',
name: 'Детский фитнес',
head: 'Николаева Ольга',
trainersCount: 3,
activeClients: 177,
description: 'Детские группы 4-8 лет, подростки 9-14 лет, спортивная гимнастика',
},
];
type DepartmentsResponse = {
data: Department[];
hasMore: boolean;
nextCursor: string | null;
};
export default function DepartmentsPage() {
const [departments, setDepartments] = useState<Department[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.get<DepartmentsResponse>('/departments')
.then((res) => setDepartments(res.data ?? []))
.catch(() => setDepartments([]))
.finally(() => setLoading(false));
}, []);
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>
);
}
return (
<div>
<div className="flex items-center justify-between mb-6">
@@ -66,7 +49,7 @@ export default function DepartmentsPage() {
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{mockDepartments.map((dept) => (
{departments.map((dept) => (
<div
key={dept.id}
className="bg-card rounded-xl p-6 shadow-sm border border-border"

View File

@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import {
Plug,
Phone,
@@ -13,43 +13,36 @@ import {
Copy,
RefreshCw,
} from 'lucide-react';
import { api } from '@/lib/api';
type IntegrationStatus = 'connected' | 'disconnected' | 'error';
type Integration = {
type IntegrationConfig = {
id: string;
name: string;
description: string;
status: IntegrationStatus;
icon: typeof Plug;
lastSync?: string;
};
const integrations: Integration[] = [
{
id: '1c',
name: '1C:Фитнес клуб',
description: 'Двусторонняя синхронизация клиентов, членств, продаж и графика работы',
status: 'connected',
icon: Plug,
lastSync: '18.02.2026, 14:30',
},
{
id: 'sip',
name: 'SIP-телефония',
description: 'Встроенный SIP-клиент для исходящих звонков с CallerID клуба',
status: 'connected',
icon: Phone,
lastSync: undefined,
},
{
id: 'webhooks',
name: 'Webhooks',
description: '12 типов исходящих событий для внешних интеграций (HMAC-SHA256)',
status: 'disconnected',
icon: Webhook,
},
];
type WebhookEntry = {
id: string;
url: string;
events: string[];
active: boolean;
};
type WebhooksResponse = {
data: WebhookEntry[];
hasMore: boolean;
nextCursor: string | null;
};
const integrationIcons: Record<string, typeof Plug> = {
'1c': Plug,
sip: Phone,
webhooks: Webhook,
};
const mockApiKeys = [
{
@@ -76,8 +69,76 @@ const statusConfig: Record<IntegrationStatus, { label: string; className: string
error: { label: 'Ошибка', className: 'text-error', icon: XCircle },
};
const defaultIntegrations: IntegrationConfig[] = [
{
id: '1c',
name: '1C:Фитнес клуб',
description: 'Двусторонняя синхронизация клиентов, членств, продаж и графика работы',
status: 'disconnected',
},
{
id: 'sip',
name: 'SIP-телефония',
description: 'Встроенный SIP-клиент для исходящих звонков с CallerID клуба',
status: 'disconnected',
},
{
id: 'webhooks',
name: 'Webhooks',
description: '12 типов исходящих событий для внешних интеграций (HMAC-SHA256)',
status: 'disconnected',
},
];
export default function IntegrationsPage() {
const [showKey, setShowKey] = useState<string | null>(null);
const [integrations, setIntegrations] = useState<IntegrationConfig[]>(defaultIntegrations);
const [loading, setLoading] = useState(true);
useEffect(() => {
void Promise.all([
api.get<IntegrationConfig>('/integration/config')
.then((config) => {
setIntegrations((prev) =>
prev.map((item) => {
if (item.id === '1c') {
return {
...item,
status: (config as Record<string, unknown>).status as IntegrationStatus ?? item.status,
lastSync: (config as Record<string, unknown>).lastSync as string | undefined,
};
}
return item;
})
);
})
.catch(() => {}),
api.get<WebhooksResponse>('/webhooks')
.then((res) => {
const hasActive = (res.data ?? []).some((w) => w.active);
setIntegrations((prev) =>
prev.map((item) => {
if (item.id === 'webhooks') {
return {
...item,
status: hasActive ? 'connected' : 'disconnected',
};
}
return item;
})
);
})
.catch(() => {}),
]).finally(() => setLoading(false));
}, []);
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>
);
}
return (
<div>
@@ -85,7 +146,7 @@ export default function IntegrationsPage() {
<div className="space-y-6 mb-10">
{integrations.map((integration) => {
const Icon = integration.icon;
const Icon = integrationIcons[integration.id] ?? Plug;
const status = statusConfig[integration.status];
const StatusIcon = status.icon;
return (

View File

@@ -1,5 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import {
KeyRound,
Calendar,
@@ -8,6 +9,7 @@ import {
Package,
BarChart3,
} from 'lucide-react';
import { api } from '@/lib/api';
type Module = {
id: string;
@@ -23,16 +25,34 @@ type UsageItem = {
unit: string;
};
const licenseInfo = {
type: 'Стандартная',
clubName: 'FitGym Premium',
activatedAt: '01.12.2025',
expiresAt: '31.12.2026',
daysLeft: 316,
status: 'active' as const,
type LicenseInfo = {
type: string;
clubName: string;
activatedAt: string;
expiresAt: string;
daysLeft: number;
status: 'active' | 'expired' | 'grace';
};
const modules: Module[] = [
type UsageData = {
active_users?: { current: number; limit: number };
clients?: { current: number; limit: number };
call_storage_gb?: { current: number; limit: number };
webhooks_sent?: { current: number; limit: number };
api_requests?: { current: number; limit: number };
push_sent?: { current: number; limit: number };
};
const defaultLicenseInfo: LicenseInfo = {
type: '—',
clubName: '—',
activatedAt: '—',
expiresAt: '—',
daysLeft: 0,
status: 'active',
};
const defaultModules: Module[] = [
{ id: 'core', name: 'Ядро CRM', enabled: true, description: 'Основные функции CRM' },
{ id: 'sales', name: 'Продажи и реализация', enabled: true, description: 'Управление продажами и долгами' },
{ id: 'coordinator', name: 'Координатор', enabled: true, description: 'Распределение клиентов по тренерам' },
@@ -46,16 +66,59 @@ const modules: Module[] = [
{ id: '1c_sync', name: 'Интеграция 1С', enabled: true, description: 'Синхронизация с 1С:Фитнес клуб' },
];
const usage: UsageItem[] = [
{ label: 'Активные пользователи', current: 24, limit: 50, unit: 'чел.' },
{ label: 'Клиентская база', current: 1248, limit: 5000, unit: 'записей' },
const defaultUsage: UsageItem[] = [
{ label: 'Активные пользователи', current: 0, limit: 0, unit: 'чел.' },
{ label: 'Клиентская база', current: 0, limit: 0, unit: 'записей' },
{ label: 'Записи звонков', current: 0, limit: 0, unit: 'ГБ' },
{ label: 'Webhook-доставка', current: 3200, limit: 10000, unit: 'событий/мес' },
{ label: 'API-запросы', current: 12450, limit: 50000, unit: 'запросов/мес' },
{ label: 'Push-уведомления', current: 1820, limit: 5000, unit: 'сообщ./мес' },
{ label: 'Webhook-доставка', current: 0, limit: 0, unit: 'событий/мес' },
{ label: 'API-запросы', current: 0, limit: 0, unit: 'запросов/мес' },
{ label: 'Push-уведомления', current: 0, limit: 0, unit: 'сообщ./мес' },
];
function mapUsageData(data: UsageData): UsageItem[] {
return [
{ label: 'Активные пользователи', current: data.active_users?.current ?? 0, limit: data.active_users?.limit ?? 0, unit: 'чел.' },
{ label: 'Клиентская база', current: data.clients?.current ?? 0, limit: data.clients?.limit ?? 0, unit: 'записей' },
{ label: 'Записи звонков', current: data.call_storage_gb?.current ?? 0, limit: data.call_storage_gb?.limit ?? 0, unit: 'ГБ' },
{ label: 'Webhook-доставка', current: data.webhooks_sent?.current ?? 0, limit: data.webhooks_sent?.limit ?? 0, unit: 'событий/мес' },
{ label: 'API-запросы', current: data.api_requests?.current ?? 0, limit: data.api_requests?.limit ?? 0, unit: 'запросов/мес' },
{ label: 'Push-уведомления', current: data.push_sent?.current ?? 0, limit: data.push_sent?.limit ?? 0, unit: 'сообщ./мес' },
];
}
export default function LicensePage() {
const [licenseInfo, setLicenseInfo] = useState<LicenseInfo>(defaultLicenseInfo);
const [modules] = useState<Module[]>(defaultModules);
const [usage, setUsage] = useState<UsageItem[]>(defaultUsage);
const [loading, setLoading] = useState(true);
useEffect(() => {
void Promise.all([
api.get<LicenseInfo>('/licenses/my')
.then((res) => setLicenseInfo(res))
.catch(() => {}),
api.get<UsageData>('/metering/usage')
.then((res) => setUsage(mapUsageData(res)))
.catch(() => {}),
]).finally(() => setLoading(false));
}, []);
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>
);
}
const statusBadge = licenseInfo.status === 'active'
? { label: 'Активна', className: 'bg-green-100 text-green-800' }
: licenseInfo.status === 'grace'
? { label: 'Льготный период', className: 'bg-yellow-100 text-yellow-800' }
: { label: 'Истекла', className: 'bg-red-100 text-red-800' };
const daysColor = licenseInfo.daysLeft > 30 ? 'text-success' : licenseInfo.daysLeft > 7 ? 'text-yellow-600' : 'text-error';
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-6">Лицензия</h1>
@@ -70,9 +133,9 @@ export default function LicensePage() {
<h2 className="text-lg font-semibold text-gray-900">
Лицензия: {licenseInfo.type}
</h2>
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<span className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium ${statusBadge.className}`}>
<CheckCircle className="h-3 w-3" />
Активна
{statusBadge.label}
</span>
</div>
<p className="text-sm text-gray-600">{licenseInfo.clubName}</p>
@@ -98,10 +161,10 @@ export default function LicensePage() {
</div>
</div>
<div className="flex items-center gap-3">
<AlertTriangle className="h-5 w-5 text-success" />
<AlertTriangle className={`h-5 w-5 ${daysColor}`} />
<div>
<p className="text-xs text-muted">Осталось дней</p>
<p className="text-sm font-medium text-success">{licenseInfo.daysLeft}</p>
<p className={`text-sm font-medium ${daysColor}`}>{licenseInfo.daysLeft}</p>
</div>
</div>
</div>

View File

@@ -1,6 +1,8 @@
'use client';
import { useEffect, useState } from 'react';
import { Plus, Edit, Trash2, MapPin, Users, Clock } from 'lucide-react';
import { api } from '@/lib/api';
type Room = {
id: string;
@@ -13,68 +15,11 @@ type Room = {
workingHours: string;
};
const mockRooms: Room[] = [
{
id: '1',
name: 'Основной тренажёрный зал',
location: '2 этаж',
capacity: 50,
type: 'Тренажёрный',
equipment: ['Кардиозона', 'Свободные веса', 'Тренажёры'],
status: 'available',
workingHours: '06:00 - 23:00',
},
{
id: '2',
name: 'Зал групповых программ A',
location: '1 этаж',
capacity: 25,
type: 'Групповой',
equipment: ['Зеркала', 'Коврики', 'Степ-платформы'],
status: 'occupied',
workingHours: '07:00 - 22:00',
},
{
id: '3',
name: 'Зал групповых программ B',
location: '1 этаж',
capacity: 20,
type: 'Групповой',
equipment: ['Зеркала', 'Коврики', 'Мячи'],
status: 'available',
workingHours: '07:00 - 22:00',
},
{
id: '4',
name: 'Бассейн (25 м)',
location: '1 этаж',
capacity: 30,
type: 'Бассейн',
equipment: ['6 дорожек', 'Трибуна'],
status: 'available',
workingHours: '06:00 - 22:00',
},
{
id: '5',
name: 'Зал единоборств',
location: '3 этаж',
capacity: 20,
type: 'Единоборства',
equipment: ['Ринг', 'Груши', 'Маты'],
status: 'maintenance',
workingHours: '08:00 - 21:00',
},
{
id: '6',
name: 'Студия йоги',
location: '2 этаж',
capacity: 15,
type: 'Студия',
equipment: ['Коврики', 'Блоки', 'Ремни', 'Болстеры'],
status: 'available',
workingHours: '07:00 - 21:00',
},
];
type RoomsResponse = {
data: Room[];
hasMore: boolean;
nextCursor: string | null;
};
const statusMap: Record<string, { label: string; className: string }> = {
available: { label: 'Свободен', className: 'bg-green-100 text-green-800' },
@@ -83,6 +28,24 @@ const statusMap: Record<string, { label: string; className: string }> = {
};
export default function RoomsPage() {
const [rooms, setRooms] = useState<Room[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.get<RoomsResponse>('/rooms')
.then((res) => setRooms(res.data ?? []))
.catch(() => setRooms([]))
.finally(() => setLoading(false));
}, []);
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>
);
}
return (
<div>
<div className="flex items-center justify-between mb-6">
@@ -94,7 +57,7 @@ export default function RoomsPage() {
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{mockRooms.map((room) => {
{rooms.map((room) => {
const status = statusMap[room.status] ?? { label: 'Неизвестно', className: 'bg-gray-100 text-gray-800' };
return (
<div

View File

@@ -1,7 +1,8 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Plus, Search, MoreHorizontal, UserCheck, UserX } from 'lucide-react';
import { api } from '@/lib/api';
type Employee = {
id: string;
@@ -13,15 +14,11 @@ type Employee = {
hiredAt: string;
};
const mockEmployees: Employee[] = [
{ id: '1', name: 'Иванов Алексей', role: 'Тренер', department: 'Тренажёрный зал', phone: '+7 (900) 123-45-67', status: 'active', hiredAt: '2024-03-15' },
{ id: '2', name: 'Петрова Мария', role: 'Тренер', department: 'Групповые программы', phone: '+7 (900) 234-56-78', status: 'active', hiredAt: '2024-01-10' },
{ id: '3', name: 'Козлов Дмитрий', role: 'Координатор', department: 'Тренажёрный зал', phone: '+7 (900) 345-67-89', status: 'active', hiredAt: '2023-09-01' },
{ id: '4', name: 'Сидорова Анна', role: 'Тренер', department: 'Бассейн', phone: '+7 (900) 456-78-90', status: 'inactive', hiredAt: '2023-06-20' },
{ id: '5', name: 'Фёдоров Игорь', role: 'Фитнес-менеджер', department: 'Управление', phone: '+7 (900) 567-89-01', status: 'active', hiredAt: '2022-11-05' },
{ id: '6', name: 'Морозова Елена', role: 'Администратор', department: 'Рецепция', phone: '+7 (900) 678-90-12', status: 'active', hiredAt: '2024-05-12' },
{ id: '7', name: 'Волков Сергей', role: 'Тренер', department: 'Единоборства', phone: '+7 (900) 789-01-23', status: 'active', hiredAt: '2024-02-28' },
];
type UsersResponse = {
data: Employee[];
hasMore: boolean;
nextCursor: string | null;
};
const roleColors: Record<string, string> = {
'Тренер': 'bg-blue-100 text-blue-800',
@@ -31,9 +28,26 @@ const roleColors: Record<string, string> = {
};
export default function StaffPage() {
const [employees, setEmployees] = useState<Employee[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const filtered = mockEmployees.filter(
useEffect(() => {
api.get<UsersResponse>('/users')
.then((res) => setEmployees(res.data ?? []))
.catch(() => setEmployees([]))
.finally(() => setLoading(false));
}, []);
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>
);
}
const filtered = employees.filter(
(e) =>
e.name.toLowerCase().includes(search.toLowerCase()) ||
e.role.toLowerCase().includes(search.toLowerCase()) ||
@@ -114,7 +128,7 @@ export default function StaffPage() {
</div>
<div className="px-6 py-3 border-t border-border text-sm text-muted">
Показано {filtered.length} из {mockEmployees.length} сотрудников
Показано {filtered.length} из {employees.length} сотрудников
</div>
</div>
</div>

View File

@@ -0,0 +1,91 @@
import { getAuthHeaders, refreshToken, logout } from './auth';
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/v1';
interface FetchOptions extends Omit<RequestInit, 'body'> {
body?: unknown;
}
export class ApiError extends Error {
constructor(
message: string,
public status: number,
public data?: unknown,
) {
super(message);
this.name = 'ApiError';
}
}
async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const data: Record<string, unknown> = await (response.json() as Promise<Record<string, unknown>>).catch(() => ({}));
throw new ApiError(
(typeof data.message === 'string' ? data.message : null) ?? `Ошибка запроса: ${response.status}`,
response.status,
data,
);
}
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
}
export async function fetchWithAuth<T>(
url: string,
options: FetchOptions = {},
): Promise<T> {
const { body, headers: customHeaders, ...rest } = options;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...getAuthHeaders(),
...(customHeaders as Record<string, string>),
};
const config: RequestInit = {
...rest,
headers,
body: body ? JSON.stringify(body) : undefined,
};
let response = await fetch(`${BASE_URL}${url}`, config);
if (response.status === 401) {
try {
const newToken = await refreshToken();
headers['Authorization'] = `Bearer ${newToken}`;
response = await fetch(`${BASE_URL}${url}`, { ...config, headers });
} catch {
logout();
throw new ApiError('Сессия истекла', 401);
}
}
return handleResponse<T>(response);
}
export const api = {
get<T>(url: string, options?: FetchOptions): Promise<T> {
return fetchWithAuth<T>(url, { ...options, method: 'GET' });
},
post<T>(url: string, body?: unknown, options?: FetchOptions): Promise<T> {
return fetchWithAuth<T>(url, { ...options, method: 'POST', body });
},
patch<T>(url: string, body?: unknown, options?: FetchOptions): Promise<T> {
return fetchWithAuth<T>(url, { ...options, method: 'PATCH', body });
},
put<T>(url: string, body?: unknown, options?: FetchOptions): Promise<T> {
return fetchWithAuth<T>(url, { ...options, method: 'PUT', body });
},
delete<T>(url: string, options?: FetchOptions): Promise<T> {
return fetchWithAuth<T>(url, { ...options, method: 'DELETE' });
},
};

View File

@@ -0,0 +1,82 @@
const TOKEN_KEY = 'fitcrm_access_token';
const REFRESH_TOKEN_KEY = 'fitcrm_refresh_token';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/v1';
export interface AuthTokens {
accessToken: string;
refreshToken: string;
}
export async function login(
phone: string,
password: string,
): Promise<AuthTokens> {
const response = await fetch(`${API_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone, password }),
});
if (!response.ok) {
const error: Record<string, unknown> = await (response.json() as Promise<Record<string, unknown>>).catch(() => ({}));
throw new Error(typeof error.message === 'string' ? error.message : 'Ошибка авторизации');
}
const data = (await response.json()) as AuthTokens;
localStorage.setItem(TOKEN_KEY, data.accessToken);
localStorage.setItem(REFRESH_TOKEN_KEY, data.refreshToken);
return data;
}
export async function refreshToken(): Promise<string> {
const refresh = localStorage.getItem(REFRESH_TOKEN_KEY);
if (!refresh) {
throw new Error('Нет refresh-токена');
}
const response = await fetch(`${API_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: refresh }),
});
if (!response.ok) {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
throw new Error('Сессия истекла');
}
const data = (await response.json()) as AuthTokens;
localStorage.setItem(TOKEN_KEY, data.accessToken);
if (data.refreshToken) {
localStorage.setItem(REFRESH_TOKEN_KEY, data.refreshToken);
}
return data.accessToken;
}
export function logout(): void {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
window.location.href = '/login';
}
export function getAccessToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem(TOKEN_KEY);
}
export function getAuthHeaders(): Record<string, string> {
const token = getAccessToken();
if (!token) return {};
return { Authorization: `Bearer ${token}` };
}
export function isAuthenticated(): boolean {
return !!getAccessToken();
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useEffect, useState, useCallback } from 'react';
import {
Search,
Plus,
@@ -9,6 +9,35 @@ import {
ChevronDown,
} from 'lucide-react';
import { cn } from '@/lib/cn';
import { api } from '@/lib/api';
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;
@@ -22,118 +51,73 @@ interface Club {
expiresAt: string;
}
const clubs: Club[] = [
{
id: '1',
name: 'FitGym Москва',
city: 'Москва',
license: 'Премиум',
users: 34,
clients: 2450,
status: 'active',
createdAt: '15.03.2025',
expiresAt: '15.03.2027',
},
{
id: '2',
name: 'SportMax Москва',
city: 'Москва',
license: 'Стандартная',
users: 18,
clients: 1120,
status: 'active',
createdAt: '01.06.2025',
expiresAt: '01.06.2026',
},
{
id: '3',
name: 'FitLife Казань',
city: 'Казань',
license: 'Стандартная',
users: 12,
clients: 680,
status: 'grace',
createdAt: '10.08.2025',
expiresAt: '16.02.2026',
},
{
id: '4',
name: 'PowerGym Уфа',
city: 'Уфа',
license: 'Стандартная',
users: 12,
clients: 540,
status: 'active',
createdAt: '14.02.2026',
expiresAt: '14.02.2027',
},
{
id: '5',
name: 'GymPro Самара',
city: 'Самара',
license: 'Стартовая',
users: 6,
clients: 320,
status: 'active',
createdAt: '20.09.2025',
expiresAt: '23.02.2026',
},
{
id: '6',
name: 'BodyFit Пермь',
city: 'Пермь',
license: 'Премиум',
users: 28,
clients: 1850,
status: 'active',
createdAt: '10.02.2026',
expiresAt: '10.02.2028',
},
{
id: '7',
name: 'FitZone Сочи',
city: 'Сочи',
license: 'Стартовая',
users: 4,
clients: 85,
status: 'trial',
createdAt: '17.02.2026',
expiresAt: '17.03.2026',
},
{
id: '8',
name: 'IronFit Новосибирск',
city: 'Новосибирск',
license: 'Стандартная',
users: 15,
clients: 920,
status: 'blocked',
createdAt: '05.01.2025',
expiresAt: '01.01.2026',
},
{
id: '9',
name: 'TopForm Екатеринбург',
city: 'Екатеринбург',
license: 'Премиум',
users: 42,
clients: 3200,
status: 'active',
createdAt: '22.04.2025',
expiresAt: '22.04.2027',
},
{
id: '10',
name: 'FlexGym Краснодар',
city: 'Краснодар',
license: 'Стартовая',
users: 5,
clients: 210,
status: 'active',
createdAt: '11.11.2025',
expiresAt: '11.11.2026',
},
];
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: 'Активен',
@@ -156,18 +140,79 @@ const licenseColors: Record<Club['license'], string> = {
};
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');
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(() => {
fetchClubs();
}, 300);
return () => clearTimeout(debounce);
}, [fetchClubs]);
const filtered = clubs.filter((club) => {
const matchesSearch =
club.name.toLowerCase().includes(search.toLowerCase()) ||
club.city.toLowerCase().includes(search.toLowerCase());
// 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 matchesSearch && matchesStatus;
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={fetchClubs}
className="text-sm text-primary hover:underline"
>
Попробовать снова
</button>
</div>
);
}
return (
<div>
<div className="flex items-center justify-between mb-6">
@@ -176,7 +221,7 @@ export default function ClubsPage() {
Управление клубами
</h1>
<p className="text-sm text-muted mt-1">
{clubs.length} клубов на платформе
{totalCount} клубов на платформе
</p>
</div>
<button 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">
@@ -301,7 +346,7 @@ export default function ClubsPage() {
<div className="px-4 py-3 border-t border-border flex items-center justify-between">
<p className="text-sm text-muted">
Показано {filtered.length} из {clubs.length} клубов
Показано {filtered.length} из {totalCount} клубов
</p>
</div>
</div>

View File

@@ -1,5 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import {
Building2,
KeyRound,
@@ -8,6 +9,39 @@ import {
AlertTriangle,
TrendingUp,
} from 'lucide-react';
import { api } from '@/lib/api';
interface ApiClub {
id: string;
name: string;
slug: string;
isActive: boolean;
createdAt: string;
license: {
id: string;
type: 'STARTER' | 'STANDARD' | 'PREMIUM';
startDate: string;
endDate: string;
isActive: boolean;
} | null;
_count: {
users: number;
clients: number;
};
}
interface RecentClub {
name: string;
license: string;
users: number;
date: string;
}
const licenseTypeMap: Record<string, string> = {
STARTER: 'Стартовая',
STANDARD: 'Стандартная',
PREMIUM: 'Премиум',
};
const stats = [
{
@@ -88,12 +122,6 @@ const alerts = [
},
];
const recentClubs = [
{ name: 'FitZone Сочи', license: 'Стартовая', users: 4, date: '17.02.2026' },
{ name: 'PowerGym Уфа', license: 'Стандартная', users: 12, date: '14.02.2026' },
{ name: 'BodyFit Пермь', license: 'Премиум', users: 28, date: '10.02.2026' },
];
const alertColors = {
critical: 'bg-red-100 text-error border-red-200',
warning: 'bg-orange-50 text-orange-700 border-orange-200',
@@ -101,6 +129,36 @@ const alertColors = {
};
export default function DashboardPage() {
const [recentClubs, setRecentClubs] = useState<RecentClub[]>([]);
const [clubsLoading, setClubsLoading] = useState(true);
useEffect(() => {
api
.get<{ data: ApiClub[]; hasMore: boolean; nextCursor: string | null }>(
'/admin/clubs?limit=5',
)
.then((res) => {
const mapped = res.data.map((club) => ({
name: club.name,
license: club.license
? licenseTypeMap[club.license.type] || 'Стартовая'
: '—',
users: club._count.users,
date: new Date(club.createdAt).toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}),
}));
setRecentClubs(mapped);
})
.catch(() => {
// Fallback: keep empty on error
setRecentClubs([]);
})
.finally(() => setClubsLoading(false));
}, []);
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-6">
@@ -152,40 +210,50 @@ export default function DashboardPage() {
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Недавно подключённые клубы
</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 text-muted font-medium">
Клуб
</th>
<th className="text-left py-2 text-muted font-medium">
Лицензия
</th>
<th className="text-right py-2 text-muted font-medium">
Пользователи
</th>
<th className="text-right py-2 text-muted font-medium">
Дата
</th>
</tr>
</thead>
<tbody>
{recentClubs.map((club) => (
<tr key={club.name} className="border-b border-border/50">
<td className="py-3 font-medium text-gray-900">
{club.name}
</td>
<td className="py-3 text-muted">{club.license}</td>
<td className="py-3 text-right text-gray-700">
{club.users}
</td>
<td className="py-3 text-right text-muted">{club.date}</td>
{clubsLoading ? (
<div className="flex items-center justify-center h-32">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
</div>
) : recentClubs.length === 0 ? (
<p className="text-sm text-muted text-center py-8">
Нет данных о клубах
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 text-muted font-medium">
Клуб
</th>
<th className="text-left py-2 text-muted font-medium">
Лицензия
</th>
<th className="text-right py-2 text-muted font-medium">
Пользователи
</th>
<th className="text-right py-2 text-muted font-medium">
Дата
</th>
</tr>
))}
</tbody>
</table>
</div>
</thead>
<tbody>
{recentClubs.map((club) => (
<tr key={club.name} className="border-b border-border/50">
<td className="py-3 font-medium text-gray-900">
{club.name}
</td>
<td className="py-3 text-muted">{club.license}</td>
<td className="py-3 text-right text-gray-700">
{club.users}
</td>
<td className="py-3 text-right text-muted">{club.date}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import {
Plus,
Check,
@@ -8,6 +8,7 @@ import {
MoreHorizontal,
} from 'lucide-react';
import { cn } from '@/lib/cn';
import { api } from '@/lib/api';
const licenseTypes = [
{
@@ -30,7 +31,7 @@ const licenseTypes = [
'Интеграция 1С': false,
},
limits: { users: 5, clients: 500 },
activeCount: 12,
activeCount: 0,
},
{
id: 'standard',
@@ -52,7 +53,7 @@ const licenseTypes = [
'Интеграция 1С': true,
},
limits: { users: 10, clients: 1000 },
activeCount: 22,
activeCount: 0,
},
{
id: 'premium',
@@ -74,10 +75,29 @@ const licenseTypes = [
'Интеграция 1С': true,
},
limits: { users: 'Без лимита', clients: 'Без лимита' },
activeCount: 8,
activeCount: 0,
},
];
interface ApiLicense {
id: string;
clubId: string;
type: 'STARTER' | 'STANDARD' | 'PREMIUM';
startDate: string;
endDate: string;
isActive: boolean;
gracePeriodEnd: string | null;
maxUsers: number;
maxClients: number;
createdAt: string;
updatedAt: string;
club: {
id: string;
name: string;
slug: string;
};
}
interface License {
id: string;
club: string;
@@ -88,16 +108,58 @@ interface License {
autoRenew: boolean;
}
const licenses: License[] = [
{ id: 'L-001', club: 'FitGym Москва', type: 'Премиум', status: 'active', startDate: '15.03.2025', endDate: '15.03.2027', autoRenew: true },
{ id: 'L-002', club: 'SportMax Москва', type: 'Стандартная', status: 'active', startDate: '01.06.2025', endDate: '01.06.2026', autoRenew: true },
{ id: 'L-003', club: 'FitLife Казань', type: 'Стандартная', status: 'expired', startDate: '10.08.2025', endDate: '16.02.2026', autoRenew: false },
{ id: 'L-004', club: 'GymPro Самара', type: 'Стартовая', status: 'expiring', startDate: '20.09.2025', endDate: '23.02.2026', autoRenew: true },
{ id: 'L-005', club: 'BodyFit Пермь', type: 'Премиум', status: 'active', startDate: '10.02.2026', endDate: '10.02.2028', autoRenew: true },
{ id: 'L-006', club: 'FitZone Сочи', type: 'Стартовая', status: 'trial', startDate: '17.02.2026', endDate: '17.03.2026', autoRenew: false },
{ id: 'L-007', club: 'TopForm Екатеринбург', type: 'Премиум', status: 'active', startDate: '22.04.2025', endDate: '22.04.2027', autoRenew: true },
{ id: 'L-008', club: 'IronFit Новосибирск', type: 'Стандартная', status: 'expired', startDate: '05.01.2025', endDate: '01.01.2026', autoRenew: false },
];
const licenseTypeMap: Record<string, License['type']> = {
STARTER: 'Стартовая',
STANDARD: 'Стандартная',
PREMIUM: 'Премиум',
};
const licenseTypeApiMap: Record<string, string> = {
STARTER: 'starter',
STANDARD: 'standard',
PREMIUM: 'premium',
};
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
function mapApiLicense(apiLic: ApiLicense): License {
const now = new Date();
const endDate = new Date(apiLic.endDate);
const startDate = new Date(apiLic.startDate);
const daysUntilExpiry = Math.ceil(
(endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
);
const licenseDuration = Math.ceil(
(endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24),
);
let status: License['status'] = 'active';
if (!apiLic.isActive || daysUntilExpiry < 0) {
status = 'expired';
} else if (licenseDuration <= 30) {
status = 'trial';
} else if (daysUntilExpiry <= 7) {
status = 'expiring';
} else {
status = 'active';
}
return {
id: apiLic.id.substring(0, 8).toUpperCase(),
club: apiLic.club.name,
type: licenseTypeMap[apiLic.type] || 'Стартовая',
status,
startDate: formatDate(apiLic.startDate),
endDate: formatDate(apiLic.endDate),
autoRenew: apiLic.isActive && daysUntilExpiry > 0,
};
}
const statusLabels: Record<License['status'], string> = {
active: 'Активна',
@@ -121,6 +183,75 @@ const typeColors: Record<License['type'], string> = {
export default function LicensesPage() {
const [tab, setTab] = useState<'types' | 'list'>('types');
const [licenses, setLicenses] = useState<License[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [typeCounts, setTypeCounts] = useState<Record<string, number>>({
starter: 0,
standard: 0,
premium: 0,
});
useEffect(() => {
setLoading(true);
setError(null);
api
.get<{ data: ApiLicense[]; hasMore: boolean; nextCursor: string | null }>(
'/licenses?limit=100',
)
.then((res) => {
const mapped = res.data.map(mapApiLicense);
setLicenses(mapped);
// Count active licenses per type
const counts: Record<string, number> = {
starter: 0,
standard: 0,
premium: 0,
};
for (const apiLic of res.data) {
const key = licenseTypeApiMap[apiLic.type];
if (key && apiLic.isActive) {
counts[key] = (counts[key] || 0) + 1;
}
}
setTypeCounts(counts);
})
.catch((err) => {
const message =
err instanceof Error ? err.message : 'Ошибка загрузки лицензий';
setError(message);
})
.finally(() => setLoading(false));
}, []);
// Update licenseTypes with live counts
const enrichedLicenseTypes = licenseTypes.map((type) => ({
...type,
activeCount: typeCounts[type.id] || 0,
}));
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={() => window.location.reload()}
className="text-sm text-primary hover:underline"
>
Попробовать снова
</button>
</div>
);
}
return (
<div>
@@ -166,7 +297,7 @@ export default function LicensesPage() {
{tab === 'types' && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{licenseTypes.map((type) => (
{enrichedLicenseTypes.map((type) => (
<div
key={type.id}
className={cn(

View File

@@ -0,0 +1,91 @@
import { getAuthHeaders, refreshToken, logout } from './auth';
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/v1';
interface FetchOptions extends Omit<RequestInit, 'body'> {
body?: unknown;
}
export class ApiError extends Error {
constructor(
message: string,
public status: number,
public data?: unknown,
) {
super(message);
this.name = 'ApiError';
}
}
async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const data: Record<string, unknown> = await (response.json() as Promise<Record<string, unknown>>).catch(() => ({}));
throw new ApiError(
(typeof data.message === 'string' ? data.message : null) ?? `Ошибка запроса: ${response.status}`,
response.status,
data,
);
}
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
}
export async function fetchWithAuth<T>(
url: string,
options: FetchOptions = {},
): Promise<T> {
const { body, headers: customHeaders, ...rest } = options;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...getAuthHeaders(),
...(customHeaders as Record<string, string>),
};
const config: RequestInit = {
...rest,
headers,
body: body ? JSON.stringify(body) : undefined,
};
let response = await fetch(`${BASE_URL}${url}`, config);
if (response.status === 401) {
try {
const newToken = await refreshToken();
headers['Authorization'] = `Bearer ${newToken}`;
response = await fetch(`${BASE_URL}${url}`, { ...config, headers });
} catch {
logout();
throw new ApiError('Сессия истекла', 401);
}
}
return handleResponse<T>(response);
}
export const api = {
get<T>(url: string, options?: FetchOptions): Promise<T> {
return fetchWithAuth<T>(url, { ...options, method: 'GET' });
},
post<T>(url: string, body?: unknown, options?: FetchOptions): Promise<T> {
return fetchWithAuth<T>(url, { ...options, method: 'POST', body });
},
patch<T>(url: string, body?: unknown, options?: FetchOptions): Promise<T> {
return fetchWithAuth<T>(url, { ...options, method: 'PATCH', body });
},
put<T>(url: string, body?: unknown, options?: FetchOptions): Promise<T> {
return fetchWithAuth<T>(url, { ...options, method: 'PUT', body });
},
delete<T>(url: string, options?: FetchOptions): Promise<T> {
return fetchWithAuth<T>(url, { ...options, method: 'DELETE' });
},
};

View File

@@ -0,0 +1,82 @@
const TOKEN_KEY = 'fitcrm_platform_access_token';
const REFRESH_TOKEN_KEY = 'fitcrm_platform_refresh_token';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/v1';
export interface AuthTokens {
accessToken: string;
refreshToken: string;
}
export async function login(
phone: string,
password: string,
): Promise<AuthTokens> {
const response = await fetch(`${API_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone, password }),
});
if (!response.ok) {
const error: Record<string, unknown> = await (response.json() as Promise<Record<string, unknown>>).catch(() => ({}));
throw new Error(typeof error.message === 'string' ? error.message : 'Ошибка авторизации');
}
const data = (await response.json()) as AuthTokens;
localStorage.setItem(TOKEN_KEY, data.accessToken);
localStorage.setItem(REFRESH_TOKEN_KEY, data.refreshToken);
return data;
}
export async function refreshToken(): Promise<string> {
const refresh = localStorage.getItem(REFRESH_TOKEN_KEY);
if (!refresh) {
throw new Error('Нет refresh-токена');
}
const response = await fetch(`${API_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: refresh }),
});
if (!response.ok) {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
throw new Error('Сессия истекла');
}
const data = (await response.json()) as AuthTokens;
localStorage.setItem(TOKEN_KEY, data.accessToken);
if (data.refreshToken) {
localStorage.setItem(REFRESH_TOKEN_KEY, data.refreshToken);
}
return data.accessToken;
}
export function logout(): void {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
window.location.href = '/login';
}
export function getAccessToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem(TOKEN_KEY);
}
export function getAuthHeaders(): Record<string, string> {
const token = getAccessToken();
if (!token) return {};
return { Authorization: `Bearer ${token}` };
}
export function isAuthenticated(): boolean {
return !!getAccessToken();
}

View File

@@ -9,6 +9,9 @@
"lint": "eslint src/ --ext .ts",
"dev": "tsc --build --watch"
},
"dependencies": {
"@fitcrm/shared-types": "workspace:*"
},
"devDependencies": {
"typescript": "^5.5.0"
}

View File

@@ -0,0 +1,547 @@
import { HttpClient, type HttpClientConfig, type QueryParams } from './http';
import type {
PaginationParams,
PaginatedResponse,
LoginRequest,
LoginResponse,
RefreshRequest,
RefreshResponse,
User,
Client,
Department,
CreateDepartmentRequest,
UpdateDepartmentRequest,
Room,
CreateRoomRequest,
UpdateRoomRequest,
ServiceCategory,
Service,
Package,
Club,
CreateClubRequest,
UpdateClubRequest,
License,
CreateLicenseRequest,
UpdateLicenseRequest,
Report,
GenerateReportRequest,
WebhookSubscription,
WebhookLog,
CreateWebhookRequest,
UpdateWebhookRequest,
IntegrationConfig,
UpdateIntegrationConfigRequest,
SyncLog,
TriggerSyncRequest,
Training,
FunnelEntry,
Sale,
StatsSummary,
FunnelStats,
RatingEntry,
Notification,
NotificationSetting,
SipCall,
SipSettings,
UsageRecord,
LimitCheck,
} from './types';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function q(params?: any): QueryParams | undefined {
if (!params) return undefined;
const result: QueryParams = {};
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
result[key] = typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
? value
: String(value);
}
}
return result;
}
export class FitCrmApiClient {
private http: HttpClient;
auth: AuthApi;
users: UsersApi;
clients: ClientsApi;
departments: DepartmentsApi;
rooms: RoomsApi;
catalog: CatalogApi;
clubs: ClubsApi;
licenses: LicensesApi;
reports: ReportsApi;
webhooks: WebhooksApi;
integration: IntegrationApi;
schedule: ScheduleApi;
funnel: FunnelApi;
sales: SalesApi;
stats: StatsApi;
notifications: NotificationsApi;
sip: SipApi;
metering: MeteringApi;
constructor(config: HttpClientConfig) {
this.http = new HttpClient(config);
this.auth = new AuthApi(this.http);
this.users = new UsersApi(this.http);
this.clients = new ClientsApi(this.http);
this.departments = new DepartmentsApi(this.http);
this.rooms = new RoomsApi(this.http);
this.catalog = new CatalogApi(this.http);
this.clubs = new ClubsApi(this.http);
this.licenses = new LicensesApi(this.http);
this.reports = new ReportsApi(this.http);
this.webhooks = new WebhooksApi(this.http);
this.integration = new IntegrationApi(this.http);
this.schedule = new ScheduleApi(this.http);
this.funnel = new FunnelApi(this.http);
this.sales = new SalesApi(this.http);
this.stats = new StatsApi(this.http);
this.notifications = new NotificationsApi(this.http);
this.sip = new SipApi(this.http);
this.metering = new MeteringApi(this.http);
}
}
class AuthApi {
constructor(private http: HttpClient) {}
login(data: LoginRequest) {
return this.http.post<LoginResponse>('/auth/login', data);
}
refresh(data: RefreshRequest) {
return this.http.post<RefreshResponse>('/auth/refresh', data);
}
}
class UsersApi {
constructor(private http: HttpClient) {}
list(params?: PaginationParams & { role?: string }) {
return this.http.get<PaginatedResponse<User>>('/users', q(params));
}
getById(id: string) {
return this.http.get<User>(`/users/${id}`);
}
trainers(params?: PaginationParams) {
return this.http.get<PaginatedResponse<User>>('/users/trainers', q(params));
}
}
class ClientsApi {
constructor(private http: HttpClient) {}
list(params?: PaginationParams & { search?: string; trainerId?: string }) {
return this.http.get<PaginatedResponse<Client>>('/clients', q(params));
}
getById(id: string) {
return this.http.get<Client>(`/clients/${id}`);
}
create(data: Partial<Client>) {
return this.http.post<Client>('/clients', data);
}
update(id: string, data: Partial<Client>) {
return this.http.patch<Client>(`/clients/${id}`, data);
}
delete(id: string) {
return this.http.delete<void>(`/clients/${id}`);
}
sleeping(params?: PaginationParams) {
return this.http.get<PaginatedResponse<Client>>('/clients/sleeping', q(params));
}
}
class DepartmentsApi {
constructor(private http: HttpClient) {}
list(params?: PaginationParams & { search?: string }) {
return this.http.get<PaginatedResponse<Department>>('/departments', q(params));
}
getById(id: string) {
return this.http.get<Department>(`/departments/${id}`);
}
create(data: CreateDepartmentRequest) {
return this.http.post<Department>('/departments', data);
}
update(id: string, data: UpdateDepartmentRequest) {
return this.http.patch<Department>(`/departments/${id}`, data);
}
delete(id: string) {
return this.http.delete<void>(`/departments/${id}`);
}
}
class RoomsApi {
constructor(private http: HttpClient) {}
list(params?: PaginationParams & { isActive?: boolean }) {
return this.http.get<PaginatedResponse<Room>>('/rooms', q(params));
}
getById(id: string) {
return this.http.get<Room>(`/rooms/${id}`);
}
create(data: CreateRoomRequest) {
return this.http.post<Room>('/rooms', data);
}
update(id: string, data: UpdateRoomRequest) {
return this.http.patch<Room>(`/rooms/${id}`, data);
}
delete(id: string) {
return this.http.delete<void>(`/rooms/${id}`);
}
}
class CatalogApi {
categories: CategoriesApi;
services: ServicesApi;
packages: PackagesApi;
constructor(http: HttpClient) {
this.categories = new CategoriesApi(http);
this.services = new ServicesApi(http);
this.packages = new PackagesApi(http);
}
}
class CategoriesApi {
constructor(private http: HttpClient) {}
list(params?: PaginationParams) {
return this.http.get<PaginatedResponse<ServiceCategory>>('/catalog/categories', q(params));
}
getById(id: string) {
return this.http.get<ServiceCategory>(`/catalog/categories/${id}`);
}
create(data: Partial<ServiceCategory>) {
return this.http.post<ServiceCategory>('/catalog/categories', data);
}
update(id: string, data: Partial<ServiceCategory>) {
return this.http.patch<ServiceCategory>(`/catalog/categories/${id}`, data);
}
delete(id: string) {
return this.http.delete<void>(`/catalog/categories/${id}`);
}
}
class ServicesApi {
constructor(private http: HttpClient) {}
list(params?: PaginationParams & { categoryId?: string }) {
return this.http.get<PaginatedResponse<Service>>('/catalog/services', q(params));
}
getById(id: string) {
return this.http.get<Service>(`/catalog/services/${id}`);
}
create(data: Partial<Service>) {
return this.http.post<Service>('/catalog/services', data);
}
update(id: string, data: Partial<Service>) {
return this.http.patch<Service>(`/catalog/services/${id}`, data);
}
delete(id: string) {
return this.http.delete<void>(`/catalog/services/${id}`);
}
}
class PackagesApi {
constructor(private http: HttpClient) {}
list(params?: PaginationParams & { serviceId?: string }) {
return this.http.get<PaginatedResponse<Package>>('/catalog/packages', q(params));
}
getById(id: string) {
return this.http.get<Package>(`/catalog/packages/${id}`);
}
create(data: Partial<Package>) {
return this.http.post<Package>('/catalog/packages', data);
}
update(id: string, data: Partial<Package>) {
return this.http.patch<Package>(`/catalog/packages/${id}`, data);
}
delete(id: string) {
return this.http.delete<void>(`/catalog/packages/${id}`);
}
}
class ClubsApi {
constructor(private http: HttpClient) {}
list(params?: PaginationParams & { search?: string; isActive?: boolean }) {
return this.http.get<PaginatedResponse<Club>>('/admin/clubs', q(params));
}
getById(id: string) {
return this.http.get<Club>(`/admin/clubs/${id}`);
}
create(data: CreateClubRequest) {
return this.http.post<Club>('/admin/clubs', data);
}
update(id: string, data: UpdateClubRequest) {
return this.http.patch<Club>(`/admin/clubs/${id}`, data);
}
delete(id: string) {
return this.http.delete<void>(`/admin/clubs/${id}`);
}
}
class LicensesApi {
constructor(private http: HttpClient) {}
list(params?: PaginationParams) {
return this.http.get<PaginatedResponse<License>>('/licenses', q(params));
}
getById(id: string) {
return this.http.get<License>(`/licenses/${id}`);
}
getMy() {
return this.http.get<License>('/licenses/my');
}
create(data: CreateLicenseRequest) {
return this.http.post<License>('/licenses', data);
}
update(id: string, data: UpdateLicenseRequest) {
return this.http.patch<License>(`/licenses/${id}`, data);
}
renew(id: string) {
return this.http.post<License>(`/licenses/${id}/renew`);
}
}
class ReportsApi {
constructor(private http: HttpClient) {}
list(params?: PaginationParams & { type?: string; status?: string }) {
return this.http.get<PaginatedResponse<Report>>('/reports', q(params));
}
getById(id: string) {
return this.http.get<Report>(`/reports/${id}`);
}
generate(data: GenerateReportRequest) {
return this.http.post<Report>('/reports/generate', data);
}
download(id: string) {
return this.http.get<{ url: string }>(`/reports/${id}/download`);
}
}
class WebhooksApi {
constructor(private http: HttpClient) {}
list(params?: PaginationParams) {
return this.http.get<PaginatedResponse<WebhookSubscription>>('/webhooks', q(params));
}
getById(id: string) {
return this.http.get<WebhookSubscription>(`/webhooks/${id}`);
}
create(data: CreateWebhookRequest) {
return this.http.post<WebhookSubscription>('/webhooks', data);
}
update(id: string, data: UpdateWebhookRequest) {
return this.http.patch<WebhookSubscription>(`/webhooks/${id}`, data);
}
delete(id: string) {
return this.http.delete<void>(`/webhooks/${id}`);
}
getLogs(id: string, params?: PaginationParams) {
return this.http.get<PaginatedResponse<WebhookLog>>(`/webhooks/${id}/logs`, q(params));
}
test(id: string) {
return this.http.post<{ success: boolean }>(`/webhooks/${id}/test`);
}
}
class IntegrationApi {
constructor(private http: HttpClient) {}
getConfig() {
return this.http.get<IntegrationConfig>('/integration/config');
}
upsertConfig(data: UpdateIntegrationConfigRequest) {
return this.http.put<IntegrationConfig>('/integration/config', data);
}
triggerSync(data: TriggerSyncRequest) {
return this.http.post<SyncLog>('/integration/sync', data);
}
getSyncLogs(params?: PaginationParams & { status?: string }) {
return this.http.get<PaginatedResponse<SyncLog>>('/integration/sync', q(params));
}
}
class ScheduleApi {
constructor(private http: HttpClient) {}
list(params?: PaginationParams & { trainerId?: string; date?: string; status?: string }) {
return this.http.get<PaginatedResponse<Training>>('/schedule', q(params));
}
getById(id: string) {
return this.http.get<Training>(`/schedule/${id}`);
}
create(data: Partial<Training>) {
return this.http.post<Training>('/schedule', data);
}
cancel(id: string, reason?: string) {
return this.http.post<Training>(`/schedule/${id}/cancel`, { reason });
}
reschedule(id: string, data: { startTime: string; endTime: string }) {
return this.http.post<Training>(`/schedule/${id}/reschedule`, data);
}
}
class FunnelApi {
constructor(private http: HttpClient) {}
list(params?: PaginationParams & { stage?: string; trainerId?: string }) {
return this.http.get<PaginatedResponse<FunnelEntry>>('/funnel', q(params));
}
getById(id: string) {
return this.http.get<FunnelEntry>(`/funnel/${id}`);
}
assign(id: string, trainerId: string) {
return this.http.post<FunnelEntry>(`/funnel/${id}/assign`, { trainerId });
}
transfer(id: string, data: { trainerId: string; departmentId?: string }) {
return this.http.post<FunnelEntry>(`/funnel/${id}/transfer`, data);
}
move(id: string, stage: string) {
return this.http.post<FunnelEntry>(`/funnel/${id}/move`, { stage });
}
}
class SalesApi {
constructor(private http: HttpClient) {}
list(params?: PaginationParams & { status?: string; clientId?: string }) {
return this.http.get<PaginatedResponse<Sale>>('/sales', q(params));
}
create(data: Partial<Sale>) {
return this.http.post<Sale>('/sales', data);
}
getDebts(params?: PaginationParams) {
return this.http.get<PaginatedResponse<Sale>>('/sales/debts', q(params));
}
}
class StatsApi {
constructor(private http: HttpClient) {}
summary() {
return this.http.get<StatsSummary>('/stats/summary');
}
funnel() {
return this.http.get<FunnelStats[]>('/stats/funnel');
}
rating(params?: PaginationParams) {
return this.http.get<PaginatedResponse<RatingEntry>>('/stats/rating', q(params));
}
}
class NotificationsApi {
constructor(private http: HttpClient) {}
list(params?: PaginationParams & { isRead?: boolean }) {
return this.http.get<PaginatedResponse<Notification>>('/notifications', q(params));
}
markRead(id: string) {
return this.http.patch<Notification>(`/notifications/${id}/read`);
}
getSettings() {
return this.http.get<NotificationSetting[]>('/notifications/settings');
}
updateSetting(id: string, data: { enabled: boolean; customText?: string }) {
return this.http.patch<NotificationSetting>(`/notifications/settings/${id}`, data);
}
}
class SipApi {
constructor(private http: HttpClient) {}
getCalls(params?: PaginationParams) {
return this.http.get<PaginatedResponse<SipCall>>('/sip/calls', q(params));
}
getSettings() {
return this.http.get<SipSettings>('/sip/settings');
}
updateSettings(data: Partial<SipSettings>) {
return this.http.patch<SipSettings>('/sip/settings', data);
}
}
class MeteringApi {
constructor(private http: HttpClient) {}
getUsage(period?: string) {
return this.http.get<UsageRecord>('/metering/usage', period ? { period } : undefined);
}
checkLimits() {
return this.http.get<LimitCheck[]>('/metering/limits');
}
}

View File

@@ -0,0 +1,108 @@
export interface HttpClientConfig {
baseUrl: string;
getToken: () => string | null;
onUnauthorized?: () => void;
refreshToken?: () => Promise<string>;
}
export class ApiError extends Error {
constructor(
message: string,
public status: number,
public data?: unknown,
) {
super(message);
this.name = 'ApiError';
}
}
export type QueryParams = Record<string, string | number | boolean | undefined>;
interface RequestOptions extends Omit<RequestInit, 'body'> {
body?: unknown;
params?: QueryParams;
}
export class HttpClient {
constructor(private config: HttpClientConfig) {}
private buildUrl(path: string, params?: QueryParams): string {
const url = `${this.config.baseUrl}${path}`;
if (!params) return url;
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) {
searchParams.set(key, String(value));
}
}
const qs = searchParams.toString();
return qs ? `${url}?${qs}` : url;
}
private async handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const data = await response.json().catch(() => ({})) as Record<string, unknown>;
throw new ApiError(
(typeof data.message === 'string' ? data.message : null) ?? `Request error: ${response.status}`,
response.status,
data,
);
}
if (response.status === 204) return undefined as T;
return response.json() as Promise<T>;
}
async request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { body, params, headers: customHeaders, ...rest } = options;
const token = this.config.getToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(customHeaders as Record<string, string>),
};
const config: RequestInit = {
...rest,
headers,
body: body ? JSON.stringify(body) : undefined,
};
const url = this.buildUrl(path, params);
let response = await fetch(url, config);
if (response.status === 401 && this.config.refreshToken) {
try {
const newToken = await this.config.refreshToken();
headers['Authorization'] = `Bearer ${newToken}`;
response = await fetch(url, { ...config, headers });
} catch {
this.config.onUnauthorized?.();
throw new ApiError('Session expired', 401);
}
}
return this.handleResponse<T>(response);
}
get<T>(path: string, params?: QueryParams): Promise<T> {
return this.request<T>(path, { method: 'GET', params });
}
post<T>(path: string, body?: unknown, params?: QueryParams): Promise<T> {
return this.request<T>(path, { method: 'POST', body, params });
}
put<T>(path: string, body?: unknown): Promise<T> {
return this.request<T>(path, { method: 'PUT', body });
}
patch<T>(path: string, body?: unknown): Promise<T> {
return this.request<T>(path, { method: 'PATCH', body });
}
delete<T>(path: string): Promise<T> {
return this.request<T>(path, { method: 'DELETE' });
}
}

View File

@@ -1,5 +1,4 @@
// @fitcrm/api-client
// Auto-generated API client from OpenAPI specification
// This package will be populated when the API OpenAPI spec is available.
export {};
export { FitCrmApiClient } from './client';
export { HttpClient, ApiError } from './http';
export type { HttpClientConfig } from './http';
export type * from './types';

View File

@@ -0,0 +1,482 @@
import type {
UserRole,
LicenseType,
TrainingStatus,
FunnelStage,
SaleStatus,
WebhookEvent,
NotificationType,
} from '@fitcrm/shared-types';
// --- Common ---
export interface PaginationParams {
cursor?: string;
limit?: number;
}
export interface PaginatedResponse<T> {
data: T[];
nextCursor: string | null;
hasMore: boolean;
total?: number;
}
// --- Auth ---
export interface LoginRequest {
phone: string;
password: string;
}
export interface LoginResponse {
accessToken: string;
refreshToken: string;
user: {
id: string;
firstName: string;
lastName: string;
role: UserRole;
clubId: string;
};
}
export interface RefreshRequest {
refreshToken: string;
}
export interface RefreshResponse {
accessToken: string;
refreshToken?: string;
}
// --- Users ---
export interface User {
id: string;
clubId: string;
firstName: string;
lastName: string;
phone: string;
email: string | null;
role: UserRole;
isActive: boolean;
departmentId: string | null;
createdAt: string;
}
// --- Clients ---
export interface Client {
id: string;
clubId: string;
firstName: string;
lastName: string;
phone: string;
email: string | null;
birthDate: string | null;
gender: string | null;
notes: string | null;
trainerId: string | null;
createdAt: string;
}
// --- Departments ---
export interface Department {
id: string;
clubId: string;
name: string;
description: string | null;
createdAt: string;
_count?: { staff: number };
}
export interface CreateDepartmentRequest {
name: string;
description?: string;
}
export interface UpdateDepartmentRequest {
name?: string;
description?: string;
}
// --- Rooms ---
export interface Room {
id: string;
clubId: string;
name: string;
capacity: number | null;
description: string | null;
isActive: boolean;
createdAt: string;
}
export interface CreateRoomRequest {
name: string;
capacity?: number;
description?: string;
isActive?: boolean;
}
export interface UpdateRoomRequest {
name?: string;
capacity?: number;
description?: string;
isActive?: boolean;
}
// --- Catalog ---
export interface ServiceCategory {
id: string;
clubId: string;
name: string;
description: string | null;
sortOrder: number;
createdAt: string;
}
export interface Service {
id: string;
clubId: string;
categoryId: string;
name: string;
description: string | null;
duration: number;
price: number;
isActive: boolean;
createdAt: string;
category?: ServiceCategory;
}
export interface Package {
id: string;
clubId: string;
serviceId: string;
name: string;
sessionsCount: number;
price: number;
validDays: number;
isActive: boolean;
createdAt: string;
service?: Service;
}
// --- Clubs (admin) ---
export interface Club {
id: string;
name: string;
slug: string;
address: string | null;
phone: string | null;
email: string | null;
logoUrl: string | null;
timezone: string | null;
isActive: boolean;
createdAt: string;
license?: License;
}
export interface CreateClubRequest {
name: string;
slug: string;
address?: string;
phone?: string;
email?: string;
}
export interface UpdateClubRequest {
name?: string;
address?: string;
phone?: string;
email?: string;
logoUrl?: string;
timezone?: string;
isActive?: boolean;
}
// --- Licenses ---
export interface License {
id: string;
clubId: string;
type: LicenseType;
startDate: string;
endDate: string;
isActive: boolean;
gracePeriodEnd: string | null;
maxUsers: number;
maxClients: number;
createdAt: string;
status?: string;
daysUntilExpiry?: number;
}
export interface CreateLicenseRequest {
clubId: string;
type: LicenseType;
maxUsers: number;
maxClients: number;
startDate?: string;
endDate?: string;
}
export interface UpdateLicenseRequest {
type?: LicenseType;
maxUsers?: number;
maxClients?: number;
endDate?: string;
isActive?: boolean;
}
// --- Reports ---
export interface Report {
id: string;
clubId: string;
generatedById: string;
type: string;
title: string;
parametersJson: Record<string, unknown> | null;
fileUrl: string | null;
status: 'PENDING' | 'GENERATING' | 'COMPLETED' | 'FAILED';
createdAt: string;
}
export interface GenerateReportRequest {
type: string;
title: string;
parameters?: Record<string, unknown>;
}
// --- Webhooks ---
export interface WebhookSubscription {
id: string;
clubId: string;
url: string;
events: WebhookEvent[];
isActive: boolean;
secret?: string;
createdAt: string;
}
export interface WebhookLog {
id: string;
clubId: string;
subscriptionId: string;
event: string;
payload: unknown;
statusCode: number | null;
response: string | null;
attempt: number;
deliveredAt: string | null;
createdAt: string;
}
export interface CreateWebhookRequest {
url: string;
events: WebhookEvent[];
}
export interface UpdateWebhookRequest {
url?: string;
events?: WebhookEvent[];
isActive?: boolean;
}
// --- Integration ---
export interface IntegrationConfig {
id: string;
clubId: string;
provider: string;
apiUrl: string | null;
apiKey: string | null;
syncEnabled: boolean;
syncIntervalMinutes: number;
lastSyncAt: string | null;
createdAt: string;
}
export interface UpdateIntegrationConfigRequest {
apiUrl?: string;
apiKey?: string;
syncEnabled?: boolean;
syncIntervalMinutes?: number;
}
export interface SyncLog {
id: string;
clubId: string;
direction: string;
entity: string;
status: string;
recordsProcessed: number;
recordsFailed: number;
errorMessage: string | null;
startedAt: string;
completedAt: string | null;
createdAt: string;
}
export interface TriggerSyncRequest {
direction: 'import' | 'export';
entity: string;
}
// --- Schedule ---
export interface Training {
id: string;
clubId: string;
trainerId: string;
clientId: string;
roomId: string | null;
serviceId: string | null;
startTime: string;
endTime: string;
status: TrainingStatus;
notes: string | null;
createdAt: string;
trainer?: User;
client?: Client;
}
// --- Funnel ---
export interface FunnelEntry {
id: string;
clubId: string;
clientId: string;
trainerId: string | null;
stage: FunnelStage;
source: string | null;
comment: string | null;
createdAt: string;
client?: Client;
trainer?: User;
}
// --- Sales ---
export interface Sale {
id: string;
clubId: string;
clientId: string;
trainerId: string;
packageId: string | null;
amount: number;
status: SaleStatus;
createdAt: string;
client?: Client;
}
// --- Stats ---
export interface StatsSummary {
activeClients: number;
newClients: number;
trainingsToday: number;
revenue: number;
conversionRate: number;
sleepingClients: number;
}
export interface FunnelStats {
stage: FunnelStage;
count: number;
conversion: number;
}
export interface RatingEntry {
trainerId: string;
trainerName: string;
score: number;
newClients: number;
conversions: number;
totalTrainings: number;
}
// --- Notifications ---
export interface Notification {
id: string;
clubId: string;
userId: string;
type: NotificationType;
title: string;
body: string;
data: unknown;
isRead: boolean;
sentAt: string;
createdAt: string;
}
export interface NotificationSetting {
id: string;
clubId: string;
notificationType: NotificationType;
enabled: boolean;
customText: string | null;
}
// --- SIP ---
export interface SipCall {
id: string;
clubId: string;
callerId: string;
clientId: string | null;
callerNumber: string;
calleeNumber: string;
direction: string;
status: string;
startedAt: string;
duration: number | null;
createdAt: string;
}
export interface SipSettings {
domain: string;
username: string;
wsUrl: string;
stunServer: string;
turnServer: string;
}
// --- Metering ---
export interface UsageRecord {
id: string;
clubId: string;
period: string;
activeUsers: number;
clients: number;
callStorageGb: number;
webhooksSent: number;
pushSent: number;
apiRequests: number;
}
export interface LimitCheck {
resource: string;
current: number;
limit: number;
exceeded: boolean;
}
// --- Club Modules ---
export interface ClubModule {
id: string;
clubId: string;
moduleId: string;
enabled: boolean;
limitsJson: Record<string, unknown> | null;
usageJson: Record<string, unknown> | null;
}

View File

@@ -3,8 +3,14 @@
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"composite": true
"composite": true,
"lib": ["ES2022", "DOM"],
"noUnusedLocals": false,
"noUnusedParameters": false
},
"references": [
{ "path": "../shared-types" }
],
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

4
pnpm-lock.yaml generated
View File

@@ -315,6 +315,10 @@ importers:
version: 5.9.3
packages/api-client:
dependencies:
'@fitcrm/shared-types':
specifier: workspace:*
version: link:../shared-types
devDependencies:
typescript:
specifier: ^5.5.0