feat(clients): add clients management page
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>
This commit is contained in:
377
apps/web/app/api/clients/[id]/route.ts
Normal file
377
apps/web/app/api/clients/[id]/route.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user