Add comprehensive clients management interface including: - Client table with search, filtering, and pagination - Client form for creating and editing clients - Client detail dialog showing profile, membership, and stats - API endpoint for individual client operations (GET, PUT, DELETE) - Stats cards showing total clients, with membership, and new this month - Integration with membership assignment dialog Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
378 lines
9.0 KiB
TypeScript
378 lines
9.0 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { getServerSession } from 'next-auth';
|
|
import { authOptions } from '@/lib/auth';
|
|
import { db } from '@/lib/db';
|
|
import { z } from 'zod';
|
|
|
|
interface RouteContext {
|
|
params: Promise<{ id: string }>;
|
|
}
|
|
|
|
// Validation schema for updating client
|
|
const updateClientSchema = z.object({
|
|
firstName: z.string().min(1, 'El nombre es requerido').optional(),
|
|
lastName: z.string().min(1, 'El apellido es requerido').optional(),
|
|
email: z.string().email('Email invalido').nullable().optional(),
|
|
phone: z.string().nullable().optional(),
|
|
avatar: z.string().url('URL invalida').nullable().optional(),
|
|
dateOfBirth: z.string().nullable().optional(),
|
|
address: z.string().nullable().optional(),
|
|
notes: z.string().nullable().optional(),
|
|
level: z.string().nullable().optional(),
|
|
tags: z.array(z.string()).optional(),
|
|
});
|
|
|
|
// GET /api/clients/[id] - Get a single client with details
|
|
export async function GET(
|
|
request: NextRequest,
|
|
context: RouteContext
|
|
) {
|
|
try {
|
|
const session = await getServerSession(authOptions);
|
|
|
|
if (!session?.user) {
|
|
return NextResponse.json(
|
|
{ error: 'No autorizado' },
|
|
{ status: 401 }
|
|
);
|
|
}
|
|
|
|
const { id } = await context.params;
|
|
|
|
const client = await db.client.findFirst({
|
|
where: {
|
|
id,
|
|
organizationId: session.user.organizationId,
|
|
},
|
|
include: {
|
|
memberships: {
|
|
where: {
|
|
status: 'ACTIVE',
|
|
endDate: {
|
|
gte: new Date(),
|
|
},
|
|
},
|
|
include: {
|
|
plan: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
price: true,
|
|
durationMonths: true,
|
|
courtHours: true,
|
|
discountPercent: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: {
|
|
endDate: 'desc',
|
|
},
|
|
take: 1,
|
|
},
|
|
_count: {
|
|
select: {
|
|
bookings: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!client) {
|
|
return NextResponse.json(
|
|
{ error: 'Cliente no encontrado' },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
// Calculate total spent from payments
|
|
const totalSpentResult = await db.payment.aggregate({
|
|
where: {
|
|
clientId: client.id,
|
|
},
|
|
_sum: {
|
|
amount: true,
|
|
},
|
|
});
|
|
|
|
// Calculate total from sales
|
|
const totalSalesResult = await db.sale.aggregate({
|
|
where: {
|
|
clientId: client.id,
|
|
},
|
|
_sum: {
|
|
total: true,
|
|
},
|
|
});
|
|
|
|
const totalSpent =
|
|
Number(totalSpentResult._sum.amount || 0) +
|
|
Number(totalSalesResult._sum.total || 0);
|
|
|
|
// For now, balance is set to 0 (can be extended with a balance field in the future)
|
|
const balance = 0;
|
|
|
|
return NextResponse.json({
|
|
...client,
|
|
stats: {
|
|
totalBookings: client._count.bookings,
|
|
totalSpent,
|
|
balance,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error('Error fetching client:', error);
|
|
return NextResponse.json(
|
|
{ error: 'Error al obtener el cliente' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// PUT /api/clients/[id] - Update a client
|
|
export async function PUT(
|
|
request: NextRequest,
|
|
context: RouteContext
|
|
) {
|
|
try {
|
|
const session = await getServerSession(authOptions);
|
|
|
|
if (!session?.user) {
|
|
return NextResponse.json(
|
|
{ error: 'No autorizado' },
|
|
{ status: 401 }
|
|
);
|
|
}
|
|
|
|
const { id } = await context.params;
|
|
|
|
// Verify client exists and belongs to user's organization
|
|
const existingClient = await db.client.findFirst({
|
|
where: {
|
|
id,
|
|
organizationId: session.user.organizationId,
|
|
},
|
|
});
|
|
|
|
if (!existingClient) {
|
|
return NextResponse.json(
|
|
{ error: 'Cliente no encontrado' },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
const body = await request.json();
|
|
|
|
// Validate input
|
|
const validationResult = updateClientSchema.safeParse(body);
|
|
if (!validationResult.success) {
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Datos de actualizacion invalidos',
|
|
details: validationResult.error.flatten().fieldErrors,
|
|
},
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
const {
|
|
firstName,
|
|
lastName,
|
|
email,
|
|
phone,
|
|
avatar,
|
|
dateOfBirth,
|
|
address,
|
|
notes,
|
|
level,
|
|
tags,
|
|
} = validationResult.data;
|
|
|
|
// Check for email uniqueness if email is being changed
|
|
if (email && email !== existingClient.email) {
|
|
const emailExists = await db.client.findFirst({
|
|
where: {
|
|
organizationId: session.user.organizationId,
|
|
email,
|
|
id: {
|
|
not: id,
|
|
},
|
|
},
|
|
});
|
|
|
|
if (emailExists) {
|
|
return NextResponse.json(
|
|
{ error: 'Ya existe un cliente con este email' },
|
|
{ status: 409 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// Build update data
|
|
const updateData: Record<string, unknown> = {};
|
|
|
|
if (firstName !== undefined) updateData.firstName = firstName;
|
|
if (lastName !== undefined) updateData.lastName = lastName;
|
|
if (email !== undefined) updateData.email = email;
|
|
if (phone !== undefined) updateData.phone = phone;
|
|
if (avatar !== undefined) updateData.avatar = avatar;
|
|
if (dateOfBirth !== undefined) {
|
|
updateData.dateOfBirth = dateOfBirth ? new Date(dateOfBirth) : null;
|
|
}
|
|
if (address !== undefined) updateData.address = address;
|
|
if (notes !== undefined) updateData.notes = notes;
|
|
if (level !== undefined) updateData.level = level;
|
|
if (tags !== undefined) updateData.tags = tags;
|
|
|
|
const client = await db.client.update({
|
|
where: { id },
|
|
data: updateData,
|
|
include: {
|
|
memberships: {
|
|
where: {
|
|
status: 'ACTIVE',
|
|
endDate: {
|
|
gte: new Date(),
|
|
},
|
|
},
|
|
include: {
|
|
plan: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
price: true,
|
|
durationMonths: true,
|
|
courtHours: true,
|
|
},
|
|
},
|
|
},
|
|
take: 1,
|
|
},
|
|
_count: {
|
|
select: {
|
|
bookings: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
return NextResponse.json(client);
|
|
} catch (error) {
|
|
console.error('Error updating client:', error);
|
|
|
|
// Check for unique constraint violation
|
|
if (error instanceof Error && error.message.includes('Unique constraint')) {
|
|
return NextResponse.json(
|
|
{ error: 'Ya existe un cliente con este email o DNI' },
|
|
{ status: 409 }
|
|
);
|
|
}
|
|
|
|
return NextResponse.json(
|
|
{ error: 'Error al actualizar el cliente' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// DELETE /api/clients/[id] - Soft delete a client (set isActive = false)
|
|
export async function DELETE(
|
|
request: NextRequest,
|
|
context: RouteContext
|
|
) {
|
|
try {
|
|
const session = await getServerSession(authOptions);
|
|
|
|
if (!session?.user) {
|
|
return NextResponse.json(
|
|
{ error: 'No autorizado' },
|
|
{ status: 401 }
|
|
);
|
|
}
|
|
|
|
const { id } = await context.params;
|
|
|
|
// Verify client exists and belongs to user's organization
|
|
const existingClient = await db.client.findFirst({
|
|
where: {
|
|
id,
|
|
organizationId: session.user.organizationId,
|
|
},
|
|
include: {
|
|
memberships: {
|
|
where: {
|
|
status: 'ACTIVE',
|
|
},
|
|
},
|
|
bookings: {
|
|
where: {
|
|
status: {
|
|
in: ['PENDING', 'CONFIRMED'],
|
|
},
|
|
startTime: {
|
|
gte: new Date(),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!existingClient) {
|
|
return NextResponse.json(
|
|
{ error: 'Cliente no encontrado' },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
// Check for active memberships
|
|
if (existingClient.memberships.length > 0) {
|
|
return NextResponse.json(
|
|
{
|
|
error: 'No se puede desactivar un cliente con membresia activa',
|
|
details: {
|
|
activeMemberships: existingClient.memberships.length,
|
|
},
|
|
},
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Check for pending/future bookings
|
|
if (existingClient.bookings.length > 0) {
|
|
return NextResponse.json(
|
|
{
|
|
error: 'No se puede desactivar un cliente con reservas pendientes',
|
|
details: {
|
|
pendingBookings: existingClient.bookings.length,
|
|
},
|
|
},
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Soft delete by setting isActive to false
|
|
const client = await db.client.update({
|
|
where: { id },
|
|
data: {
|
|
isActive: false,
|
|
},
|
|
select: {
|
|
id: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
isActive: true,
|
|
},
|
|
});
|
|
|
|
return NextResponse.json({
|
|
message: 'Cliente desactivado exitosamente',
|
|
client,
|
|
});
|
|
} catch (error) {
|
|
console.error('Error deleting client:', error);
|
|
return NextResponse.json(
|
|
{ error: 'Error al desactivar el cliente' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|