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
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:
@@ -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[]> {
|
async checkLimits(clubId: string): Promise<LimitCheck[]> {
|
||||||
const periodStart = this.getCurrentPeriodStart();
|
const periodStart = this.getCurrentPeriodStart();
|
||||||
@@ -115,22 +117,11 @@ export class MeteringService {
|
|||||||
usage = await this.aggregateUsage(clubId);
|
usage = await this.aggregateUsage(clubId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get license limits
|
// Get base limits from license (max_users, max_clients)
|
||||||
const licenses = await this.prisma.$queryRawUnsafe<
|
const licenses = await this.prisma.$queryRawUnsafe<
|
||||||
{
|
{ maxUsers: number; maxClients: number }[]
|
||||||
maxUsers: number;
|
|
||||||
maxClients: number;
|
|
||||||
maxCallStorageGb: number;
|
|
||||||
maxWebhooks: number;
|
|
||||||
maxPush: number;
|
|
||||||
maxApiRequests: number;
|
|
||||||
}[]
|
|
||||||
>(
|
>(
|
||||||
`SELECT max_users AS "maxUsers", max_clients AS "maxClients",
|
`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"
|
|
||||||
FROM licenses
|
FROM licenses
|
||||||
WHERE club_id = $1 AND is_active = true
|
WHERE club_id = $1 AND is_active = true
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
@@ -141,44 +132,69 @@ export class MeteringService {
|
|||||||
return [];
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
resource: 'active_users',
|
resource: 'active_users',
|
||||||
current: usage.activeUsers,
|
current: usage.activeUsers,
|
||||||
limit: limits.maxUsers,
|
limit: license.maxUsers,
|
||||||
exceeded: usage.activeUsers > limits.maxUsers,
|
exceeded: usage.activeUsers > license.maxUsers,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
resource: 'clients',
|
resource: 'clients',
|
||||||
current: usage.clients,
|
current: usage.clients,
|
||||||
limit: limits.maxClients,
|
limit: license.maxClients,
|
||||||
exceeded: usage.clients > limits.maxClients,
|
exceeded: usage.clients > license.maxClients,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
resource: 'call_storage_gb',
|
resource: 'call_storage_gb',
|
||||||
current: usage.callStorageGb,
|
current: usage.callStorageGb,
|
||||||
limit: limits.maxCallStorageGb,
|
limit: (sipRecordingLimits as Record<string, number>).max_storage_gb ?? 0,
|
||||||
exceeded: usage.callStorageGb > limits.maxCallStorageGb,
|
exceeded:
|
||||||
|
usage.callStorageGb >
|
||||||
|
((sipRecordingLimits as Record<string, number>).max_storage_gb ?? 0),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
resource: 'webhooks_sent',
|
resource: 'webhooks_sent',
|
||||||
current: usage.webhooksSent,
|
current: usage.webhooksSent,
|
||||||
limit: limits.maxWebhooks,
|
limit: (webhookLimits as Record<string, number>).max_events ?? 10000,
|
||||||
exceeded: usage.webhooksSent > limits.maxWebhooks,
|
exceeded:
|
||||||
|
usage.webhooksSent >
|
||||||
|
((webhookLimits as Record<string, number>).max_events ?? 10000),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
resource: 'push_sent',
|
resource: 'push_sent',
|
||||||
current: usage.pushSent,
|
current: usage.pushSent,
|
||||||
limit: limits.maxPush,
|
limit: (pushLimits as Record<string, number>).max_messages ?? 5000,
|
||||||
exceeded: usage.pushSent > limits.maxPush,
|
exceeded:
|
||||||
|
usage.pushSent >
|
||||||
|
((pushLimits as Record<string, number>).max_messages ?? 5000),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
resource: 'api_requests',
|
resource: 'api_requests',
|
||||||
current: usage.apiRequests,
|
current: usage.apiRequests,
|
||||||
limit: limits.maxApiRequests,
|
limit: 50000,
|
||||||
exceeded: usage.apiRequests > limits.maxApiRequests,
|
exceeded: usage.apiRequests > 50000,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -252,15 +268,12 @@ export class MeteringService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Sum call recording storage in GB for a club.
|
* 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> {
|
private async sumCallStorage(_clubId: string): Promise<number> {
|
||||||
const rows = await this.prisma.$queryRawUnsafe<{ total: number }[]>(
|
// TODO: Query actual call recording storage when sip_recording module is implemented
|
||||||
`SELECT COALESCE(SUM(file_size_bytes), 0) / (1024.0 * 1024.0 * 1024.0) AS total
|
return 0;
|
||||||
FROM call_recordings
|
|
||||||
WHERE club_id = $1`,
|
|
||||||
clubId,
|
|
||||||
);
|
|
||||||
return Math.round((rows[0]?.total ?? 0) * 100) / 100;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -271,7 +284,7 @@ export class MeteringService {
|
|||||||
since: Date,
|
since: Date,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const rows = await this.prisma.$queryRawUnsafe<{ count: bigint }[]>(
|
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`,
|
WHERE club_id = $1 AND created_at >= $2`,
|
||||||
clubId,
|
clubId,
|
||||||
since,
|
since,
|
||||||
@@ -284,7 +297,7 @@ export class MeteringService {
|
|||||||
*/
|
*/
|
||||||
private async countPushSent(clubId: string, since: Date): Promise<number> {
|
private async countPushSent(clubId: string, since: Date): Promise<number> {
|
||||||
const rows = await this.prisma.$queryRawUnsafe<{ count: bigint }[]>(
|
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`,
|
WHERE club_id = $1 AND created_at >= $2`,
|
||||||
clubId,
|
clubId,
|
||||||
since,
|
since,
|
||||||
|
|||||||
@@ -102,8 +102,8 @@ export class ProvisioningService {
|
|||||||
|
|
||||||
// 2. Create the license
|
// 2. Create the license
|
||||||
await tx.$executeRawUnsafe(
|
await tx.$executeRawUnsafe(
|
||||||
`INSERT INTO licenses (club_id, type, max_users, max_clients, is_active, expires_at, created_at, updated_at)
|
`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() + INTERVAL '1 year', NOW(), NOW())`,
|
VALUES ($1, $2, $3, $4, true, NOW(), NOW() + INTERVAL '1 year', NOW(), NOW())`,
|
||||||
club.id,
|
club.id,
|
||||||
licenseType,
|
licenseType,
|
||||||
maxUsers,
|
maxUsers,
|
||||||
@@ -123,24 +123,28 @@ export class ProvisioningService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Create default notification settings
|
// 4. Create default notification settings (one row per type)
|
||||||
await tx.$executeRawUnsafe(
|
const notificationTypes = [
|
||||||
`INSERT INTO notification_settings (club_id, settings_json, created_at, updated_at)
|
'CLIENT_ASSIGNED',
|
||||||
VALUES ($1, $2::jsonb, NOW(), NOW())
|
'SERVICE_BOOKED',
|
||||||
ON CONFLICT (club_id) DO NOTHING`,
|
'SERVICE_PAID',
|
||||||
club.id,
|
'PACKAGE_TOPUP',
|
||||||
JSON.stringify({
|
'BOOKING_CANCELLED',
|
||||||
new_client_assigned: true,
|
'TRAINING_DEDUCTED',
|
||||||
service_booking: true,
|
'TRAININGS_ENDING',
|
||||||
payment_received: true,
|
'CLIENT_RETURNED',
|
||||||
package_topup: true,
|
'CLIENT_BIRTHDAY',
|
||||||
booking_cancelled: true,
|
];
|
||||||
training_deducted: true,
|
|
||||||
trainings_ending: true,
|
for (const notificationType of notificationTypes) {
|
||||||
client_returned: true,
|
await tx.$executeRawUnsafe(
|
||||||
client_birthday: true,
|
`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;
|
return club;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,35 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { UserPlus, ChevronRight } from 'lucide-react';
|
import { UserPlus, ChevronRight } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
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 {
|
interface UnassignedClient {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -22,69 +47,87 @@ interface Trainer {
|
|||||||
capacity: number;
|
capacity: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const placeholderClients: UnassignedClient[] = [
|
function mapFunnelEntryToClient(entry: FunnelEntry): UnassignedClient {
|
||||||
{
|
return {
|
||||||
id: '1',
|
id: entry.client.id,
|
||||||
name: 'Кузнецов Артём',
|
name: `${entry.client.lastName} ${entry.client.firstName}`,
|
||||||
phone: '+7 (999) 111-22-33',
|
phone: entry.client.phone,
|
||||||
registeredAt: '2026-02-17',
|
registeredAt: new Date(entry.createdAt).toISOString().split('T')[0] ?? '',
|
||||||
source: 'Сайт',
|
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: 'Рецепция',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const placeholderTrainers: Trainer[] = [
|
function mapUserToTrainer(user: TrainerUser): Trainer {
|
||||||
{
|
return {
|
||||||
id: 't1',
|
id: user.id,
|
||||||
name: 'Петров А.С.',
|
name: `${user.lastName} ${user.firstName.charAt(0)}.`,
|
||||||
department: 'Тренажёрный зал',
|
department: '',
|
||||||
activeClients: 12,
|
activeClients: 0,
|
||||||
capacity: 20,
|
capacity: 20,
|
||||||
},
|
};
|
||||||
{
|
}
|
||||||
id: 't2',
|
|
||||||
name: 'Козлова Е.В.',
|
|
||||||
department: 'Групповые программы',
|
|
||||||
activeClients: 8,
|
|
||||||
capacity: 15,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 't3',
|
|
||||||
name: 'Смирнова И.П.',
|
|
||||||
department: 'Тренажёрный зал',
|
|
||||||
activeClients: 15,
|
|
||||||
capacity: 20,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function DistributionPage() {
|
export default function DistributionPage() {
|
||||||
const [selectedClient, setSelectedClient] = useState<string | null>(null);
|
const [selectedClient, setSelectedClient] = useState<string | null>(null);
|
||||||
const [selectedTrainer, setSelectedTrainer] = 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 = () => {
|
const handleAssign = () => {
|
||||||
if (selectedClient && selectedTrainer) {
|
if (selectedClient && selectedTrainer) {
|
||||||
// Placeholder: will call API
|
setAssigning(true);
|
||||||
alert(
|
api
|
||||||
`Клиент назначен тренеру (client: ${selectedClient}, trainer: ${selectedTrainer})`,
|
.post('/funnel/assign', {
|
||||||
);
|
clientId: selectedClient,
|
||||||
setSelectedClient(null);
|
trainerId: selectedTrainer,
|
||||||
setSelectedTrainer(null);
|
})
|
||||||
|
.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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
@@ -93,7 +136,7 @@ export default function DistributionPage() {
|
|||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<p className="text-sm text-[#6B7280]">Нераспределённые</p>
|
<p className="text-sm text-[#6B7280]">Нераспределённые</p>
|
||||||
<p className="text-2xl font-bold text-[#FF6B00]">
|
<p className="text-2xl font-bold text-[#FF6B00]">
|
||||||
{placeholderClients.length}
|
{clients.length}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -101,14 +144,14 @@ export default function DistributionPage() {
|
|||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<p className="text-sm text-[#6B7280]">Тренеров доступно</p>
|
<p className="text-sm text-[#6B7280]">Тренеров доступно</p>
|
||||||
<p className="text-2xl font-bold text-gray-900">
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
{placeholderTrainers.length}
|
{trainers.length}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<p className="text-sm text-[#6B7280]">Распределено сегодня</p>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,7 +163,7 @@ export default function DistributionPage() {
|
|||||||
<CardTitle>Нераспределённые клиенты</CardTitle>
|
<CardTitle>Нераспределённые клиенты</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
{placeholderClients.map((client) => (
|
{clients.map((client) => (
|
||||||
<button
|
<button
|
||||||
key={client.id}
|
key={client.id}
|
||||||
onClick={() => setSelectedClient(client.id)}
|
onClick={() => setSelectedClient(client.id)}
|
||||||
@@ -143,7 +186,7 @@ export default function DistributionPage() {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{placeholderClients.length === 0 && (
|
{clients.length === 0 && (
|
||||||
<p className="py-8 text-center text-[#6B7280]">
|
<p className="py-8 text-center text-[#6B7280]">
|
||||||
Все клиенты распределены
|
Все клиенты распределены
|
||||||
</p>
|
</p>
|
||||||
@@ -157,7 +200,7 @@ export default function DistributionPage() {
|
|||||||
<CardTitle>Выберите тренера</CardTitle>
|
<CardTitle>Выберите тренера</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
{placeholderTrainers.map((trainer) => (
|
{trainers.map((trainer) => (
|
||||||
<button
|
<button
|
||||||
key={trainer.id}
|
key={trainer.id}
|
||||||
onClick={() => setSelectedTrainer(trainer.id)}
|
onClick={() => setSelectedTrainer(trainer.id)}
|
||||||
@@ -170,7 +213,7 @@ export default function DistributionPage() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-gray-900">{trainer.name}</p>
|
<p className="font-medium text-gray-900">{trainer.name}</p>
|
||||||
<p className="text-sm text-[#6B7280]">
|
<p className="text-sm text-[#6B7280]">
|
||||||
{trainer.department}
|
{trainer.department || 'Тренер'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
@@ -181,6 +224,11 @@ export default function DistributionPage() {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{trainers.length === 0 && (
|
||||||
|
<p className="py-8 text-center text-[#6B7280]">
|
||||||
|
Тренеры не найдены
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,9 +236,9 @@ export default function DistributionPage() {
|
|||||||
{/* Assign button */}
|
{/* Assign button */}
|
||||||
{selectedClient && selectedTrainer && (
|
{selectedClient && selectedTrainer && (
|
||||||
<div className="flex justify-end">
|
<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" />
|
<UserPlus className="mr-2 h-5 w-5" />
|
||||||
Назначить тренера
|
{assigning ? 'Назначение...' : 'Назначить тренера'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,10 +1,40 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Search, Plus, Clock, Phone, User } from 'lucide-react';
|
import { Search, Plus, Clock, Phone, User } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
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 {
|
interface Appointment {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -33,58 +63,61 @@ const statusVariants: Record<
|
|||||||
cancelled: 'error',
|
cancelled: 'error',
|
||||||
};
|
};
|
||||||
|
|
||||||
const placeholderAppointments: Appointment[] = [
|
function mapTrainingStatus(status: string): Appointment['status'] {
|
||||||
{
|
const statusMap: Record<string, Appointment['status']> = {
|
||||||
id: '1',
|
PLANNED: 'planned',
|
||||||
clientName: 'Иванова Мария',
|
CONFIRMED: 'confirmed',
|
||||||
clientPhone: '+7 (999) 123-45-67',
|
COMPLETED: 'completed',
|
||||||
time: '09:00',
|
CANCELLED: 'cancelled',
|
||||||
trainer: 'Петров А.С.',
|
};
|
||||||
type: 'ВПТ',
|
return statusMap[status] ?? 'planned';
|
||||||
status: 'confirmed',
|
}
|
||||||
},
|
|
||||||
{
|
function formatTime(isoString: string): string {
|
||||||
id: '2',
|
const date = new Date(isoString);
|
||||||
clientName: 'Сидоров Алексей',
|
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
|
||||||
clientPhone: '+7 (999) 234-56-78',
|
}
|
||||||
time: '10:00',
|
|
||||||
trainer: 'Козлова Е.В.',
|
function mapTrainingToAppointment(training: Training): Appointment {
|
||||||
type: 'Персональная',
|
return {
|
||||||
status: 'planned',
|
id: training.id,
|
||||||
},
|
clientName: `${training.client.lastName} ${training.client.firstName}`,
|
||||||
{
|
clientPhone: training.client.phone,
|
||||||
id: '3',
|
time: formatTime(training.scheduledAt),
|
||||||
clientName: 'Петрова Ольга',
|
trainer: `${training.trainer.lastName} ${training.trainer.firstName.charAt(0)}.`,
|
||||||
clientPhone: '+7 (999) 345-67-89',
|
type: training.service?.name ?? 'Тренировка',
|
||||||
time: '11:30',
|
status: mapTrainingStatus(training.status),
|
||||||
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',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function ReceptionPage() {
|
export default function ReceptionPage() {
|
||||||
const [search, setSearch] = useState('');
|
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) =>
|
||||||
a.clientName.toLowerCase().includes(search.toLowerCase()) ||
|
a.clientName.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
a.clientPhone.includes(search),
|
a.clientPhone.includes(search),
|
||||||
@@ -116,7 +149,7 @@ export default function ReceptionPage() {
|
|||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<p className="text-sm text-[#6B7280]">Всего записей</p>
|
<p className="text-sm text-[#6B7280]">Всего записей</p>
|
||||||
<p className="text-2xl font-bold text-gray-900">
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
{placeholderAppointments.length}
|
{appointments.length}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -124,7 +157,7 @@ export default function ReceptionPage() {
|
|||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<p className="text-sm text-[#6B7280]">Подтверждено</p>
|
<p className="text-sm text-[#6B7280]">Подтверждено</p>
|
||||||
<p className="text-2xl font-bold text-[#43A047]">
|
<p className="text-2xl font-bold text-[#43A047]">
|
||||||
{placeholderAppointments.filter((a) => a.status === 'confirmed').length}
|
{appointments.filter((a) => a.status === 'confirmed').length}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -132,7 +165,7 @@ export default function ReceptionPage() {
|
|||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<p className="text-sm text-[#6B7280]">Ожидает</p>
|
<p className="text-sm text-[#6B7280]">Ожидает</p>
|
||||||
<p className="text-2xl font-bold text-[#FF9800]">
|
<p className="text-2xl font-bold text-[#FF9800]">
|
||||||
{placeholderAppointments.filter((a) => a.status === 'planned').length}
|
{appointments.filter((a) => a.status === 'planned').length}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -140,7 +173,7 @@ export default function ReceptionPage() {
|
|||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<p className="text-sm text-[#6B7280]">Отменено</p>
|
<p className="text-sm text-[#6B7280]">Отменено</p>
|
||||||
<p className="text-2xl font-bold text-[#E53935]">
|
<p className="text-2xl font-bold text-[#E53935]">
|
||||||
{placeholderAppointments.filter((a) => a.status === 'cancelled').length}
|
{appointments.filter((a) => a.status === 'cancelled').length}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,35 +1,110 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { FileText } from 'lucide-react';
|
import { FileText } from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
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() {
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* Report generation buttons */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Отчёты</CardTitle>
|
<CardTitle>Отчёты</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{[
|
{defaultReportTypes.map((report) => (
|
||||||
{
|
|
||||||
title: 'Текущее состояние клиентов',
|
|
||||||
description: 'Активные, спящие, ушедшие клиенты',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Статистика по тренерам',
|
|
||||||
description: 'Конверсия, нагрузка, рейтинг',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Воронка продаж',
|
|
||||||
description: 'Конверсия по этапам воронки',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Финансовый отчёт',
|
|
||||||
description: 'Продажи, реализация, долги',
|
|
||||||
},
|
|
||||||
].map((report) => (
|
|
||||||
<button
|
<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"
|
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">
|
<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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,30 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
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 {
|
interface TrainerRow {
|
||||||
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
department: string;
|
department: string;
|
||||||
activeClients: number;
|
activeClients: number;
|
||||||
@@ -10,42 +33,61 @@ interface TrainerRow {
|
|||||||
status: 'online' | 'offline';
|
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() {
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -53,61 +95,67 @@ export default function TrainersPage() {
|
|||||||
<CardTitle>Статистика по тренерам</CardTitle>
|
<CardTitle>Статистика по тренерам</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="overflow-x-auto">
|
{trainers.length === 0 ? (
|
||||||
<table className="w-full text-sm">
|
<p className="py-8 text-center text-[#6B7280]">
|
||||||
<thead>
|
Тренеры не найдены
|
||||||
<tr className="border-b border-[#E5E7EB]">
|
</p>
|
||||||
<th className="pb-3 text-left font-medium text-[#6B7280]">
|
) : (
|
||||||
Тренер
|
<div className="overflow-x-auto">
|
||||||
</th>
|
<table className="w-full text-sm">
|
||||||
<th className="pb-3 text-left font-medium text-[#6B7280]">
|
<thead>
|
||||||
Департамент
|
<tr className="border-b border-[#E5E7EB]">
|
||||||
</th>
|
<th className="pb-3 text-left font-medium text-[#6B7280]">
|
||||||
<th className="pb-3 text-left font-medium text-[#6B7280]">
|
Тренер
|
||||||
Клиенты
|
</th>
|
||||||
</th>
|
<th className="pb-3 text-left font-medium text-[#6B7280]">
|
||||||
<th className="pb-3 text-left font-medium text-[#6B7280]">
|
Департамент
|
||||||
Конверсия
|
</th>
|
||||||
</th>
|
<th className="pb-3 text-left font-medium text-[#6B7280]">
|
||||||
<th className="pb-3 text-left font-medium text-[#6B7280]">
|
Клиенты
|
||||||
Рейтинг
|
</th>
|
||||||
</th>
|
<th className="pb-3 text-left font-medium text-[#6B7280]">
|
||||||
<th className="pb-3 text-left font-medium text-[#6B7280]">
|
Конверсия
|
||||||
Статус
|
</th>
|
||||||
</th>
|
<th className="pb-3 text-left font-medium text-[#6B7280]">
|
||||||
</tr>
|
Рейтинг
|
||||||
</thead>
|
</th>
|
||||||
<tbody>
|
<th className="pb-3 text-left font-medium text-[#6B7280]">
|
||||||
{placeholderTrainers.map((trainer) => (
|
Статус
|
||||||
<tr
|
</th>
|
||||||
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>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{trainers.map((trainer) => (
|
||||||
</div>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Plus, Search, Package, Tag } from 'lucide-react';
|
import { Plus, Search, Package, Tag } from 'lucide-react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
type Service = {
|
type Service = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -22,60 +23,45 @@ type ServicePackage = {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockServices: Service[] = [
|
type ServicesResponse = {
|
||||||
{ id: '1', name: 'Персональная тренировка', category: 'Тренажёрный зал', price: 3000, duration: 60, isActive: true },
|
data: Service[];
|
||||||
{ id: '2', name: 'Сплит-тренировка (2 чел.)', category: 'Тренажёрный зал', price: 2000, duration: 60, isActive: true },
|
hasMore: boolean;
|
||||||
{ id: '3', name: 'Групповое занятие', category: 'Групповые', price: 800, duration: 55, isActive: true },
|
nextCursor: string | null;
|
||||||
{ 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 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockPackages: ServicePackage[] = [
|
type PackagesResponse = {
|
||||||
{
|
data: ServicePackage[];
|
||||||
id: '1',
|
hasMore: boolean;
|
||||||
name: 'Старт (8 тренировок)',
|
nextCursor: string | null;
|
||||||
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,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function CatalogPage() {
|
export default function CatalogPage() {
|
||||||
const [tab, setTab] = useState<'services' | 'packages'>('services');
|
const [tab, setTab] = useState<'services' | 'packages'>('services');
|
||||||
const [search, setSearch] = useState('');
|
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.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
s.category.toLowerCase().includes(search.toLowerCase())
|
s.category.toLowerCase().includes(search.toLowerCase())
|
||||||
);
|
);
|
||||||
@@ -100,7 +86,7 @@ export default function CatalogPage() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Tag className="h-4 w-4" />
|
<Tag className="h-4 w-4" />
|
||||||
Услуги ({mockServices.length})
|
Услуги ({services.length})
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setTab('packages')}
|
onClick={() => setTab('packages')}
|
||||||
@@ -111,7 +97,7 @@ export default function CatalogPage() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Package className="h-4 w-4" />
|
<Package className="h-4 w-4" />
|
||||||
Пакеты ({mockPackages.length})
|
Пакеты ({packages.length})
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -166,7 +152,7 @@ export default function CatalogPage() {
|
|||||||
|
|
||||||
{tab === 'packages' && (
|
{tab === 'packages' && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<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 key={pkg.id} className="bg-card rounded-xl p-6 shadow-sm border border-border">
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<h3 className="text-lg font-semibold text-gray-900">{pkg.name}</h3>
|
<h3 className="text-lg font-semibold text-gray-900">{pkg.name}</h3>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Users,
|
Users,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
@@ -8,57 +9,22 @@ import {
|
|||||||
DollarSign,
|
DollarSign,
|
||||||
Activity,
|
Activity,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
const stats = [
|
type StatsSummary = {
|
||||||
{
|
employees?: number;
|
||||||
label: 'Сотрудники',
|
employeesChange?: string;
|
||||||
value: '24',
|
activeClients?: number;
|
||||||
change: '+2 за месяц',
|
activeClientsChange?: string;
|
||||||
icon: Users,
|
revenue?: number;
|
||||||
color: 'text-blue-600',
|
revenueChange?: string;
|
||||||
bg: 'bg-blue-50',
|
trainings?: number;
|
||||||
},
|
trainingsChange?: string;
|
||||||
{
|
funnelConversion?: number;
|
||||||
label: 'Активные клиенты',
|
funnelConversionChange?: string;
|
||||||
value: '1 248',
|
roomOccupancy?: number;
|
||||||
change: '+86 за месяц',
|
roomOccupancyNote?: string;
|
||||||
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',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const recentActivity = [
|
const recentActivity = [
|
||||||
{ time: '14:32', text: 'Иванов А. провёл тренировку с Петровой М.' },
|
{ time: '14:32', text: 'Иванов А. провёл тренировку с Петровой М.' },
|
||||||
@@ -68,7 +34,81 @@ const recentActivity = [
|
|||||||
{ time: '12:55', text: 'Смена графика: Фёдоров И. — выходной 20.02' },
|
{ 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() {
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Дашборд клуба</h1>
|
<h1 className="text-2xl font-bold text-gray-900 mb-6">Дашборд клуба</h1>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { Plus, Users, UserCheck, Edit, Trash2 } from 'lucide-react';
|
import { Plus, Users, UserCheck, Edit, Trash2 } from 'lucide-react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
type Department = {
|
type Department = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -11,50 +13,31 @@ type Department = {
|
|||||||
description: string;
|
description: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockDepartments: Department[] = [
|
type DepartmentsResponse = {
|
||||||
{
|
data: Department[];
|
||||||
id: '1',
|
hasMore: boolean;
|
||||||
name: 'Тренажёрный зал',
|
nextCursor: string | null;
|
||||||
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 лет, спортивная гимнастика',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function DepartmentsPage() {
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
@@ -66,7 +49,7 @@ export default function DepartmentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{mockDepartments.map((dept) => (
|
{departments.map((dept) => (
|
||||||
<div
|
<div
|
||||||
key={dept.id}
|
key={dept.id}
|
||||||
className="bg-card rounded-xl p-6 shadow-sm border border-border"
|
className="bg-card rounded-xl p-6 shadow-sm border border-border"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Plug,
|
Plug,
|
||||||
Phone,
|
Phone,
|
||||||
@@ -13,43 +13,36 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
type IntegrationStatus = 'connected' | 'disconnected' | 'error';
|
type IntegrationStatus = 'connected' | 'disconnected' | 'error';
|
||||||
|
|
||||||
type Integration = {
|
type IntegrationConfig = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
status: IntegrationStatus;
|
status: IntegrationStatus;
|
||||||
icon: typeof Plug;
|
|
||||||
lastSync?: string;
|
lastSync?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const integrations: Integration[] = [
|
type WebhookEntry = {
|
||||||
{
|
id: string;
|
||||||
id: '1c',
|
url: string;
|
||||||
name: '1C:Фитнес клуб',
|
events: string[];
|
||||||
description: 'Двусторонняя синхронизация клиентов, членств, продаж и графика работы',
|
active: boolean;
|
||||||
status: 'connected',
|
};
|
||||||
icon: Plug,
|
|
||||||
lastSync: '18.02.2026, 14:30',
|
type WebhooksResponse = {
|
||||||
},
|
data: WebhookEntry[];
|
||||||
{
|
hasMore: boolean;
|
||||||
id: 'sip',
|
nextCursor: string | null;
|
||||||
name: 'SIP-телефония',
|
};
|
||||||
description: 'Встроенный SIP-клиент для исходящих звонков с CallerID клуба',
|
|
||||||
status: 'connected',
|
const integrationIcons: Record<string, typeof Plug> = {
|
||||||
icon: Phone,
|
'1c': Plug,
|
||||||
lastSync: undefined,
|
sip: Phone,
|
||||||
},
|
webhooks: Webhook,
|
||||||
{
|
};
|
||||||
id: 'webhooks',
|
|
||||||
name: 'Webhooks',
|
|
||||||
description: '12 типов исходящих событий для внешних интеграций (HMAC-SHA256)',
|
|
||||||
status: 'disconnected',
|
|
||||||
icon: Webhook,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockApiKeys = [
|
const mockApiKeys = [
|
||||||
{
|
{
|
||||||
@@ -76,8 +69,76 @@ const statusConfig: Record<IntegrationStatus, { label: string; className: string
|
|||||||
error: { label: 'Ошибка', className: 'text-error', icon: XCircle },
|
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() {
|
export default function IntegrationsPage() {
|
||||||
const [showKey, setShowKey] = useState<string | null>(null);
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -85,7 +146,7 @@ export default function IntegrationsPage() {
|
|||||||
|
|
||||||
<div className="space-y-6 mb-10">
|
<div className="space-y-6 mb-10">
|
||||||
{integrations.map((integration) => {
|
{integrations.map((integration) => {
|
||||||
const Icon = integration.icon;
|
const Icon = integrationIcons[integration.id] ?? Plug;
|
||||||
const status = statusConfig[integration.status];
|
const status = statusConfig[integration.status];
|
||||||
const StatusIcon = status.icon;
|
const StatusIcon = status.icon;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
KeyRound,
|
KeyRound,
|
||||||
Calendar,
|
Calendar,
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
Package,
|
Package,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
type Module = {
|
type Module = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -23,16 +25,34 @@ type UsageItem = {
|
|||||||
unit: string;
|
unit: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const licenseInfo = {
|
type LicenseInfo = {
|
||||||
type: 'Стандартная',
|
type: string;
|
||||||
clubName: 'FitGym Premium',
|
clubName: string;
|
||||||
activatedAt: '01.12.2025',
|
activatedAt: string;
|
||||||
expiresAt: '31.12.2026',
|
expiresAt: string;
|
||||||
daysLeft: 316,
|
daysLeft: number;
|
||||||
status: 'active' as const,
|
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: 'core', name: 'Ядро CRM', enabled: true, description: 'Основные функции CRM' },
|
||||||
{ id: 'sales', name: 'Продажи и реализация', enabled: true, description: 'Управление продажами и долгами' },
|
{ id: 'sales', name: 'Продажи и реализация', enabled: true, description: 'Управление продажами и долгами' },
|
||||||
{ id: 'coordinator', 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С:Фитнес клуб' },
|
{ id: '1c_sync', name: 'Интеграция 1С', enabled: true, description: 'Синхронизация с 1С:Фитнес клуб' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const usage: UsageItem[] = [
|
const defaultUsage: UsageItem[] = [
|
||||||
{ label: 'Активные пользователи', current: 24, limit: 50, unit: 'чел.' },
|
{ label: 'Активные пользователи', current: 0, limit: 0, unit: 'чел.' },
|
||||||
{ label: 'Клиентская база', current: 1248, limit: 5000, unit: 'записей' },
|
{ 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: 'Webhook-доставка', current: 0, limit: 0, unit: 'событий/мес' },
|
||||||
{ label: 'API-запросы', current: 12450, limit: 50000, unit: 'запросов/мес' },
|
{ label: 'API-запросы', current: 0, limit: 0, unit: 'запросов/мес' },
|
||||||
{ label: 'Push-уведомления', current: 1820, limit: 5000, 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() {
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Лицензия</h1>
|
<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">
|
<h2 className="text-lg font-semibold text-gray-900">
|
||||||
Лицензия: {licenseInfo.type}
|
Лицензия: {licenseInfo.type}
|
||||||
</h2>
|
</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" />
|
<CheckCircle className="h-3 w-3" />
|
||||||
Активна
|
{statusBadge.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600">{licenseInfo.clubName}</p>
|
<p className="text-sm text-gray-600">{licenseInfo.clubName}</p>
|
||||||
@@ -98,10 +161,10 @@ export default function LicensePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<AlertTriangle className="h-5 w-5 text-success" />
|
<AlertTriangle className={`h-5 w-5 ${daysColor}`} />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted">Осталось дней</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { Plus, Edit, Trash2, MapPin, Users, Clock } from 'lucide-react';
|
import { Plus, Edit, Trash2, MapPin, Users, Clock } from 'lucide-react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
type Room = {
|
type Room = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -13,68 +15,11 @@ type Room = {
|
|||||||
workingHours: string;
|
workingHours: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockRooms: Room[] = [
|
type RoomsResponse = {
|
||||||
{
|
data: Room[];
|
||||||
id: '1',
|
hasMore: boolean;
|
||||||
name: 'Основной тренажёрный зал',
|
nextCursor: string | null;
|
||||||
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',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const statusMap: Record<string, { label: string; className: string }> = {
|
const statusMap: Record<string, { label: string; className: string }> = {
|
||||||
available: { label: 'Свободен', className: 'bg-green-100 text-green-800' },
|
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() {
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
@@ -94,7 +57,7 @@ export default function RoomsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<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' };
|
const status = statusMap[room.status] ?? { label: 'Неизвестно', className: 'bg-gray-100 text-gray-800' };
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Plus, Search, MoreHorizontal, UserCheck, UserX } from 'lucide-react';
|
import { Plus, Search, MoreHorizontal, UserCheck, UserX } from 'lucide-react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
type Employee = {
|
type Employee = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -13,15 +14,11 @@ type Employee = {
|
|||||||
hiredAt: string;
|
hiredAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockEmployees: Employee[] = [
|
type UsersResponse = {
|
||||||
{ id: '1', name: 'Иванов Алексей', role: 'Тренер', department: 'Тренажёрный зал', phone: '+7 (900) 123-45-67', status: 'active', hiredAt: '2024-03-15' },
|
data: Employee[];
|
||||||
{ id: '2', name: 'Петрова Мария', role: 'Тренер', department: 'Групповые программы', phone: '+7 (900) 234-56-78', status: 'active', hiredAt: '2024-01-10' },
|
hasMore: boolean;
|
||||||
{ id: '3', name: 'Козлов Дмитрий', role: 'Координатор', department: 'Тренажёрный зал', phone: '+7 (900) 345-67-89', status: 'active', hiredAt: '2023-09-01' },
|
nextCursor: string | null;
|
||||||
{ 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' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const roleColors: Record<string, string> = {
|
const roleColors: Record<string, string> = {
|
||||||
'Тренер': 'bg-blue-100 text-blue-800',
|
'Тренер': 'bg-blue-100 text-blue-800',
|
||||||
@@ -31,9 +28,26 @@ const roleColors: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function StaffPage() {
|
export default function StaffPage() {
|
||||||
|
const [employees, setEmployees] = useState<Employee[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
const [search, setSearch] = useState('');
|
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) =>
|
||||||
e.name.toLowerCase().includes(search.toLowerCase()) ||
|
e.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
e.role.toLowerCase().includes(search.toLowerCase()) ||
|
e.role.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
@@ -114,7 +128,7 @@ export default function StaffPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 py-3 border-t border-border text-sm text-muted">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
91
apps/web-club-admin/src/lib/api.ts
Normal file
91
apps/web-club-admin/src/lib/api.ts
Normal 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' });
|
||||||
|
},
|
||||||
|
};
|
||||||
82
apps/web-club-admin/src/lib/auth.ts
Normal file
82
apps/web-club-admin/src/lib/auth.ts
Normal 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();
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -9,6 +9,35 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/cn';
|
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 {
|
interface Club {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -22,118 +51,73 @@ interface Club {
|
|||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clubs: Club[] = [
|
const licenseTypeMap: Record<string, Club['license']> = {
|
||||||
{
|
STARTER: 'Стартовая',
|
||||||
id: '1',
|
STANDARD: 'Стандартная',
|
||||||
name: 'FitGym Москва',
|
PREMIUM: 'Премиум',
|
||||||
city: 'Москва',
|
};
|
||||||
license: 'Премиум',
|
|
||||||
users: 34,
|
function mapApiClubToClub(apiClub: ApiClub): Club {
|
||||||
clients: 2450,
|
let status: Club['status'] = 'active';
|
||||||
status: 'active',
|
let expiresAt = '—';
|
||||||
createdAt: '15.03.2025',
|
let licenseLabel: Club['license'] = 'Стартовая';
|
||||||
expiresAt: '15.03.2027',
|
|
||||||
},
|
if (apiClub.license) {
|
||||||
{
|
licenseLabel = licenseTypeMap[apiClub.license.type] || 'Стартовая';
|
||||||
id: '2',
|
const endDate = new Date(apiClub.license.endDate);
|
||||||
name: 'SportMax Москва',
|
expiresAt = endDate.toLocaleDateString('ru-RU', {
|
||||||
city: 'Москва',
|
day: '2-digit',
|
||||||
license: 'Стандартная',
|
month: '2-digit',
|
||||||
users: 18,
|
year: 'numeric',
|
||||||
clients: 1120,
|
});
|
||||||
status: 'active',
|
|
||||||
createdAt: '01.06.2025',
|
const now = new Date();
|
||||||
expiresAt: '01.06.2026',
|
const daysUntilExpiry = Math.ceil(
|
||||||
},
|
(endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
|
||||||
{
|
);
|
||||||
id: '3',
|
|
||||||
name: 'FitLife Казань',
|
if (!apiClub.isActive || !apiClub.license.isActive) {
|
||||||
city: 'Казань',
|
status = 'blocked';
|
||||||
license: 'Стандартная',
|
} else if (apiClub.license.gracePeriodEnd) {
|
||||||
users: 12,
|
status = 'grace';
|
||||||
clients: 680,
|
} else if (daysUntilExpiry <= 0) {
|
||||||
status: 'grace',
|
status = 'blocked';
|
||||||
createdAt: '10.08.2025',
|
} else if (daysUntilExpiry <= 30 && daysUntilExpiry > 0) {
|
||||||
expiresAt: '16.02.2026',
|
// Could be trial or active depending on license age
|
||||||
},
|
const startDate = new Date(apiClub.license.startDate);
|
||||||
{
|
const licenseDuration = Math.ceil(
|
||||||
id: '4',
|
(endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24),
|
||||||
name: 'PowerGym Уфа',
|
);
|
||||||
city: 'Уфа',
|
if (licenseDuration <= 30) {
|
||||||
license: 'Стандартная',
|
status = 'trial';
|
||||||
users: 12,
|
} else {
|
||||||
clients: 540,
|
status = 'active';
|
||||||
status: 'active',
|
}
|
||||||
createdAt: '14.02.2026',
|
} else {
|
||||||
expiresAt: '14.02.2027',
|
status = 'active';
|
||||||
},
|
}
|
||||||
{
|
} else {
|
||||||
id: '5',
|
status = apiClub.isActive ? 'trial' : 'blocked';
|
||||||
name: 'GymPro Самара',
|
}
|
||||||
city: 'Самара',
|
|
||||||
license: 'Стартовая',
|
const createdAt = new Date(apiClub.createdAt).toLocaleDateString('ru-RU', {
|
||||||
users: 6,
|
day: '2-digit',
|
||||||
clients: 320,
|
month: '2-digit',
|
||||||
status: 'active',
|
year: 'numeric',
|
||||||
createdAt: '20.09.2025',
|
});
|
||||||
expiresAt: '23.02.2026',
|
|
||||||
},
|
return {
|
||||||
{
|
id: apiClub.id,
|
||||||
id: '6',
|
name: apiClub.name,
|
||||||
name: 'BodyFit Пермь',
|
city: apiClub.slug,
|
||||||
city: 'Пермь',
|
license: licenseLabel,
|
||||||
license: 'Премиум',
|
users: apiClub._count.users,
|
||||||
users: 28,
|
clients: apiClub._count.clients,
|
||||||
clients: 1850,
|
status,
|
||||||
status: 'active',
|
createdAt,
|
||||||
createdAt: '10.02.2026',
|
expiresAt,
|
||||||
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 statusLabels: Record<Club['status'], string> = {
|
const statusLabels: Record<Club['status'], string> = {
|
||||||
active: 'Активен',
|
active: 'Активен',
|
||||||
@@ -156,18 +140,79 @@ const licenseColors: Record<Club['license'], string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function ClubsPage() {
|
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 [search, setSearch] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
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 filtered = clubs.filter((club) => {
|
||||||
const matchesSearch =
|
// API already handles search, but we still filter status client-side
|
||||||
club.name.toLowerCase().includes(search.toLowerCase()) ||
|
// since API only has isActive filter, not grace/trial distinction
|
||||||
club.city.toLowerCase().includes(search.toLowerCase());
|
|
||||||
const matchesStatus =
|
const matchesStatus =
|
||||||
statusFilter === 'all' || club.status === statusFilter;
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
@@ -176,7 +221,7 @@ export default function ClubsPage() {
|
|||||||
Управление клубами
|
Управление клубами
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted mt-1">
|
<p className="text-sm text-muted mt-1">
|
||||||
{clubs.length} клубов на платформе
|
{totalCount} клубов на платформе
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<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">
|
<div className="px-4 py-3 border-t border-border flex items-center justify-between">
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted">
|
||||||
Показано {filtered.length} из {clubs.length} клубов
|
Показано {filtered.length} из {totalCount} клубов
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
@@ -8,6 +9,39 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
} from 'lucide-react';
|
} 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 = [
|
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 = {
|
const alertColors = {
|
||||||
critical: 'bg-red-100 text-error border-red-200',
|
critical: 'bg-red-100 text-error border-red-200',
|
||||||
warning: 'bg-orange-50 text-orange-700 border-orange-200',
|
warning: 'bg-orange-50 text-orange-700 border-orange-200',
|
||||||
@@ -101,6 +129,36 @@ const alertColors = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function DashboardPage() {
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">
|
<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 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
Недавно подключённые клубы
|
Недавно подключённые клубы
|
||||||
</h2>
|
</h2>
|
||||||
<div className="overflow-x-auto">
|
{clubsLoading ? (
|
||||||
<table className="w-full text-sm">
|
<div className="flex items-center justify-center h-32">
|
||||||
<thead>
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
|
||||||
<tr className="border-b border-border">
|
</div>
|
||||||
<th className="text-left py-2 text-muted font-medium">
|
) : recentClubs.length === 0 ? (
|
||||||
Клуб
|
<p className="text-sm text-muted text-center py-8">
|
||||||
</th>
|
Нет данных о клубах
|
||||||
<th className="text-left py-2 text-muted font-medium">
|
</p>
|
||||||
Лицензия
|
) : (
|
||||||
</th>
|
<div className="overflow-x-auto">
|
||||||
<th className="text-right py-2 text-muted font-medium">
|
<table className="w-full text-sm">
|
||||||
Пользователи
|
<thead>
|
||||||
</th>
|
<tr className="border-b border-border">
|
||||||
<th className="text-right py-2 text-muted font-medium">
|
<th className="text-left py-2 text-muted font-medium">
|
||||||
Дата
|
Клуб
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
<th className="text-left py-2 text-muted font-medium">
|
||||||
</thead>
|
Лицензия
|
||||||
<tbody>
|
</th>
|
||||||
{recentClubs.map((club) => (
|
<th className="text-right py-2 text-muted font-medium">
|
||||||
<tr key={club.name} className="border-b border-border/50">
|
Пользователи
|
||||||
<td className="py-3 font-medium text-gray-900">
|
</th>
|
||||||
{club.name}
|
<th className="text-right py-2 text-muted font-medium">
|
||||||
</td>
|
Дата
|
||||||
<td className="py-3 text-muted">{club.license}</td>
|
</th>
|
||||||
<td className="py-3 text-right text-gray-700">
|
|
||||||
{club.users}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 text-right text-muted">{club.date}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{recentClubs.map((club) => (
|
||||||
</div>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Check,
|
Check,
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
const licenseTypes = [
|
const licenseTypes = [
|
||||||
{
|
{
|
||||||
@@ -30,7 +31,7 @@ const licenseTypes = [
|
|||||||
'Интеграция 1С': false,
|
'Интеграция 1С': false,
|
||||||
},
|
},
|
||||||
limits: { users: 5, clients: 500 },
|
limits: { users: 5, clients: 500 },
|
||||||
activeCount: 12,
|
activeCount: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'standard',
|
id: 'standard',
|
||||||
@@ -52,7 +53,7 @@ const licenseTypes = [
|
|||||||
'Интеграция 1С': true,
|
'Интеграция 1С': true,
|
||||||
},
|
},
|
||||||
limits: { users: 10, clients: 1000 },
|
limits: { users: 10, clients: 1000 },
|
||||||
activeCount: 22,
|
activeCount: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'premium',
|
id: 'premium',
|
||||||
@@ -74,10 +75,29 @@ const licenseTypes = [
|
|||||||
'Интеграция 1С': true,
|
'Интеграция 1С': true,
|
||||||
},
|
},
|
||||||
limits: { users: 'Без лимита', clients: 'Без лимита' },
|
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 {
|
interface License {
|
||||||
id: string;
|
id: string;
|
||||||
club: string;
|
club: string;
|
||||||
@@ -88,16 +108,58 @@ interface License {
|
|||||||
autoRenew: boolean;
|
autoRenew: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const licenses: License[] = [
|
const licenseTypeMap: Record<string, License['type']> = {
|
||||||
{ id: 'L-001', club: 'FitGym Москва', type: 'Премиум', status: 'active', startDate: '15.03.2025', endDate: '15.03.2027', autoRenew: true },
|
STARTER: 'Стартовая',
|
||||||
{ id: 'L-002', club: 'SportMax Москва', type: 'Стандартная', status: 'active', startDate: '01.06.2025', endDate: '01.06.2026', autoRenew: true },
|
STANDARD: 'Стандартная',
|
||||||
{ id: 'L-003', club: 'FitLife Казань', type: 'Стандартная', status: 'expired', startDate: '10.08.2025', endDate: '16.02.2026', autoRenew: false },
|
PREMIUM: 'Премиум',
|
||||||
{ 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 },
|
const licenseTypeApiMap: Record<string, string> = {
|
||||||
{ id: 'L-007', club: 'TopForm Екатеринбург', type: 'Премиум', status: 'active', startDate: '22.04.2025', endDate: '22.04.2027', autoRenew: true },
|
STARTER: 'starter',
|
||||||
{ id: 'L-008', club: 'IronFit Новосибирск', type: 'Стандартная', status: 'expired', startDate: '05.01.2025', endDate: '01.01.2026', autoRenew: false },
|
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> = {
|
const statusLabels: Record<License['status'], string> = {
|
||||||
active: 'Активна',
|
active: 'Активна',
|
||||||
@@ -121,6 +183,75 @@ const typeColors: Record<License['type'], string> = {
|
|||||||
|
|
||||||
export default function LicensesPage() {
|
export default function LicensesPage() {
|
||||||
const [tab, setTab] = useState<'types' | 'list'>('types');
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -166,7 +297,7 @@ export default function LicensesPage() {
|
|||||||
|
|
||||||
{tab === 'types' && (
|
{tab === 'types' && (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
{licenseTypes.map((type) => (
|
{enrichedLicenseTypes.map((type) => (
|
||||||
<div
|
<div
|
||||||
key={type.id}
|
key={type.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
91
apps/web-platform-admin/src/lib/api.ts
Normal file
91
apps/web-platform-admin/src/lib/api.ts
Normal 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' });
|
||||||
|
},
|
||||||
|
};
|
||||||
82
apps/web-platform-admin/src/lib/auth.ts
Normal file
82
apps/web-platform-admin/src/lib/auth.ts
Normal 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();
|
||||||
|
}
|
||||||
@@ -9,6 +9,9 @@
|
|||||||
"lint": "eslint src/ --ext .ts",
|
"lint": "eslint src/ --ext .ts",
|
||||||
"dev": "tsc --build --watch"
|
"dev": "tsc --build --watch"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fitcrm/shared-types": "workspace:*"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.5.0"
|
"typescript": "^5.5.0"
|
||||||
}
|
}
|
||||||
|
|||||||
547
packages/api-client/src/client.ts
Normal file
547
packages/api-client/src/client.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
108
packages/api-client/src/http.ts
Normal file
108
packages/api-client/src/http.ts
Normal 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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
// @fitcrm/api-client
|
export { FitCrmApiClient } from './client';
|
||||||
// Auto-generated API client from OpenAPI specification
|
export { HttpClient, ApiError } from './http';
|
||||||
// This package will be populated when the API OpenAPI spec is available.
|
export type { HttpClientConfig } from './http';
|
||||||
|
export type * from './types';
|
||||||
export {};
|
|
||||||
|
|||||||
482
packages/api-client/src/types.ts
Normal file
482
packages/api-client/src/types.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -3,8 +3,14 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src",
|
"rootDir": "./src",
|
||||||
"composite": true
|
"composite": true,
|
||||||
|
"lib": ["ES2022", "DOM"],
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false
|
||||||
},
|
},
|
||||||
|
"references": [
|
||||||
|
{ "path": "../shared-types" }
|
||||||
|
],
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
4
pnpm-lock.yaml
generated
4
pnpm-lock.yaml
generated
@@ -315,6 +315,10 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
packages/api-client:
|
packages/api-client:
|
||||||
|
dependencies:
|
||||||
|
'@fitcrm/shared-types':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../shared-types
|
||||||
devDependencies:
|
devDependencies:
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.5.0
|
specifier: ^5.5.0
|
||||||
|
|||||||
Reference in New Issue
Block a user