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[]> {
|
||||
const periodStart = this.getCurrentPeriodStart();
|
||||
@@ -115,22 +117,11 @@ export class MeteringService {
|
||||
usage = await this.aggregateUsage(clubId);
|
||||
}
|
||||
|
||||
// Get license limits
|
||||
// Get base limits from license (max_users, max_clients)
|
||||
const licenses = await this.prisma.$queryRawUnsafe<
|
||||
{
|
||||
maxUsers: number;
|
||||
maxClients: number;
|
||||
maxCallStorageGb: number;
|
||||
maxWebhooks: number;
|
||||
maxPush: number;
|
||||
maxApiRequests: number;
|
||||
}[]
|
||||
{ maxUsers: number; maxClients: number }[]
|
||||
>(
|
||||
`SELECT max_users AS "maxUsers", max_clients AS "maxClients",
|
||||
COALESCE(max_call_storage_gb, 0) AS "maxCallStorageGb",
|
||||
COALESCE(max_webhooks, 10000) AS "maxWebhooks",
|
||||
COALESCE(max_push, 5000) AS "maxPush",
|
||||
COALESCE(max_api_requests, 50000) AS "maxApiRequests"
|
||||
`SELECT max_users AS "maxUsers", max_clients AS "maxClients"
|
||||
FROM licenses
|
||||
WHERE club_id = $1 AND is_active = true
|
||||
LIMIT 1`,
|
||||
@@ -141,44 +132,69 @@ export class MeteringService {
|
||||
return [];
|
||||
}
|
||||
|
||||
const limits = licenses[0];
|
||||
const license = licenses[0];
|
||||
|
||||
// Get module-specific limits from club_modules.limits_json
|
||||
const moduleLimits = await this.prisma.$queryRawUnsafe<
|
||||
{ moduleId: string; limitsJson: Record<string, number> | null }[]
|
||||
>(
|
||||
`SELECT module_id AS "moduleId", limits_json AS "limitsJson"
|
||||
FROM club_modules
|
||||
WHERE club_id = $1 AND enabled = true
|
||||
AND module_id IN ('webhooks', 'push', 'sip_recording')`,
|
||||
clubId,
|
||||
);
|
||||
|
||||
const moduleLimitsMap = new Map(
|
||||
moduleLimits.map((m) => [m.moduleId, m.limitsJson ?? {}]),
|
||||
);
|
||||
|
||||
const webhookLimits = moduleLimitsMap.get('webhooks') ?? {};
|
||||
const pushLimits = moduleLimitsMap.get('push') ?? {};
|
||||
const sipRecordingLimits = moduleLimitsMap.get('sip_recording') ?? {};
|
||||
|
||||
return [
|
||||
{
|
||||
resource: 'active_users',
|
||||
current: usage.activeUsers,
|
||||
limit: limits.maxUsers,
|
||||
exceeded: usage.activeUsers > limits.maxUsers,
|
||||
limit: license.maxUsers,
|
||||
exceeded: usage.activeUsers > license.maxUsers,
|
||||
},
|
||||
{
|
||||
resource: 'clients',
|
||||
current: usage.clients,
|
||||
limit: limits.maxClients,
|
||||
exceeded: usage.clients > limits.maxClients,
|
||||
limit: license.maxClients,
|
||||
exceeded: usage.clients > license.maxClients,
|
||||
},
|
||||
{
|
||||
resource: 'call_storage_gb',
|
||||
current: usage.callStorageGb,
|
||||
limit: limits.maxCallStorageGb,
|
||||
exceeded: usage.callStorageGb > limits.maxCallStorageGb,
|
||||
limit: (sipRecordingLimits as Record<string, number>).max_storage_gb ?? 0,
|
||||
exceeded:
|
||||
usage.callStorageGb >
|
||||
((sipRecordingLimits as Record<string, number>).max_storage_gb ?? 0),
|
||||
},
|
||||
{
|
||||
resource: 'webhooks_sent',
|
||||
current: usage.webhooksSent,
|
||||
limit: limits.maxWebhooks,
|
||||
exceeded: usage.webhooksSent > limits.maxWebhooks,
|
||||
limit: (webhookLimits as Record<string, number>).max_events ?? 10000,
|
||||
exceeded:
|
||||
usage.webhooksSent >
|
||||
((webhookLimits as Record<string, number>).max_events ?? 10000),
|
||||
},
|
||||
{
|
||||
resource: 'push_sent',
|
||||
current: usage.pushSent,
|
||||
limit: limits.maxPush,
|
||||
exceeded: usage.pushSent > limits.maxPush,
|
||||
limit: (pushLimits as Record<string, number>).max_messages ?? 5000,
|
||||
exceeded:
|
||||
usage.pushSent >
|
||||
((pushLimits as Record<string, number>).max_messages ?? 5000),
|
||||
},
|
||||
{
|
||||
resource: 'api_requests',
|
||||
current: usage.apiRequests,
|
||||
limit: limits.maxApiRequests,
|
||||
exceeded: usage.apiRequests > limits.maxApiRequests,
|
||||
limit: 50000,
|
||||
exceeded: usage.apiRequests > 50000,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -252,15 +268,12 @@ export class MeteringService {
|
||||
|
||||
/**
|
||||
* Sum call recording storage in GB for a club.
|
||||
* Note: Call recording is not implemented in MVP (sip_calls stores metadata only).
|
||||
* Returns 0 until a call_recordings table is added.
|
||||
*/
|
||||
private async sumCallStorage(clubId: string): Promise<number> {
|
||||
const rows = await this.prisma.$queryRawUnsafe<{ total: number }[]>(
|
||||
`SELECT COALESCE(SUM(file_size_bytes), 0) / (1024.0 * 1024.0 * 1024.0) AS total
|
||||
FROM call_recordings
|
||||
WHERE club_id = $1`,
|
||||
clubId,
|
||||
);
|
||||
return Math.round((rows[0]?.total ?? 0) * 100) / 100;
|
||||
private async sumCallStorage(_clubId: string): Promise<number> {
|
||||
// TODO: Query actual call recording storage when sip_recording module is implemented
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -271,7 +284,7 @@ export class MeteringService {
|
||||
since: Date,
|
||||
): Promise<number> {
|
||||
const rows = await this.prisma.$queryRawUnsafe<{ count: bigint }[]>(
|
||||
`SELECT COUNT(*)::bigint AS count FROM webhook_deliveries
|
||||
`SELECT COUNT(*)::bigint AS count FROM webhook_logs
|
||||
WHERE club_id = $1 AND created_at >= $2`,
|
||||
clubId,
|
||||
since,
|
||||
@@ -284,7 +297,7 @@ export class MeteringService {
|
||||
*/
|
||||
private async countPushSent(clubId: string, since: Date): Promise<number> {
|
||||
const rows = await this.prisma.$queryRawUnsafe<{ count: bigint }[]>(
|
||||
`SELECT COUNT(*)::bigint AS count FROM push_notifications
|
||||
`SELECT COUNT(*)::bigint AS count FROM notifications
|
||||
WHERE club_id = $1 AND created_at >= $2`,
|
||||
clubId,
|
||||
since,
|
||||
|
||||
@@ -102,8 +102,8 @@ export class ProvisioningService {
|
||||
|
||||
// 2. Create the license
|
||||
await tx.$executeRawUnsafe(
|
||||
`INSERT INTO licenses (club_id, type, max_users, max_clients, is_active, expires_at, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, true, NOW() + INTERVAL '1 year', NOW(), NOW())`,
|
||||
`INSERT INTO licenses (club_id, type, max_users, max_clients, is_active, start_date, end_date, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, true, NOW(), NOW() + INTERVAL '1 year', NOW(), NOW())`,
|
||||
club.id,
|
||||
licenseType,
|
||||
maxUsers,
|
||||
@@ -123,24 +123,28 @@ export class ProvisioningService {
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Create default notification settings
|
||||
await tx.$executeRawUnsafe(
|
||||
`INSERT INTO notification_settings (club_id, settings_json, created_at, updated_at)
|
||||
VALUES ($1, $2::jsonb, NOW(), NOW())
|
||||
ON CONFLICT (club_id) DO NOTHING`,
|
||||
club.id,
|
||||
JSON.stringify({
|
||||
new_client_assigned: true,
|
||||
service_booking: true,
|
||||
payment_received: true,
|
||||
package_topup: true,
|
||||
booking_cancelled: true,
|
||||
training_deducted: true,
|
||||
trainings_ending: true,
|
||||
client_returned: true,
|
||||
client_birthday: true,
|
||||
}),
|
||||
);
|
||||
// 4. Create default notification settings (one row per type)
|
||||
const notificationTypes = [
|
||||
'CLIENT_ASSIGNED',
|
||||
'SERVICE_BOOKED',
|
||||
'SERVICE_PAID',
|
||||
'PACKAGE_TOPUP',
|
||||
'BOOKING_CANCELLED',
|
||||
'TRAINING_DEDUCTED',
|
||||
'TRAININGS_ENDING',
|
||||
'CLIENT_RETURNED',
|
||||
'CLIENT_BIRTHDAY',
|
||||
];
|
||||
|
||||
for (const notificationType of notificationTypes) {
|
||||
await tx.$executeRawUnsafe(
|
||||
`INSERT INTO notification_settings (id, club_id, notification_type, enabled, created_at, updated_at)
|
||||
VALUES (gen_random_uuid(), $1, $2::"NotificationType", true, NOW(), NOW())
|
||||
ON CONFLICT (club_id, notification_type) DO NOTHING`,
|
||||
club.id,
|
||||
notificationType,
|
||||
);
|
||||
}
|
||||
|
||||
return club;
|
||||
});
|
||||
|
||||
@@ -1,10 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { UserPlus, ChevronRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
interface FunnelClient {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
interface FunnelEntry {
|
||||
id: string;
|
||||
stage: string;
|
||||
createdAt: string;
|
||||
comment: string | null;
|
||||
client: FunnelClient;
|
||||
}
|
||||
|
||||
interface TrainerUser {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone: string;
|
||||
role: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface UnassignedClient {
|
||||
id: string;
|
||||
@@ -22,69 +47,87 @@ interface Trainer {
|
||||
capacity: number;
|
||||
}
|
||||
|
||||
const placeholderClients: UnassignedClient[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Кузнецов Артём',
|
||||
phone: '+7 (999) 111-22-33',
|
||||
registeredAt: '2026-02-17',
|
||||
source: 'Сайт',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Морозова Елена',
|
||||
phone: '+7 (999) 222-33-44',
|
||||
registeredAt: '2026-02-16',
|
||||
source: 'Звонок',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Волков Сергей',
|
||||
phone: '+7 (999) 333-44-55',
|
||||
registeredAt: '2026-02-16',
|
||||
source: 'Рецепция',
|
||||
},
|
||||
];
|
||||
function mapFunnelEntryToClient(entry: FunnelEntry): UnassignedClient {
|
||||
return {
|
||||
id: entry.client.id,
|
||||
name: `${entry.client.lastName} ${entry.client.firstName}`,
|
||||
phone: entry.client.phone,
|
||||
registeredAt: new Date(entry.createdAt).toISOString().split('T')[0] ?? '',
|
||||
source: 'Воронка',
|
||||
};
|
||||
}
|
||||
|
||||
const placeholderTrainers: Trainer[] = [
|
||||
{
|
||||
id: 't1',
|
||||
name: 'Петров А.С.',
|
||||
department: 'Тренажёрный зал',
|
||||
activeClients: 12,
|
||||
function mapUserToTrainer(user: TrainerUser): Trainer {
|
||||
return {
|
||||
id: user.id,
|
||||
name: `${user.lastName} ${user.firstName.charAt(0)}.`,
|
||||
department: '',
|
||||
activeClients: 0,
|
||||
capacity: 20,
|
||||
},
|
||||
{
|
||||
id: 't2',
|
||||
name: 'Козлова Е.В.',
|
||||
department: 'Групповые программы',
|
||||
activeClients: 8,
|
||||
capacity: 15,
|
||||
},
|
||||
{
|
||||
id: 't3',
|
||||
name: 'Смирнова И.П.',
|
||||
department: 'Тренажёрный зал',
|
||||
activeClients: 15,
|
||||
capacity: 20,
|
||||
},
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
export default function DistributionPage() {
|
||||
const [selectedClient, setSelectedClient] = useState<string | null>(null);
|
||||
const [selectedTrainer, setSelectedTrainer] = useState<string | null>(null);
|
||||
const [clients, setClients] = useState<UnassignedClient[]>([]);
|
||||
const [trainers, setTrainers] = useState<Trainer[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [assigning, setAssigning] = useState(false);
|
||||
|
||||
const fetchData = () => {
|
||||
setLoading(true);
|
||||
void Promise.all([
|
||||
api
|
||||
.get<{ data: FunnelEntry[]; hasMore: boolean; nextCursor: string | null }>(
|
||||
'/funnel?stage=NEW&limit=50',
|
||||
)
|
||||
.then((res) => res.data.map(mapFunnelEntryToClient))
|
||||
.catch(() => [] as UnassignedClient[]),
|
||||
api
|
||||
.get<TrainerUser[]>('/users/trainers')
|
||||
.then((res) => res.map(mapUserToTrainer))
|
||||
.catch(() => [] as Trainer[]),
|
||||
])
|
||||
.then(([clientsData, trainersData]) => {
|
||||
setClients(clientsData);
|
||||
setTrainers(trainersData);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleAssign = () => {
|
||||
if (selectedClient && selectedTrainer) {
|
||||
// Placeholder: will call API
|
||||
alert(
|
||||
`Клиент назначен тренеру (client: ${selectedClient}, trainer: ${selectedTrainer})`,
|
||||
);
|
||||
setSelectedClient(null);
|
||||
setSelectedTrainer(null);
|
||||
setAssigning(true);
|
||||
api
|
||||
.post('/funnel/assign', {
|
||||
clientId: selectedClient,
|
||||
trainerId: selectedTrainer,
|
||||
})
|
||||
.then(() => {
|
||||
setSelectedClient(null);
|
||||
setSelectedTrainer(null);
|
||||
fetchData();
|
||||
})
|
||||
.catch(() => {
|
||||
alert('Ошибка при назначении тренера');
|
||||
})
|
||||
.finally(() => setAssigning(false));
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#FF6B00]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats */}
|
||||
@@ -93,7 +136,7 @@ export default function DistributionPage() {
|
||||
<CardContent className="p-4">
|
||||
<p className="text-sm text-[#6B7280]">Нераспределённые</p>
|
||||
<p className="text-2xl font-bold text-[#FF6B00]">
|
||||
{placeholderClients.length}
|
||||
{clients.length}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -101,14 +144,14 @@ export default function DistributionPage() {
|
||||
<CardContent className="p-4">
|
||||
<p className="text-sm text-[#6B7280]">Тренеров доступно</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{placeholderTrainers.length}
|
||||
{trainers.length}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-sm text-[#6B7280]">Распределено сегодня</p>
|
||||
<p className="text-2xl font-bold text-[#43A047]">4</p>
|
||||
<p className="text-2xl font-bold text-[#43A047]">--</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -120,7 +163,7 @@ export default function DistributionPage() {
|
||||
<CardTitle>Нераспределённые клиенты</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{placeholderClients.map((client) => (
|
||||
{clients.map((client) => (
|
||||
<button
|
||||
key={client.id}
|
||||
onClick={() => setSelectedClient(client.id)}
|
||||
@@ -143,7 +186,7 @@ export default function DistributionPage() {
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{placeholderClients.length === 0 && (
|
||||
{clients.length === 0 && (
|
||||
<p className="py-8 text-center text-[#6B7280]">
|
||||
Все клиенты распределены
|
||||
</p>
|
||||
@@ -157,7 +200,7 @@ export default function DistributionPage() {
|
||||
<CardTitle>Выберите тренера</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{placeholderTrainers.map((trainer) => (
|
||||
{trainers.map((trainer) => (
|
||||
<button
|
||||
key={trainer.id}
|
||||
onClick={() => setSelectedTrainer(trainer.id)}
|
||||
@@ -170,7 +213,7 @@ export default function DistributionPage() {
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{trainer.name}</p>
|
||||
<p className="text-sm text-[#6B7280]">
|
||||
{trainer.department}
|
||||
{trainer.department || 'Тренер'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
@@ -181,6 +224,11 @@ export default function DistributionPage() {
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{trainers.length === 0 && (
|
||||
<p className="py-8 text-center text-[#6B7280]">
|
||||
Тренеры не найдены
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -188,9 +236,9 @@ export default function DistributionPage() {
|
||||
{/* Assign button */}
|
||||
{selectedClient && selectedTrainer && (
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleAssign} size="lg">
|
||||
<Button onClick={handleAssign} size="lg" disabled={assigning}>
|
||||
<UserPlus className="mr-2 h-5 w-5" />
|
||||
Назначить тренера
|
||||
{assigning ? 'Назначение...' : 'Назначить тренера'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Search, Plus, Clock, Phone, User } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
interface TrainingClient {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
interface TrainingTrainer {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
}
|
||||
|
||||
interface TrainingService {
|
||||
id: string;
|
||||
name: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
interface Training {
|
||||
id: string;
|
||||
scheduledAt: string;
|
||||
duration: number;
|
||||
status: string;
|
||||
client: TrainingClient;
|
||||
trainer: TrainingTrainer;
|
||||
service: TrainingService | null;
|
||||
}
|
||||
|
||||
interface Appointment {
|
||||
id: string;
|
||||
@@ -33,58 +63,61 @@ const statusVariants: Record<
|
||||
cancelled: 'error',
|
||||
};
|
||||
|
||||
const placeholderAppointments: Appointment[] = [
|
||||
{
|
||||
id: '1',
|
||||
clientName: 'Иванова Мария',
|
||||
clientPhone: '+7 (999) 123-45-67',
|
||||
time: '09:00',
|
||||
trainer: 'Петров А.С.',
|
||||
type: 'ВПТ',
|
||||
status: 'confirmed',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
clientName: 'Сидоров Алексей',
|
||||
clientPhone: '+7 (999) 234-56-78',
|
||||
time: '10:00',
|
||||
trainer: 'Козлова Е.В.',
|
||||
type: 'Персональная',
|
||||
status: 'planned',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
clientName: 'Петрова Ольга',
|
||||
clientPhone: '+7 (999) 345-67-89',
|
||||
time: '11:30',
|
||||
trainer: 'Петров А.С.',
|
||||
type: 'ВПТ',
|
||||
status: 'planned',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
clientName: 'Козлов Дмитрий',
|
||||
clientPhone: '+7 (999) 456-78-90',
|
||||
time: '13:00',
|
||||
trainer: 'Смирнова И.П.',
|
||||
type: 'Персональная',
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
clientName: 'Новикова Анна',
|
||||
clientPhone: '+7 (999) 567-89-01',
|
||||
time: '15:00',
|
||||
trainer: 'Козлова Е.В.',
|
||||
type: 'ВПТ',
|
||||
status: 'cancelled',
|
||||
},
|
||||
];
|
||||
function mapTrainingStatus(status: string): Appointment['status'] {
|
||||
const statusMap: Record<string, Appointment['status']> = {
|
||||
PLANNED: 'planned',
|
||||
CONFIRMED: 'confirmed',
|
||||
COMPLETED: 'completed',
|
||||
CANCELLED: 'cancelled',
|
||||
};
|
||||
return statusMap[status] ?? 'planned';
|
||||
}
|
||||
|
||||
function formatTime(isoString: string): string {
|
||||
const date = new Date(isoString);
|
||||
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function mapTrainingToAppointment(training: Training): Appointment {
|
||||
return {
|
||||
id: training.id,
|
||||
clientName: `${training.client.lastName} ${training.client.firstName}`,
|
||||
clientPhone: training.client.phone,
|
||||
time: formatTime(training.scheduledAt),
|
||||
trainer: `${training.trainer.lastName} ${training.trainer.firstName.charAt(0)}.`,
|
||||
type: training.service?.name ?? 'Тренировка',
|
||||
status: mapTrainingStatus(training.status),
|
||||
};
|
||||
}
|
||||
|
||||
export default function ReceptionPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const filteredAppointments = placeholderAppointments.filter(
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<{ data: Training[]; hasMore: boolean; nextCursor: string | null }>(
|
||||
'/trainings?limit=50',
|
||||
)
|
||||
.then((res) => {
|
||||
setAppointments(res.data.map(mapTrainingToAppointment));
|
||||
})
|
||||
.catch(() => {
|
||||
setAppointments([]);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#FF6B00]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filteredAppointments = appointments.filter(
|
||||
(a) =>
|
||||
a.clientName.toLowerCase().includes(search.toLowerCase()) ||
|
||||
a.clientPhone.includes(search),
|
||||
@@ -116,7 +149,7 @@ export default function ReceptionPage() {
|
||||
<CardContent className="p-4">
|
||||
<p className="text-sm text-[#6B7280]">Всего записей</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{placeholderAppointments.length}
|
||||
{appointments.length}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -124,7 +157,7 @@ export default function ReceptionPage() {
|
||||
<CardContent className="p-4">
|
||||
<p className="text-sm text-[#6B7280]">Подтверждено</p>
|
||||
<p className="text-2xl font-bold text-[#43A047]">
|
||||
{placeholderAppointments.filter((a) => a.status === 'confirmed').length}
|
||||
{appointments.filter((a) => a.status === 'confirmed').length}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -132,7 +165,7 @@ export default function ReceptionPage() {
|
||||
<CardContent className="p-4">
|
||||
<p className="text-sm text-[#6B7280]">Ожидает</p>
|
||||
<p className="text-2xl font-bold text-[#FF9800]">
|
||||
{placeholderAppointments.filter((a) => a.status === 'planned').length}
|
||||
{appointments.filter((a) => a.status === 'planned').length}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -140,7 +173,7 @@ export default function ReceptionPage() {
|
||||
<CardContent className="p-4">
|
||||
<p className="text-sm text-[#6B7280]">Отменено</p>
|
||||
<p className="text-2xl font-bold text-[#E53935]">
|
||||
{placeholderAppointments.filter((a) => a.status === 'cancelled').length}
|
||||
{appointments.filter((a) => a.status === 'cancelled').length}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,35 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FileText } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
interface ReportGeneratedBy {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
}
|
||||
|
||||
interface Report {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
status: string;
|
||||
fileUrl: string | null;
|
||||
createdAt: string;
|
||||
generatedBy: ReportGeneratedBy | null;
|
||||
}
|
||||
|
||||
const reportTypeLabels: Record<string, string> = {
|
||||
CLIENT_STATUS: 'Текущее состояние клиентов',
|
||||
TRAINER_STATS: 'Статистика по тренерам',
|
||||
FUNNEL: 'Воронка продаж',
|
||||
FINANCIAL: 'Финансовый отчёт',
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
PENDING: 'Ожидает',
|
||||
GENERATING: 'Генерируется',
|
||||
COMPLETED: 'Готов',
|
||||
FAILED: 'Ошибка',
|
||||
};
|
||||
|
||||
const statusVariants: Record<string, 'warning' | 'success' | 'default' | 'error'> = {
|
||||
PENDING: 'warning',
|
||||
GENERATING: 'warning',
|
||||
COMPLETED: 'success',
|
||||
FAILED: 'error',
|
||||
};
|
||||
|
||||
// Default report types to always show as available for generation
|
||||
const defaultReportTypes = [
|
||||
{
|
||||
title: 'Текущее состояние клиентов',
|
||||
description: 'Активные, спящие, ушедшие клиенты',
|
||||
type: 'CLIENT_STATUS',
|
||||
},
|
||||
{
|
||||
title: 'Статистика по тренерам',
|
||||
description: 'Конверсия, нагрузка, рейтинг',
|
||||
type: 'TRAINER_STATS',
|
||||
},
|
||||
{
|
||||
title: 'Воронка продаж',
|
||||
description: 'Конверсия по этапам воронки',
|
||||
type: 'FUNNEL',
|
||||
},
|
||||
{
|
||||
title: 'Финансовый отчёт',
|
||||
description: 'Продажи, реализация, долги',
|
||||
type: 'FINANCIAL',
|
||||
},
|
||||
];
|
||||
|
||||
export default function ReportsPage() {
|
||||
const [reports, setReports] = useState<Report[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<{ data: Report[]; hasMore: boolean; nextCursor: string | null }>(
|
||||
'/reports?limit=50',
|
||||
)
|
||||
.then((res) => {
|
||||
setReports(res.data);
|
||||
})
|
||||
.catch(() => {
|
||||
setReports([]);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#FF6B00]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Report generation buttons */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Отчёты</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[
|
||||
{
|
||||
title: 'Текущее состояние клиентов',
|
||||
description: 'Активные, спящие, ушедшие клиенты',
|
||||
},
|
||||
{
|
||||
title: 'Статистика по тренерам',
|
||||
description: 'Конверсия, нагрузка, рейтинг',
|
||||
},
|
||||
{
|
||||
title: 'Воронка продаж',
|
||||
description: 'Конверсия по этапам воронки',
|
||||
},
|
||||
{
|
||||
title: 'Финансовый отчёт',
|
||||
description: 'Продажи, реализация, долги',
|
||||
},
|
||||
].map((report) => (
|
||||
{defaultReportTypes.map((report) => (
|
||||
<button
|
||||
key={report.title}
|
||||
key={report.type}
|
||||
className="flex items-start gap-4 rounded-xl border border-[#E5E7EB] p-4 text-left transition-colors hover:border-[#FF6B00] hover:bg-[#FF6B00]/5"
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[#FF6B00]/10">
|
||||
@@ -46,6 +121,73 @@ export default function ReportsPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Generated reports list */}
|
||||
{reports.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Сгенерированные отчёты</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#E5E7EB]">
|
||||
<th className="pb-3 text-left font-medium text-[#6B7280]">
|
||||
Название
|
||||
</th>
|
||||
<th className="pb-3 text-left font-medium text-[#6B7280]">
|
||||
Тип
|
||||
</th>
|
||||
<th className="pb-3 text-left font-medium text-[#6B7280]">
|
||||
Автор
|
||||
</th>
|
||||
<th className="pb-3 text-left font-medium text-[#6B7280]">
|
||||
Дата
|
||||
</th>
|
||||
<th className="pb-3 text-left font-medium text-[#6B7280]">
|
||||
Статус
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{reports.map((report) => (
|
||||
<tr
|
||||
key={report.id}
|
||||
className="border-b border-[#E5E7EB] last:border-0"
|
||||
>
|
||||
<td className="py-3 font-medium text-gray-900">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-[#FF6B00]" />
|
||||
{report.title}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 text-[#6B7280]">
|
||||
{reportTypeLabels[report.type] ?? report.type}
|
||||
</td>
|
||||
<td className="py-3 text-[#6B7280]">
|
||||
{report.generatedBy
|
||||
? `${report.generatedBy.lastName} ${report.generatedBy.firstName.charAt(0)}.`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="py-3 text-[#6B7280]">
|
||||
{new Date(report.createdAt).toLocaleDateString('ru-RU')}
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<Badge
|
||||
variant={statusVariants[report.status] ?? 'default'}
|
||||
>
|
||||
{statusLabels[report.status] ?? report.status}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
interface RatingEntry {
|
||||
trainerId: string;
|
||||
trainerName: string;
|
||||
department?: string;
|
||||
activeClients: number;
|
||||
conversionRate: number;
|
||||
score: number;
|
||||
}
|
||||
|
||||
interface TrainerUser {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone: string;
|
||||
role: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface TrainerRow {
|
||||
id: string;
|
||||
name: string;
|
||||
department: string;
|
||||
activeClients: number;
|
||||
@@ -10,42 +33,61 @@ interface TrainerRow {
|
||||
status: 'online' | 'offline';
|
||||
}
|
||||
|
||||
const placeholderTrainers: TrainerRow[] = [
|
||||
{
|
||||
name: 'Петров Андрей Сергеевич',
|
||||
department: 'Тренажёрный зал',
|
||||
activeClients: 12,
|
||||
conversionRate: 78,
|
||||
rating: 4.8,
|
||||
status: 'online',
|
||||
},
|
||||
{
|
||||
name: 'Козлова Екатерина Владимировна',
|
||||
department: 'Групповые программы',
|
||||
activeClients: 8,
|
||||
conversionRate: 85,
|
||||
rating: 4.9,
|
||||
status: 'online',
|
||||
},
|
||||
{
|
||||
name: 'Смирнова Ирина Павловна',
|
||||
department: 'Тренажёрный зал',
|
||||
activeClients: 15,
|
||||
conversionRate: 72,
|
||||
rating: 4.6,
|
||||
status: 'offline',
|
||||
},
|
||||
{
|
||||
name: 'Васильев Дмитрий Олегович',
|
||||
department: 'Единоборства',
|
||||
activeClients: 10,
|
||||
conversionRate: 68,
|
||||
rating: 4.5,
|
||||
status: 'offline',
|
||||
},
|
||||
];
|
||||
|
||||
export default function TrainersPage() {
|
||||
const [trainers, setTrainers] = useState<TrainerRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Try stats/rating first for rich data, fall back to users/trainers
|
||||
api
|
||||
.get<RatingEntry[]>('/stats/rating')
|
||||
.then((res) => {
|
||||
const rows: TrainerRow[] = (Array.isArray(res) ? res : []).map(
|
||||
(entry) => ({
|
||||
id: entry.trainerId,
|
||||
name: entry.trainerName,
|
||||
department: entry.department ?? '',
|
||||
activeClients: entry.activeClients ?? 0,
|
||||
conversionRate: Math.round(entry.conversionRate ?? 0),
|
||||
rating: typeof entry.score === 'number' ? Math.round(entry.score * 10) / 10 : 0,
|
||||
status: 'online' as const,
|
||||
}),
|
||||
);
|
||||
setTrainers(rows);
|
||||
})
|
||||
.catch(() => {
|
||||
// Fallback to basic trainers list
|
||||
return api
|
||||
.get<TrainerUser[]>('/users/trainers')
|
||||
.then((res) => {
|
||||
const rows: TrainerRow[] = (Array.isArray(res) ? res : []).map(
|
||||
(user) => ({
|
||||
id: user.id,
|
||||
name: `${user.lastName} ${user.firstName}`,
|
||||
department: '',
|
||||
activeClients: 0,
|
||||
conversionRate: 0,
|
||||
rating: 0,
|
||||
status: user.isActive ? ('online' as const) : ('offline' as const),
|
||||
}),
|
||||
);
|
||||
setTrainers(rows);
|
||||
})
|
||||
.catch(() => {
|
||||
setTrainers([]);
|
||||
});
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#FF6B00]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
@@ -53,61 +95,67 @@ export default function TrainersPage() {
|
||||
<CardTitle>Статистика по тренерам</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#E5E7EB]">
|
||||
<th className="pb-3 text-left font-medium text-[#6B7280]">
|
||||
Тренер
|
||||
</th>
|
||||
<th className="pb-3 text-left font-medium text-[#6B7280]">
|
||||
Департамент
|
||||
</th>
|
||||
<th className="pb-3 text-left font-medium text-[#6B7280]">
|
||||
Клиенты
|
||||
</th>
|
||||
<th className="pb-3 text-left font-medium text-[#6B7280]">
|
||||
Конверсия
|
||||
</th>
|
||||
<th className="pb-3 text-left font-medium text-[#6B7280]">
|
||||
Рейтинг
|
||||
</th>
|
||||
<th className="pb-3 text-left font-medium text-[#6B7280]">
|
||||
Статус
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{placeholderTrainers.map((trainer) => (
|
||||
<tr
|
||||
key={trainer.name}
|
||||
className="border-b border-[#E5E7EB] last:border-0"
|
||||
>
|
||||
<td className="py-3 font-medium text-gray-900">
|
||||
{trainer.name}
|
||||
</td>
|
||||
<td className="py-3 text-[#6B7280]">
|
||||
{trainer.department}
|
||||
</td>
|
||||
<td className="py-3">{trainer.activeClients}</td>
|
||||
<td className="py-3">{trainer.conversionRate}%</td>
|
||||
<td className="py-3">{trainer.rating}</td>
|
||||
<td className="py-3">
|
||||
<Badge
|
||||
variant={
|
||||
trainer.status === 'online' ? 'success' : 'default'
|
||||
}
|
||||
>
|
||||
{trainer.status === 'online'
|
||||
? 'На смене'
|
||||
: 'Не на смене'}
|
||||
</Badge>
|
||||
</td>
|
||||
{trainers.length === 0 ? (
|
||||
<p className="py-8 text-center text-[#6B7280]">
|
||||
Тренеры не найдены
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#E5E7EB]">
|
||||
<th className="pb-3 text-left font-medium text-[#6B7280]">
|
||||
Тренер
|
||||
</th>
|
||||
<th className="pb-3 text-left font-medium text-[#6B7280]">
|
||||
Департамент
|
||||
</th>
|
||||
<th className="pb-3 text-left font-medium text-[#6B7280]">
|
||||
Клиенты
|
||||
</th>
|
||||
<th className="pb-3 text-left font-medium text-[#6B7280]">
|
||||
Конверсия
|
||||
</th>
|
||||
<th className="pb-3 text-left font-medium text-[#6B7280]">
|
||||
Рейтинг
|
||||
</th>
|
||||
<th className="pb-3 text-left font-medium text-[#6B7280]">
|
||||
Статус
|
||||
</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
{trainers.map((trainer) => (
|
||||
<tr
|
||||
key={trainer.id}
|
||||
className="border-b border-[#E5E7EB] last:border-0"
|
||||
>
|
||||
<td className="py-3 font-medium text-gray-900">
|
||||
{trainer.name}
|
||||
</td>
|
||||
<td className="py-3 text-[#6B7280]">
|
||||
{trainer.department || '—'}
|
||||
</td>
|
||||
<td className="py-3">{trainer.activeClients}</td>
|
||||
<td className="py-3">{trainer.conversionRate}%</td>
|
||||
<td className="py-3">{trainer.rating}</td>
|
||||
<td className="py-3">
|
||||
<Badge
|
||||
variant={
|
||||
trainer.status === 'online' ? 'success' : 'default'
|
||||
}
|
||||
>
|
||||
{trainer.status === 'online'
|
||||
? 'На смене'
|
||||
: 'Не на смене'}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plus, Search, Package, Tag } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
type Service = {
|
||||
id: string;
|
||||
@@ -22,60 +23,45 @@ type ServicePackage = {
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
const mockServices: Service[] = [
|
||||
{ id: '1', name: 'Персональная тренировка', category: 'Тренажёрный зал', price: 3000, duration: 60, isActive: true },
|
||||
{ id: '2', name: 'Сплит-тренировка (2 чел.)', category: 'Тренажёрный зал', price: 2000, duration: 60, isActive: true },
|
||||
{ id: '3', name: 'Групповое занятие', category: 'Групповые', price: 800, duration: 55, isActive: true },
|
||||
{ id: '4', name: 'Персональное плавание', category: 'Бассейн', price: 2500, duration: 45, isActive: true },
|
||||
{ id: '5', name: 'ВПТ (вводная тренировка)', category: 'Тренажёрный зал', price: 0, duration: 60, isActive: true },
|
||||
{ id: '6', name: 'Занятие по боксу', category: 'Единоборства', price: 2500, duration: 60, isActive: true },
|
||||
{ id: '7', name: 'Стретчинг (индив.)', category: 'Групповые', price: 2000, duration: 45, isActive: false },
|
||||
];
|
||||
type ServicesResponse = {
|
||||
data: Service[];
|
||||
hasMore: boolean;
|
||||
nextCursor: string | null;
|
||||
};
|
||||
|
||||
const mockPackages: ServicePackage[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Старт (8 тренировок)',
|
||||
services: ['Персональная тренировка'],
|
||||
totalPrice: 20000,
|
||||
discountPercent: 17,
|
||||
validDays: 30,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Оптимал (12 тренировок)',
|
||||
services: ['Персональная тренировка'],
|
||||
totalPrice: 28800,
|
||||
discountPercent: 20,
|
||||
validDays: 45,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Безлимит (месяц)',
|
||||
services: ['Групповое занятие', 'Бассейн'],
|
||||
totalPrice: 15000,
|
||||
discountPercent: 0,
|
||||
validDays: 30,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Боец (10 занятий)',
|
||||
services: ['Занятие по боксу'],
|
||||
totalPrice: 20000,
|
||||
discountPercent: 20,
|
||||
validDays: 45,
|
||||
isActive: true,
|
||||
},
|
||||
];
|
||||
type PackagesResponse = {
|
||||
data: ServicePackage[];
|
||||
hasMore: boolean;
|
||||
nextCursor: string | null;
|
||||
};
|
||||
|
||||
export default function CatalogPage() {
|
||||
const [tab, setTab] = useState<'services' | 'packages'>('services');
|
||||
const [search, setSearch] = useState('');
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [packages, setPackages] = useState<ServicePackage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const filteredServices = mockServices.filter((s) =>
|
||||
useEffect(() => {
|
||||
void Promise.all([
|
||||
api.get<ServicesResponse>('/catalog/services')
|
||||
.then((res) => setServices(res.data ?? []))
|
||||
.catch(() => setServices([])),
|
||||
api.get<PackagesResponse>('/catalog/packages')
|
||||
.then((res) => setPackages(res.data ?? []))
|
||||
.catch(() => setPackages([])),
|
||||
]).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filteredServices = services.filter((s) =>
|
||||
s.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
s.category.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
@@ -100,7 +86,7 @@ export default function CatalogPage() {
|
||||
}`}
|
||||
>
|
||||
<Tag className="h-4 w-4" />
|
||||
Услуги ({mockServices.length})
|
||||
Услуги ({services.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('packages')}
|
||||
@@ -111,7 +97,7 @@ export default function CatalogPage() {
|
||||
}`}
|
||||
>
|
||||
<Package className="h-4 w-4" />
|
||||
Пакеты ({mockPackages.length})
|
||||
Пакеты ({packages.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -166,7 +152,7 @@ export default function CatalogPage() {
|
||||
|
||||
{tab === 'packages' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{mockPackages.map((pkg) => (
|
||||
{packages.map((pkg) => (
|
||||
<div key={pkg.id} className="bg-card rounded-xl p-6 shadow-sm border border-border">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{pkg.name}</h3>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Users,
|
||||
UserCheck,
|
||||
@@ -8,57 +9,22 @@ import {
|
||||
DollarSign,
|
||||
Activity,
|
||||
} from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: 'Сотрудники',
|
||||
value: '24',
|
||||
change: '+2 за месяц',
|
||||
icon: Users,
|
||||
color: 'text-blue-600',
|
||||
bg: 'bg-blue-50',
|
||||
},
|
||||
{
|
||||
label: 'Активные клиенты',
|
||||
value: '1 248',
|
||||
change: '+86 за месяц',
|
||||
icon: UserCheck,
|
||||
color: 'text-success',
|
||||
bg: 'bg-green-50',
|
||||
},
|
||||
{
|
||||
label: 'Выручка (месяц)',
|
||||
value: '2 450 000 \u20BD',
|
||||
change: '+12% к прошлому',
|
||||
icon: DollarSign,
|
||||
color: 'text-primary',
|
||||
bg: 'bg-orange-50',
|
||||
},
|
||||
{
|
||||
label: 'Тренировок (месяц)',
|
||||
value: '3 672',
|
||||
change: '+8% к прошлому',
|
||||
icon: Calendar,
|
||||
color: 'text-purple-600',
|
||||
bg: 'bg-purple-50',
|
||||
},
|
||||
{
|
||||
label: 'Конверсия воронки',
|
||||
value: '34%',
|
||||
change: '+2 п.п.',
|
||||
icon: TrendingUp,
|
||||
color: 'text-teal-600',
|
||||
bg: 'bg-teal-50',
|
||||
},
|
||||
{
|
||||
label: 'Загруженность залов',
|
||||
value: '72%',
|
||||
change: 'В рабочие часы',
|
||||
icon: Activity,
|
||||
color: 'text-rose-600',
|
||||
bg: 'bg-rose-50',
|
||||
},
|
||||
];
|
||||
type StatsSummary = {
|
||||
employees?: number;
|
||||
employeesChange?: string;
|
||||
activeClients?: number;
|
||||
activeClientsChange?: string;
|
||||
revenue?: number;
|
||||
revenueChange?: string;
|
||||
trainings?: number;
|
||||
trainingsChange?: string;
|
||||
funnelConversion?: number;
|
||||
funnelConversionChange?: string;
|
||||
roomOccupancy?: number;
|
||||
roomOccupancyNote?: string;
|
||||
};
|
||||
|
||||
const recentActivity = [
|
||||
{ time: '14:32', text: 'Иванов А. провёл тренировку с Петровой М.' },
|
||||
@@ -68,7 +34,81 @@ const recentActivity = [
|
||||
{ time: '12:55', text: 'Смена графика: Фёдоров И. — выходной 20.02' },
|
||||
];
|
||||
|
||||
function formatNumber(num: number | undefined): string {
|
||||
if (num === undefined) return '—';
|
||||
return num.toLocaleString('ru-RU');
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [data, setData] = useState<StatsSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<StatsSummary>('/stats/summary')
|
||||
.then((res) => setData(res))
|
||||
.catch(() => setData(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: 'Сотрудники',
|
||||
value: formatNumber(data?.employees),
|
||||
change: data?.employeesChange ?? '—',
|
||||
icon: Users,
|
||||
color: 'text-blue-600',
|
||||
bg: 'bg-blue-50',
|
||||
},
|
||||
{
|
||||
label: 'Активные клиенты',
|
||||
value: formatNumber(data?.activeClients),
|
||||
change: data?.activeClientsChange ?? '—',
|
||||
icon: UserCheck,
|
||||
color: 'text-success',
|
||||
bg: 'bg-green-50',
|
||||
},
|
||||
{
|
||||
label: 'Выручка (месяц)',
|
||||
value: data?.revenue !== undefined ? `${formatNumber(data.revenue)} \u20BD` : '—',
|
||||
change: data?.revenueChange ?? '—',
|
||||
icon: DollarSign,
|
||||
color: 'text-primary',
|
||||
bg: 'bg-orange-50',
|
||||
},
|
||||
{
|
||||
label: 'Тренировок (месяц)',
|
||||
value: formatNumber(data?.trainings),
|
||||
change: data?.trainingsChange ?? '—',
|
||||
icon: Calendar,
|
||||
color: 'text-purple-600',
|
||||
bg: 'bg-purple-50',
|
||||
},
|
||||
{
|
||||
label: 'Конверсия воронки',
|
||||
value: data?.funnelConversion !== undefined ? `${data.funnelConversion}%` : '—',
|
||||
change: data?.funnelConversionChange ?? '—',
|
||||
icon: TrendingUp,
|
||||
color: 'text-teal-600',
|
||||
bg: 'bg-teal-50',
|
||||
},
|
||||
{
|
||||
label: 'Загруженность залов',
|
||||
value: data?.roomOccupancy !== undefined ? `${data.roomOccupancy}%` : '—',
|
||||
change: data?.roomOccupancyNote ?? '—',
|
||||
icon: Activity,
|
||||
color: 'text-rose-600',
|
||||
bg: 'bg-rose-50',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Дашборд клуба</h1>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plus, Users, UserCheck, Edit, Trash2 } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
type Department = {
|
||||
id: string;
|
||||
@@ -11,50 +13,31 @@ type Department = {
|
||||
description: string;
|
||||
};
|
||||
|
||||
const mockDepartments: Department[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Тренажёрный зал',
|
||||
head: 'Козлов Дмитрий',
|
||||
trainersCount: 8,
|
||||
activeClients: 420,
|
||||
description: 'Силовые тренировки, функциональный тренинг, персональные занятия',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Групповые программы',
|
||||
head: 'Петрова Мария',
|
||||
trainersCount: 6,
|
||||
activeClients: 310,
|
||||
description: 'Йога, пилатес, степ-аэробика, стретчинг, танцы',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Бассейн',
|
||||
head: 'Сидорова Анна',
|
||||
trainersCount: 3,
|
||||
activeClients: 185,
|
||||
description: 'Плавание, аквааэробика, обучение плаванию',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Единоборства',
|
||||
head: 'Волков Сергей',
|
||||
trainersCount: 4,
|
||||
activeClients: 156,
|
||||
description: 'Бокс, кикбоксинг, MMA, самооборона',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'Детский фитнес',
|
||||
head: 'Николаева Ольга',
|
||||
trainersCount: 3,
|
||||
activeClients: 177,
|
||||
description: 'Детские группы 4-8 лет, подростки 9-14 лет, спортивная гимнастика',
|
||||
},
|
||||
];
|
||||
type DepartmentsResponse = {
|
||||
data: Department[];
|
||||
hasMore: boolean;
|
||||
nextCursor: string | null;
|
||||
};
|
||||
|
||||
export default function DepartmentsPage() {
|
||||
const [departments, setDepartments] = useState<Department[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<DepartmentsResponse>('/departments')
|
||||
.then((res) => setDepartments(res.data ?? []))
|
||||
.catch(() => setDepartments([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
@@ -66,7 +49,7 @@ export default function DepartmentsPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{mockDepartments.map((dept) => (
|
||||
{departments.map((dept) => (
|
||||
<div
|
||||
key={dept.id}
|
||||
className="bg-card rounded-xl p-6 shadow-sm border border-border"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Plug,
|
||||
Phone,
|
||||
@@ -13,43 +13,36 @@ import {
|
||||
Copy,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
type IntegrationStatus = 'connected' | 'disconnected' | 'error';
|
||||
|
||||
type Integration = {
|
||||
type IntegrationConfig = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: IntegrationStatus;
|
||||
icon: typeof Plug;
|
||||
lastSync?: string;
|
||||
};
|
||||
|
||||
const integrations: Integration[] = [
|
||||
{
|
||||
id: '1c',
|
||||
name: '1C:Фитнес клуб',
|
||||
description: 'Двусторонняя синхронизация клиентов, членств, продаж и графика работы',
|
||||
status: 'connected',
|
||||
icon: Plug,
|
||||
lastSync: '18.02.2026, 14:30',
|
||||
},
|
||||
{
|
||||
id: 'sip',
|
||||
name: 'SIP-телефония',
|
||||
description: 'Встроенный SIP-клиент для исходящих звонков с CallerID клуба',
|
||||
status: 'connected',
|
||||
icon: Phone,
|
||||
lastSync: undefined,
|
||||
},
|
||||
{
|
||||
id: 'webhooks',
|
||||
name: 'Webhooks',
|
||||
description: '12 типов исходящих событий для внешних интеграций (HMAC-SHA256)',
|
||||
status: 'disconnected',
|
||||
icon: Webhook,
|
||||
},
|
||||
];
|
||||
type WebhookEntry = {
|
||||
id: string;
|
||||
url: string;
|
||||
events: string[];
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
type WebhooksResponse = {
|
||||
data: WebhookEntry[];
|
||||
hasMore: boolean;
|
||||
nextCursor: string | null;
|
||||
};
|
||||
|
||||
const integrationIcons: Record<string, typeof Plug> = {
|
||||
'1c': Plug,
|
||||
sip: Phone,
|
||||
webhooks: Webhook,
|
||||
};
|
||||
|
||||
const mockApiKeys = [
|
||||
{
|
||||
@@ -76,8 +69,76 @@ const statusConfig: Record<IntegrationStatus, { label: string; className: string
|
||||
error: { label: 'Ошибка', className: 'text-error', icon: XCircle },
|
||||
};
|
||||
|
||||
const defaultIntegrations: IntegrationConfig[] = [
|
||||
{
|
||||
id: '1c',
|
||||
name: '1C:Фитнес клуб',
|
||||
description: 'Двусторонняя синхронизация клиентов, членств, продаж и графика работы',
|
||||
status: 'disconnected',
|
||||
},
|
||||
{
|
||||
id: 'sip',
|
||||
name: 'SIP-телефония',
|
||||
description: 'Встроенный SIP-клиент для исходящих звонков с CallerID клуба',
|
||||
status: 'disconnected',
|
||||
},
|
||||
{
|
||||
id: 'webhooks',
|
||||
name: 'Webhooks',
|
||||
description: '12 типов исходящих событий для внешних интеграций (HMAC-SHA256)',
|
||||
status: 'disconnected',
|
||||
},
|
||||
];
|
||||
|
||||
export default function IntegrationsPage() {
|
||||
const [showKey, setShowKey] = useState<string | null>(null);
|
||||
const [integrations, setIntegrations] = useState<IntegrationConfig[]>(defaultIntegrations);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
void Promise.all([
|
||||
api.get<IntegrationConfig>('/integration/config')
|
||||
.then((config) => {
|
||||
setIntegrations((prev) =>
|
||||
prev.map((item) => {
|
||||
if (item.id === '1c') {
|
||||
return {
|
||||
...item,
|
||||
status: (config as Record<string, unknown>).status as IntegrationStatus ?? item.status,
|
||||
lastSync: (config as Record<string, unknown>).lastSync as string | undefined,
|
||||
};
|
||||
}
|
||||
return item;
|
||||
})
|
||||
);
|
||||
})
|
||||
.catch(() => {}),
|
||||
api.get<WebhooksResponse>('/webhooks')
|
||||
.then((res) => {
|
||||
const hasActive = (res.data ?? []).some((w) => w.active);
|
||||
setIntegrations((prev) =>
|
||||
prev.map((item) => {
|
||||
if (item.id === 'webhooks') {
|
||||
return {
|
||||
...item,
|
||||
status: hasActive ? 'connected' : 'disconnected',
|
||||
};
|
||||
}
|
||||
return item;
|
||||
})
|
||||
);
|
||||
})
|
||||
.catch(() => {}),
|
||||
]).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -85,7 +146,7 @@ export default function IntegrationsPage() {
|
||||
|
||||
<div className="space-y-6 mb-10">
|
||||
{integrations.map((integration) => {
|
||||
const Icon = integration.icon;
|
||||
const Icon = integrationIcons[integration.id] ?? Plug;
|
||||
const status = statusConfig[integration.status];
|
||||
const StatusIcon = status.icon;
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
KeyRound,
|
||||
Calendar,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
Package,
|
||||
BarChart3,
|
||||
} from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
type Module = {
|
||||
id: string;
|
||||
@@ -23,16 +25,34 @@ type UsageItem = {
|
||||
unit: string;
|
||||
};
|
||||
|
||||
const licenseInfo = {
|
||||
type: 'Стандартная',
|
||||
clubName: 'FitGym Premium',
|
||||
activatedAt: '01.12.2025',
|
||||
expiresAt: '31.12.2026',
|
||||
daysLeft: 316,
|
||||
status: 'active' as const,
|
||||
type LicenseInfo = {
|
||||
type: string;
|
||||
clubName: string;
|
||||
activatedAt: string;
|
||||
expiresAt: string;
|
||||
daysLeft: number;
|
||||
status: 'active' | 'expired' | 'grace';
|
||||
};
|
||||
|
||||
const modules: Module[] = [
|
||||
type UsageData = {
|
||||
active_users?: { current: number; limit: number };
|
||||
clients?: { current: number; limit: number };
|
||||
call_storage_gb?: { current: number; limit: number };
|
||||
webhooks_sent?: { current: number; limit: number };
|
||||
api_requests?: { current: number; limit: number };
|
||||
push_sent?: { current: number; limit: number };
|
||||
};
|
||||
|
||||
const defaultLicenseInfo: LicenseInfo = {
|
||||
type: '—',
|
||||
clubName: '—',
|
||||
activatedAt: '—',
|
||||
expiresAt: '—',
|
||||
daysLeft: 0,
|
||||
status: 'active',
|
||||
};
|
||||
|
||||
const defaultModules: Module[] = [
|
||||
{ id: 'core', name: 'Ядро CRM', enabled: true, description: 'Основные функции CRM' },
|
||||
{ id: 'sales', name: 'Продажи и реализация', enabled: true, description: 'Управление продажами и долгами' },
|
||||
{ id: 'coordinator', name: 'Координатор', enabled: true, description: 'Распределение клиентов по тренерам' },
|
||||
@@ -46,16 +66,59 @@ const modules: Module[] = [
|
||||
{ id: '1c_sync', name: 'Интеграция 1С', enabled: true, description: 'Синхронизация с 1С:Фитнес клуб' },
|
||||
];
|
||||
|
||||
const usage: UsageItem[] = [
|
||||
{ label: 'Активные пользователи', current: 24, limit: 50, unit: 'чел.' },
|
||||
{ label: 'Клиентская база', current: 1248, limit: 5000, unit: 'записей' },
|
||||
const defaultUsage: UsageItem[] = [
|
||||
{ label: 'Активные пользователи', current: 0, limit: 0, unit: 'чел.' },
|
||||
{ label: 'Клиентская база', current: 0, limit: 0, unit: 'записей' },
|
||||
{ label: 'Записи звонков', current: 0, limit: 0, unit: 'ГБ' },
|
||||
{ label: 'Webhook-доставка', current: 3200, limit: 10000, unit: 'событий/мес' },
|
||||
{ label: 'API-запросы', current: 12450, limit: 50000, unit: 'запросов/мес' },
|
||||
{ label: 'Push-уведомления', current: 1820, limit: 5000, unit: 'сообщ./мес' },
|
||||
{ label: 'Webhook-доставка', current: 0, limit: 0, unit: 'событий/мес' },
|
||||
{ label: 'API-запросы', current: 0, limit: 0, unit: 'запросов/мес' },
|
||||
{ label: 'Push-уведомления', current: 0, limit: 0, unit: 'сообщ./мес' },
|
||||
];
|
||||
|
||||
function mapUsageData(data: UsageData): UsageItem[] {
|
||||
return [
|
||||
{ label: 'Активные пользователи', current: data.active_users?.current ?? 0, limit: data.active_users?.limit ?? 0, unit: 'чел.' },
|
||||
{ label: 'Клиентская база', current: data.clients?.current ?? 0, limit: data.clients?.limit ?? 0, unit: 'записей' },
|
||||
{ label: 'Записи звонков', current: data.call_storage_gb?.current ?? 0, limit: data.call_storage_gb?.limit ?? 0, unit: 'ГБ' },
|
||||
{ label: 'Webhook-доставка', current: data.webhooks_sent?.current ?? 0, limit: data.webhooks_sent?.limit ?? 0, unit: 'событий/мес' },
|
||||
{ label: 'API-запросы', current: data.api_requests?.current ?? 0, limit: data.api_requests?.limit ?? 0, unit: 'запросов/мес' },
|
||||
{ label: 'Push-уведомления', current: data.push_sent?.current ?? 0, limit: data.push_sent?.limit ?? 0, unit: 'сообщ./мес' },
|
||||
];
|
||||
}
|
||||
|
||||
export default function LicensePage() {
|
||||
const [licenseInfo, setLicenseInfo] = useState<LicenseInfo>(defaultLicenseInfo);
|
||||
const [modules] = useState<Module[]>(defaultModules);
|
||||
const [usage, setUsage] = useState<UsageItem[]>(defaultUsage);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
void Promise.all([
|
||||
api.get<LicenseInfo>('/licenses/my')
|
||||
.then((res) => setLicenseInfo(res))
|
||||
.catch(() => {}),
|
||||
api.get<UsageData>('/metering/usage')
|
||||
.then((res) => setUsage(mapUsageData(res)))
|
||||
.catch(() => {}),
|
||||
]).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusBadge = licenseInfo.status === 'active'
|
||||
? { label: 'Активна', className: 'bg-green-100 text-green-800' }
|
||||
: licenseInfo.status === 'grace'
|
||||
? { label: 'Льготный период', className: 'bg-yellow-100 text-yellow-800' }
|
||||
: { label: 'Истекла', className: 'bg-red-100 text-red-800' };
|
||||
|
||||
const daysColor = licenseInfo.daysLeft > 30 ? 'text-success' : licenseInfo.daysLeft > 7 ? 'text-yellow-600' : 'text-error';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Лицензия</h1>
|
||||
@@ -70,9 +133,9 @@ export default function LicensePage() {
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
Лицензия: {licenseInfo.type}
|
||||
</h2>
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
<span className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium ${statusBadge.className}`}>
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Активна
|
||||
{statusBadge.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">{licenseInfo.clubName}</p>
|
||||
@@ -98,10 +161,10 @@ export default function LicensePage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-success" />
|
||||
<AlertTriangle className={`h-5 w-5 ${daysColor}`} />
|
||||
<div>
|
||||
<p className="text-xs text-muted">Осталось дней</p>
|
||||
<p className="text-sm font-medium text-success">{licenseInfo.daysLeft}</p>
|
||||
<p className={`text-sm font-medium ${daysColor}`}>{licenseInfo.daysLeft}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plus, Edit, Trash2, MapPin, Users, Clock } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
type Room = {
|
||||
id: string;
|
||||
@@ -13,68 +15,11 @@ type Room = {
|
||||
workingHours: string;
|
||||
};
|
||||
|
||||
const mockRooms: Room[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Основной тренажёрный зал',
|
||||
location: '2 этаж',
|
||||
capacity: 50,
|
||||
type: 'Тренажёрный',
|
||||
equipment: ['Кардиозона', 'Свободные веса', 'Тренажёры'],
|
||||
status: 'available',
|
||||
workingHours: '06:00 - 23:00',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Зал групповых программ A',
|
||||
location: '1 этаж',
|
||||
capacity: 25,
|
||||
type: 'Групповой',
|
||||
equipment: ['Зеркала', 'Коврики', 'Степ-платформы'],
|
||||
status: 'occupied',
|
||||
workingHours: '07:00 - 22:00',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Зал групповых программ B',
|
||||
location: '1 этаж',
|
||||
capacity: 20,
|
||||
type: 'Групповой',
|
||||
equipment: ['Зеркала', 'Коврики', 'Мячи'],
|
||||
status: 'available',
|
||||
workingHours: '07:00 - 22:00',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Бассейн (25 м)',
|
||||
location: '1 этаж',
|
||||
capacity: 30,
|
||||
type: 'Бассейн',
|
||||
equipment: ['6 дорожек', 'Трибуна'],
|
||||
status: 'available',
|
||||
workingHours: '06:00 - 22:00',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'Зал единоборств',
|
||||
location: '3 этаж',
|
||||
capacity: 20,
|
||||
type: 'Единоборства',
|
||||
equipment: ['Ринг', 'Груши', 'Маты'],
|
||||
status: 'maintenance',
|
||||
workingHours: '08:00 - 21:00',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
name: 'Студия йоги',
|
||||
location: '2 этаж',
|
||||
capacity: 15,
|
||||
type: 'Студия',
|
||||
equipment: ['Коврики', 'Блоки', 'Ремни', 'Болстеры'],
|
||||
status: 'available',
|
||||
workingHours: '07:00 - 21:00',
|
||||
},
|
||||
];
|
||||
type RoomsResponse = {
|
||||
data: Room[];
|
||||
hasMore: boolean;
|
||||
nextCursor: string | null;
|
||||
};
|
||||
|
||||
const statusMap: Record<string, { label: string; className: string }> = {
|
||||
available: { label: 'Свободен', className: 'bg-green-100 text-green-800' },
|
||||
@@ -83,6 +28,24 @@ const statusMap: Record<string, { label: string; className: string }> = {
|
||||
};
|
||||
|
||||
export default function RoomsPage() {
|
||||
const [rooms, setRooms] = useState<Room[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<RoomsResponse>('/rooms')
|
||||
.then((res) => setRooms(res.data ?? []))
|
||||
.catch(() => setRooms([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
@@ -94,7 +57,7 @@ export default function RoomsPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{mockRooms.map((room) => {
|
||||
{rooms.map((room) => {
|
||||
const status = statusMap[room.status] ?? { label: 'Неизвестно', className: 'bg-gray-100 text-gray-800' };
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plus, Search, MoreHorizontal, UserCheck, UserX } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
type Employee = {
|
||||
id: string;
|
||||
@@ -13,15 +14,11 @@ type Employee = {
|
||||
hiredAt: string;
|
||||
};
|
||||
|
||||
const mockEmployees: Employee[] = [
|
||||
{ id: '1', name: 'Иванов Алексей', role: 'Тренер', department: 'Тренажёрный зал', phone: '+7 (900) 123-45-67', status: 'active', hiredAt: '2024-03-15' },
|
||||
{ id: '2', name: 'Петрова Мария', role: 'Тренер', department: 'Групповые программы', phone: '+7 (900) 234-56-78', status: 'active', hiredAt: '2024-01-10' },
|
||||
{ id: '3', name: 'Козлов Дмитрий', role: 'Координатор', department: 'Тренажёрный зал', phone: '+7 (900) 345-67-89', status: 'active', hiredAt: '2023-09-01' },
|
||||
{ id: '4', name: 'Сидорова Анна', role: 'Тренер', department: 'Бассейн', phone: '+7 (900) 456-78-90', status: 'inactive', hiredAt: '2023-06-20' },
|
||||
{ id: '5', name: 'Фёдоров Игорь', role: 'Фитнес-менеджер', department: 'Управление', phone: '+7 (900) 567-89-01', status: 'active', hiredAt: '2022-11-05' },
|
||||
{ id: '6', name: 'Морозова Елена', role: 'Администратор', department: 'Рецепция', phone: '+7 (900) 678-90-12', status: 'active', hiredAt: '2024-05-12' },
|
||||
{ id: '7', name: 'Волков Сергей', role: 'Тренер', department: 'Единоборства', phone: '+7 (900) 789-01-23', status: 'active', hiredAt: '2024-02-28' },
|
||||
];
|
||||
type UsersResponse = {
|
||||
data: Employee[];
|
||||
hasMore: boolean;
|
||||
nextCursor: string | null;
|
||||
};
|
||||
|
||||
const roleColors: Record<string, string> = {
|
||||
'Тренер': 'bg-blue-100 text-blue-800',
|
||||
@@ -31,9 +28,26 @@ const roleColors: Record<string, string> = {
|
||||
};
|
||||
|
||||
export default function StaffPage() {
|
||||
const [employees, setEmployees] = useState<Employee[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filtered = mockEmployees.filter(
|
||||
useEffect(() => {
|
||||
api.get<UsersResponse>('/users')
|
||||
.then((res) => setEmployees(res.data ?? []))
|
||||
.catch(() => setEmployees([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filtered = employees.filter(
|
||||
(e) =>
|
||||
e.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
e.role.toLowerCase().includes(search.toLowerCase()) ||
|
||||
@@ -114,7 +128,7 @@ export default function StaffPage() {
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-3 border-t border-border text-sm text-muted">
|
||||
Показано {filtered.length} из {mockEmployees.length} сотрудников
|
||||
Показано {filtered.length} из {employees.length} сотрудников
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
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';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
@@ -9,6 +9,35 @@ import {
|
||||
ChevronDown,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
interface ApiClub {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
address: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
logoUrl: string | null;
|
||||
timezone: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
license: {
|
||||
id: string;
|
||||
type: 'STARTER' | 'STANDARD' | 'PREMIUM';
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
isActive: boolean;
|
||||
gracePeriodEnd: string | null;
|
||||
maxUsers: number;
|
||||
maxClients: number;
|
||||
} | null;
|
||||
_count: {
|
||||
users: number;
|
||||
clients: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface Club {
|
||||
id: string;
|
||||
@@ -22,118 +51,73 @@ interface Club {
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
const clubs: Club[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'FitGym Москва',
|
||||
city: 'Москва',
|
||||
license: 'Премиум',
|
||||
users: 34,
|
||||
clients: 2450,
|
||||
status: 'active',
|
||||
createdAt: '15.03.2025',
|
||||
expiresAt: '15.03.2027',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'SportMax Москва',
|
||||
city: 'Москва',
|
||||
license: 'Стандартная',
|
||||
users: 18,
|
||||
clients: 1120,
|
||||
status: 'active',
|
||||
createdAt: '01.06.2025',
|
||||
expiresAt: '01.06.2026',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'FitLife Казань',
|
||||
city: 'Казань',
|
||||
license: 'Стандартная',
|
||||
users: 12,
|
||||
clients: 680,
|
||||
status: 'grace',
|
||||
createdAt: '10.08.2025',
|
||||
expiresAt: '16.02.2026',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'PowerGym Уфа',
|
||||
city: 'Уфа',
|
||||
license: 'Стандартная',
|
||||
users: 12,
|
||||
clients: 540,
|
||||
status: 'active',
|
||||
createdAt: '14.02.2026',
|
||||
expiresAt: '14.02.2027',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'GymPro Самара',
|
||||
city: 'Самара',
|
||||
license: 'Стартовая',
|
||||
users: 6,
|
||||
clients: 320,
|
||||
status: 'active',
|
||||
createdAt: '20.09.2025',
|
||||
expiresAt: '23.02.2026',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
name: 'BodyFit Пермь',
|
||||
city: 'Пермь',
|
||||
license: 'Премиум',
|
||||
users: 28,
|
||||
clients: 1850,
|
||||
status: 'active',
|
||||
createdAt: '10.02.2026',
|
||||
expiresAt: '10.02.2028',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
name: 'FitZone Сочи',
|
||||
city: 'Сочи',
|
||||
license: 'Стартовая',
|
||||
users: 4,
|
||||
clients: 85,
|
||||
status: 'trial',
|
||||
createdAt: '17.02.2026',
|
||||
expiresAt: '17.03.2026',
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
name: 'IronFit Новосибирск',
|
||||
city: 'Новосибирск',
|
||||
license: 'Стандартная',
|
||||
users: 15,
|
||||
clients: 920,
|
||||
status: 'blocked',
|
||||
createdAt: '05.01.2025',
|
||||
expiresAt: '01.01.2026',
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
name: 'TopForm Екатеринбург',
|
||||
city: 'Екатеринбург',
|
||||
license: 'Премиум',
|
||||
users: 42,
|
||||
clients: 3200,
|
||||
status: 'active',
|
||||
createdAt: '22.04.2025',
|
||||
expiresAt: '22.04.2027',
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
name: 'FlexGym Краснодар',
|
||||
city: 'Краснодар',
|
||||
license: 'Стартовая',
|
||||
users: 5,
|
||||
clients: 210,
|
||||
status: 'active',
|
||||
createdAt: '11.11.2025',
|
||||
expiresAt: '11.11.2026',
|
||||
},
|
||||
];
|
||||
const licenseTypeMap: Record<string, Club['license']> = {
|
||||
STARTER: 'Стартовая',
|
||||
STANDARD: 'Стандартная',
|
||||
PREMIUM: 'Премиум',
|
||||
};
|
||||
|
||||
function mapApiClubToClub(apiClub: ApiClub): Club {
|
||||
let status: Club['status'] = 'active';
|
||||
let expiresAt = '—';
|
||||
let licenseLabel: Club['license'] = 'Стартовая';
|
||||
|
||||
if (apiClub.license) {
|
||||
licenseLabel = licenseTypeMap[apiClub.license.type] || 'Стартовая';
|
||||
const endDate = new Date(apiClub.license.endDate);
|
||||
expiresAt = endDate.toLocaleDateString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const daysUntilExpiry = Math.ceil(
|
||||
(endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
|
||||
if (!apiClub.isActive || !apiClub.license.isActive) {
|
||||
status = 'blocked';
|
||||
} else if (apiClub.license.gracePeriodEnd) {
|
||||
status = 'grace';
|
||||
} else if (daysUntilExpiry <= 0) {
|
||||
status = 'blocked';
|
||||
} else if (daysUntilExpiry <= 30 && daysUntilExpiry > 0) {
|
||||
// Could be trial or active depending on license age
|
||||
const startDate = new Date(apiClub.license.startDate);
|
||||
const licenseDuration = Math.ceil(
|
||||
(endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
if (licenseDuration <= 30) {
|
||||
status = 'trial';
|
||||
} else {
|
||||
status = 'active';
|
||||
}
|
||||
} else {
|
||||
status = 'active';
|
||||
}
|
||||
} else {
|
||||
status = apiClub.isActive ? 'trial' : 'blocked';
|
||||
}
|
||||
|
||||
const createdAt = new Date(apiClub.createdAt).toLocaleDateString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
return {
|
||||
id: apiClub.id,
|
||||
name: apiClub.name,
|
||||
city: apiClub.slug,
|
||||
license: licenseLabel,
|
||||
users: apiClub._count.users,
|
||||
clients: apiClub._count.clients,
|
||||
status,
|
||||
createdAt,
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
const statusLabels: Record<Club['status'], string> = {
|
||||
active: 'Активен',
|
||||
@@ -156,18 +140,79 @@ const licenseColors: Record<Club['license'], string> = {
|
||||
};
|
||||
|
||||
export default function ClubsPage() {
|
||||
const [clubs, setClubs] = useState<Club[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
|
||||
const fetchClubs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', '100');
|
||||
if (search) {
|
||||
params.set('search', search);
|
||||
}
|
||||
if (statusFilter === 'blocked') {
|
||||
params.set('isActive', 'false');
|
||||
} else if (statusFilter !== 'all') {
|
||||
params.set('isActive', 'true');
|
||||
}
|
||||
|
||||
const res = await api.get<{ data: ApiClub[]; hasMore: boolean; nextCursor: string | null }>(
|
||||
`/admin/clubs?${params.toString()}`,
|
||||
);
|
||||
const mapped = res.data.map(mapApiClubToClub);
|
||||
setClubs(mapped);
|
||||
setTotalCount(mapped.length);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Ошибка загрузки клубов';
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [search, statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
const debounce = setTimeout(() => {
|
||||
fetchClubs();
|
||||
}, 300);
|
||||
return () => clearTimeout(debounce);
|
||||
}, [fetchClubs]);
|
||||
|
||||
const filtered = clubs.filter((club) => {
|
||||
const matchesSearch =
|
||||
club.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
club.city.toLowerCase().includes(search.toLowerCase());
|
||||
// API already handles search, but we still filter status client-side
|
||||
// since API only has isActive filter, not grace/trial distinction
|
||||
const matchesStatus =
|
||||
statusFilter === 'all' || club.status === statusFilter;
|
||||
return matchesSearch && matchesStatus;
|
||||
return matchesStatus;
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64 gap-4">
|
||||
<p className="text-sm text-error">{error}</p>
|
||||
<button
|
||||
onClick={fetchClubs}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
Попробовать снова
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
@@ -176,7 +221,7 @@ export default function ClubsPage() {
|
||||
Управление клубами
|
||||
</h1>
|
||||
<p className="text-sm text-muted mt-1">
|
||||
{clubs.length} клубов на платформе
|
||||
{totalCount} клубов на платформе
|
||||
</p>
|
||||
</div>
|
||||
<button className="flex items-center gap-2 bg-primary text-white px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-primary/90 transition-colors">
|
||||
@@ -301,7 +346,7 @@ export default function ClubsPage() {
|
||||
|
||||
<div className="px-4 py-3 border-t border-border flex items-center justify-between">
|
||||
<p className="text-sm text-muted">
|
||||
Показано {filtered.length} из {clubs.length} клубов
|
||||
Показано {filtered.length} из {totalCount} клубов
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Building2,
|
||||
KeyRound,
|
||||
@@ -8,6 +9,39 @@ import {
|
||||
AlertTriangle,
|
||||
TrendingUp,
|
||||
} from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
interface ApiClub {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
license: {
|
||||
id: string;
|
||||
type: 'STARTER' | 'STANDARD' | 'PREMIUM';
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
isActive: boolean;
|
||||
} | null;
|
||||
_count: {
|
||||
users: number;
|
||||
clients: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface RecentClub {
|
||||
name: string;
|
||||
license: string;
|
||||
users: number;
|
||||
date: string;
|
||||
}
|
||||
|
||||
const licenseTypeMap: Record<string, string> = {
|
||||
STARTER: 'Стартовая',
|
||||
STANDARD: 'Стандартная',
|
||||
PREMIUM: 'Премиум',
|
||||
};
|
||||
|
||||
const stats = [
|
||||
{
|
||||
@@ -88,12 +122,6 @@ const alerts = [
|
||||
},
|
||||
];
|
||||
|
||||
const recentClubs = [
|
||||
{ name: 'FitZone Сочи', license: 'Стартовая', users: 4, date: '17.02.2026' },
|
||||
{ name: 'PowerGym Уфа', license: 'Стандартная', users: 12, date: '14.02.2026' },
|
||||
{ name: 'BodyFit Пермь', license: 'Премиум', users: 28, date: '10.02.2026' },
|
||||
];
|
||||
|
||||
const alertColors = {
|
||||
critical: 'bg-red-100 text-error border-red-200',
|
||||
warning: 'bg-orange-50 text-orange-700 border-orange-200',
|
||||
@@ -101,6 +129,36 @@ const alertColors = {
|
||||
};
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [recentClubs, setRecentClubs] = useState<RecentClub[]>([]);
|
||||
const [clubsLoading, setClubsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<{ data: ApiClub[]; hasMore: boolean; nextCursor: string | null }>(
|
||||
'/admin/clubs?limit=5',
|
||||
)
|
||||
.then((res) => {
|
||||
const mapped = res.data.map((club) => ({
|
||||
name: club.name,
|
||||
license: club.license
|
||||
? licenseTypeMap[club.license.type] || 'Стартовая'
|
||||
: '—',
|
||||
users: club._count.users,
|
||||
date: new Date(club.createdAt).toLocaleDateString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
}),
|
||||
}));
|
||||
setRecentClubs(mapped);
|
||||
})
|
||||
.catch(() => {
|
||||
// Fallback: keep empty on error
|
||||
setRecentClubs([]);
|
||||
})
|
||||
.finally(() => setClubsLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">
|
||||
@@ -152,40 +210,50 @@ export default function DashboardPage() {
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Недавно подключённые клубы
|
||||
</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left py-2 text-muted font-medium">
|
||||
Клуб
|
||||
</th>
|
||||
<th className="text-left py-2 text-muted font-medium">
|
||||
Лицензия
|
||||
</th>
|
||||
<th className="text-right py-2 text-muted font-medium">
|
||||
Пользователи
|
||||
</th>
|
||||
<th className="text-right py-2 text-muted font-medium">
|
||||
Дата
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentClubs.map((club) => (
|
||||
<tr key={club.name} className="border-b border-border/50">
|
||||
<td className="py-3 font-medium text-gray-900">
|
||||
{club.name}
|
||||
</td>
|
||||
<td className="py-3 text-muted">{club.license}</td>
|
||||
<td className="py-3 text-right text-gray-700">
|
||||
{club.users}
|
||||
</td>
|
||||
<td className="py-3 text-right text-muted">{club.date}</td>
|
||||
{clubsLoading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
|
||||
</div>
|
||||
) : recentClubs.length === 0 ? (
|
||||
<p className="text-sm text-muted text-center py-8">
|
||||
Нет данных о клубах
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left py-2 text-muted font-medium">
|
||||
Клуб
|
||||
</th>
|
||||
<th className="text-left py-2 text-muted font-medium">
|
||||
Лицензия
|
||||
</th>
|
||||
<th className="text-right py-2 text-muted font-medium">
|
||||
Пользователи
|
||||
</th>
|
||||
<th className="text-right py-2 text-muted font-medium">
|
||||
Дата
|
||||
</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentClubs.map((club) => (
|
||||
<tr key={club.name} className="border-b border-border/50">
|
||||
<td className="py-3 font-medium text-gray-900">
|
||||
{club.name}
|
||||
</td>
|
||||
<td className="py-3 text-muted">{club.license}</td>
|
||||
<td className="py-3 text-right text-gray-700">
|
||||
{club.users}
|
||||
</td>
|
||||
<td className="py-3 text-right text-muted">{club.date}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Check,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
MoreHorizontal,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
const licenseTypes = [
|
||||
{
|
||||
@@ -30,7 +31,7 @@ const licenseTypes = [
|
||||
'Интеграция 1С': false,
|
||||
},
|
||||
limits: { users: 5, clients: 500 },
|
||||
activeCount: 12,
|
||||
activeCount: 0,
|
||||
},
|
||||
{
|
||||
id: 'standard',
|
||||
@@ -52,7 +53,7 @@ const licenseTypes = [
|
||||
'Интеграция 1С': true,
|
||||
},
|
||||
limits: { users: 10, clients: 1000 },
|
||||
activeCount: 22,
|
||||
activeCount: 0,
|
||||
},
|
||||
{
|
||||
id: 'premium',
|
||||
@@ -74,10 +75,29 @@ const licenseTypes = [
|
||||
'Интеграция 1С': true,
|
||||
},
|
||||
limits: { users: 'Без лимита', clients: 'Без лимита' },
|
||||
activeCount: 8,
|
||||
activeCount: 0,
|
||||
},
|
||||
];
|
||||
|
||||
interface ApiLicense {
|
||||
id: string;
|
||||
clubId: string;
|
||||
type: 'STARTER' | 'STANDARD' | 'PREMIUM';
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
isActive: boolean;
|
||||
gracePeriodEnd: string | null;
|
||||
maxUsers: number;
|
||||
maxClients: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
club: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface License {
|
||||
id: string;
|
||||
club: string;
|
||||
@@ -88,16 +108,58 @@ interface License {
|
||||
autoRenew: boolean;
|
||||
}
|
||||
|
||||
const licenses: License[] = [
|
||||
{ id: 'L-001', club: 'FitGym Москва', type: 'Премиум', status: 'active', startDate: '15.03.2025', endDate: '15.03.2027', autoRenew: true },
|
||||
{ id: 'L-002', club: 'SportMax Москва', type: 'Стандартная', status: 'active', startDate: '01.06.2025', endDate: '01.06.2026', autoRenew: true },
|
||||
{ id: 'L-003', club: 'FitLife Казань', type: 'Стандартная', status: 'expired', startDate: '10.08.2025', endDate: '16.02.2026', autoRenew: false },
|
||||
{ id: 'L-004', club: 'GymPro Самара', type: 'Стартовая', status: 'expiring', startDate: '20.09.2025', endDate: '23.02.2026', autoRenew: true },
|
||||
{ id: 'L-005', club: 'BodyFit Пермь', type: 'Премиум', status: 'active', startDate: '10.02.2026', endDate: '10.02.2028', autoRenew: true },
|
||||
{ id: 'L-006', club: 'FitZone Сочи', type: 'Стартовая', status: 'trial', startDate: '17.02.2026', endDate: '17.03.2026', autoRenew: false },
|
||||
{ id: 'L-007', club: 'TopForm Екатеринбург', type: 'Премиум', status: 'active', startDate: '22.04.2025', endDate: '22.04.2027', autoRenew: true },
|
||||
{ id: 'L-008', club: 'IronFit Новосибирск', type: 'Стандартная', status: 'expired', startDate: '05.01.2025', endDate: '01.01.2026', autoRenew: false },
|
||||
];
|
||||
const licenseTypeMap: Record<string, License['type']> = {
|
||||
STARTER: 'Стартовая',
|
||||
STANDARD: 'Стандартная',
|
||||
PREMIUM: 'Премиум',
|
||||
};
|
||||
|
||||
const licenseTypeApiMap: Record<string, string> = {
|
||||
STARTER: 'starter',
|
||||
STANDARD: 'standard',
|
||||
PREMIUM: 'premium',
|
||||
};
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function mapApiLicense(apiLic: ApiLicense): License {
|
||||
const now = new Date();
|
||||
const endDate = new Date(apiLic.endDate);
|
||||
const startDate = new Date(apiLic.startDate);
|
||||
const daysUntilExpiry = Math.ceil(
|
||||
(endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
const licenseDuration = Math.ceil(
|
||||
(endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
|
||||
let status: License['status'] = 'active';
|
||||
if (!apiLic.isActive || daysUntilExpiry < 0) {
|
||||
status = 'expired';
|
||||
} else if (licenseDuration <= 30) {
|
||||
status = 'trial';
|
||||
} else if (daysUntilExpiry <= 7) {
|
||||
status = 'expiring';
|
||||
} else {
|
||||
status = 'active';
|
||||
}
|
||||
|
||||
return {
|
||||
id: apiLic.id.substring(0, 8).toUpperCase(),
|
||||
club: apiLic.club.name,
|
||||
type: licenseTypeMap[apiLic.type] || 'Стартовая',
|
||||
status,
|
||||
startDate: formatDate(apiLic.startDate),
|
||||
endDate: formatDate(apiLic.endDate),
|
||||
autoRenew: apiLic.isActive && daysUntilExpiry > 0,
|
||||
};
|
||||
}
|
||||
|
||||
const statusLabels: Record<License['status'], string> = {
|
||||
active: 'Активна',
|
||||
@@ -121,6 +183,75 @@ const typeColors: Record<License['type'], string> = {
|
||||
|
||||
export default function LicensesPage() {
|
||||
const [tab, setTab] = useState<'types' | 'list'>('types');
|
||||
const [licenses, setLicenses] = useState<License[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [typeCounts, setTypeCounts] = useState<Record<string, number>>({
|
||||
starter: 0,
|
||||
standard: 0,
|
||||
premium: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api
|
||||
.get<{ data: ApiLicense[]; hasMore: boolean; nextCursor: string | null }>(
|
||||
'/licenses?limit=100',
|
||||
)
|
||||
.then((res) => {
|
||||
const mapped = res.data.map(mapApiLicense);
|
||||
setLicenses(mapped);
|
||||
|
||||
// Count active licenses per type
|
||||
const counts: Record<string, number> = {
|
||||
starter: 0,
|
||||
standard: 0,
|
||||
premium: 0,
|
||||
};
|
||||
for (const apiLic of res.data) {
|
||||
const key = licenseTypeApiMap[apiLic.type];
|
||||
if (key && apiLic.isActive) {
|
||||
counts[key] = (counts[key] || 0) + 1;
|
||||
}
|
||||
}
|
||||
setTypeCounts(counts);
|
||||
})
|
||||
.catch((err) => {
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'Ошибка загрузки лицензий';
|
||||
setError(message);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
// Update licenseTypes with live counts
|
||||
const enrichedLicenseTypes = licenseTypes.map((type) => ({
|
||||
...type,
|
||||
activeCount: typeCounts[type.id] || 0,
|
||||
}));
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64 gap-4">
|
||||
<p className="text-sm text-error">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
Попробовать снова
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -166,7 +297,7 @@ export default function LicensesPage() {
|
||||
|
||||
{tab === 'types' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{licenseTypes.map((type) => (
|
||||
{enrichedLicenseTypes.map((type) => (
|
||||
<div
|
||||
key={type.id}
|
||||
className={cn(
|
||||
|
||||
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",
|
||||
"dev": "tsc --build --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fitcrm/shared-types": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"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
|
||||
// Auto-generated API client from OpenAPI specification
|
||||
// This package will be populated when the API OpenAPI spec is available.
|
||||
|
||||
export {};
|
||||
export { FitCrmApiClient } from './client';
|
||||
export { HttpClient, ApiError } from './http';
|
||||
export type { HttpClientConfig } from './http';
|
||||
export type * from './types';
|
||||
|
||||
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": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"composite": true
|
||||
"composite": true,
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false
|
||||
},
|
||||
"references": [
|
||||
{ "path": "../shared-types" }
|
||||
],
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
4
pnpm-lock.yaml
generated
4
pnpm-lock.yaml
generated
@@ -315,6 +315,10 @@ importers:
|
||||
version: 5.9.3
|
||||
|
||||
packages/api-client:
|
||||
dependencies:
|
||||
'@fitcrm/shared-types':
|
||||
specifier: workspace:*
|
||||
version: link:../shared-types
|
||||
devDependencies:
|
||||
typescript:
|
||||
specifier: ^5.5.0
|
||||
|
||||
Reference in New Issue
Block a user