diff --git a/apps/web/app/(admin)/clients/page.tsx b/apps/web/app/(admin)/clients/page.tsx new file mode 100644 index 0000000..938d247 --- /dev/null +++ b/apps/web/app/(admin)/clients/page.tsx @@ -0,0 +1,605 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent } from "@/components/ui/card"; +import { ClientTable } from "@/components/clients/client-table"; +import { ClientForm } from "@/components/clients/client-form"; +import { ClientDetailDialog } from "@/components/clients/client-detail-dialog"; +import { AssignMembershipDialog } from "@/components/memberships/assign-membership-dialog"; +import { StatCard, StatCardSkeleton } from "@/components/dashboard/stat-card"; + +interface Client { + id: string; + firstName: string; + lastName: string; + email: string | null; + phone: string | null; + avatar?: string | null; + level: string | null; + notes: string | null; + isActive: boolean; + createdAt: string; + memberships?: Array<{ + id: string; + status: string; + startDate: string; + endDate: string; + remainingHours: number | null; + plan: { + id: string; + name: string; + price: number | string; + durationMonths: number; + courtHours: number | null; + discountPercent: number | string | null; + }; + }>; + _count?: { + bookings: number; + }; + stats?: { + totalBookings: number; + totalSpent: number; + balance: number; + }; +} + +interface ClientsResponse { + data: Client[]; + pagination: { + total: number; + limit: number; + offset: number; + hasMore: boolean; + }; +} + +interface MembershipPlan { + id: string; + name: string; + price: number | string; + durationMonths: number; + courtHours: number | null; + discountPercent: number | string | null; +} + +const membershipFilters = [ + { value: "", label: "Todos" }, + { value: "with", label: "Con membresia" }, + { value: "without", label: "Sin membresia" }, +]; + +const ITEMS_PER_PAGE = 10; + +export default function ClientsPage() { + // Clients state + const [clients, setClients] = useState([]); + const [loadingClients, setLoadingClients] = useState(true); + const [searchQuery, setSearchQuery] = useState(""); + const [membershipFilter, setMembershipFilter] = useState(""); + const [currentPage, setCurrentPage] = useState(1); + const [totalClients, setTotalClients] = useState(0); + + // Modal state + const [showCreateForm, setShowCreateForm] = useState(false); + const [editingClient, setEditingClient] = useState(null); + const [selectedClient, setSelectedClient] = useState(null); + const [showAssignMembership, setShowAssignMembership] = useState(false); + const [formLoading, setFormLoading] = useState(false); + + // Stats state + const [stats, setStats] = useState({ + totalClients: 0, + withMembership: 0, + newThisMonth: 0, + }); + const [loadingStats, setLoadingStats] = useState(true); + + // Membership plans for assignment dialog + const [membershipPlans, setMembershipPlans] = useState([]); + + const [error, setError] = useState(null); + + // Fetch clients + const fetchClients = useCallback(async () => { + setLoadingClients(true); + try { + const params = new URLSearchParams(); + if (searchQuery) params.append("search", searchQuery); + params.append("limit", ITEMS_PER_PAGE.toString()); + params.append("offset", ((currentPage - 1) * ITEMS_PER_PAGE).toString()); + + const response = await fetch(`/api/clients?${params.toString()}`); + if (!response.ok) throw new Error("Error al cargar clientes"); + + const data: ClientsResponse = await response.json(); + + // Filter by membership status client-side for simplicity + let filteredData = data.data; + if (membershipFilter === "with") { + filteredData = data.data.filter( + (c) => + c.memberships && + c.memberships.length > 0 && + c.memberships[0].status === "ACTIVE" + ); + } else if (membershipFilter === "without") { + filteredData = data.data.filter( + (c) => + !c.memberships || + c.memberships.length === 0 || + c.memberships[0].status !== "ACTIVE" + ); + } + + setClients(filteredData); + setTotalClients(data.pagination.total); + } catch (err) { + console.error("Error fetching clients:", err); + setError(err instanceof Error ? err.message : "Error desconocido"); + } finally { + setLoadingClients(false); + } + }, [searchQuery, currentPage, membershipFilter]); + + // Fetch stats + const fetchStats = useCallback(async () => { + setLoadingStats(true); + try { + // Fetch all clients to calculate stats + const response = await fetch("/api/clients?limit=1000"); + if (!response.ok) throw new Error("Error al cargar estadisticas"); + + const data: ClientsResponse = await response.json(); + const allClients = data.data; + + // Calculate stats + const now = new Date(); + const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + + const withMembership = allClients.filter( + (c) => + c.memberships && + c.memberships.length > 0 && + c.memberships[0].status === "ACTIVE" + ).length; + + const newThisMonth = allClients.filter( + (c) => new Date(c.createdAt) >= startOfMonth + ).length; + + setStats({ + totalClients: data.pagination.total, + withMembership, + newThisMonth, + }); + } catch (err) { + console.error("Error fetching stats:", err); + } finally { + setLoadingStats(false); + } + }, []); + + // Fetch membership plans + const fetchMembershipPlans = useCallback(async () => { + try { + const response = await fetch("/api/membership-plans"); + if (!response.ok) throw new Error("Error al cargar planes"); + const data = await response.json(); + setMembershipPlans(data.filter((p: MembershipPlan & { isActive?: boolean }) => p.isActive !== false)); + } catch (err) { + console.error("Error fetching membership plans:", err); + } + }, []); + + // Fetch client details + const fetchClientDetails = async (clientId: string) => { + try { + const response = await fetch(`/api/clients/${clientId}`); + if (!response.ok) throw new Error("Error al cargar detalles del cliente"); + const data = await response.json(); + setSelectedClient(data); + } catch (err) { + console.error("Error fetching client details:", err); + setError(err instanceof Error ? err.message : "Error desconocido"); + } + }; + + useEffect(() => { + fetchClients(); + fetchStats(); + fetchMembershipPlans(); + }, [fetchClients, fetchStats, fetchMembershipPlans]); + + // Debounce search + const [debouncedSearch, setDebouncedSearch] = useState(searchQuery); + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearch(searchQuery); + }, 300); + return () => clearTimeout(timer); + }, [searchQuery]); + + useEffect(() => { + setCurrentPage(1); + }, [debouncedSearch, membershipFilter]); + + // Handle create client + const handleCreateClient = async (data: { + firstName: string; + lastName: string; + email: string; + phone: string; + avatar?: string; + }) => { + setFormLoading(true); + try { + const response = await fetch("/api/clients", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Error al crear cliente"); + } + + setShowCreateForm(false); + await Promise.all([fetchClients(), fetchStats()]); + } catch (err) { + throw err; + } finally { + setFormLoading(false); + } + }; + + // Handle update client + const handleUpdateClient = async (data: { + firstName: string; + lastName: string; + email: string; + phone: string; + avatar?: string; + }) => { + if (!editingClient) return; + + setFormLoading(true); + try { + const response = await fetch(`/api/clients/${editingClient.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Error al actualizar cliente"); + } + + setEditingClient(null); + await fetchClients(); + + // Update selected client if viewing details + if (selectedClient?.id === editingClient.id) { + await fetchClientDetails(editingClient.id); + } + } catch (err) { + throw err; + } finally { + setFormLoading(false); + } + }; + + // Handle delete client + const handleDeleteClient = async (client: Client) => { + if ( + !confirm( + `¿Estas seguro de desactivar a ${client.firstName} ${client.lastName}?` + ) + ) { + return; + } + + try { + const response = await fetch(`/api/clients/${client.id}`, { + method: "DELETE", + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Error al desactivar cliente"); + } + + await Promise.all([fetchClients(), fetchStats()]); + } catch (err) { + console.error("Error deleting client:", err); + setError(err instanceof Error ? err.message : "Error desconocido"); + } + }; + + // Handle assign membership + const handleAssignMembership = async (data: { + clientId: string; + planId: string; + startDate: string; + endDate: string; + }) => { + setFormLoading(true); + try { + const response = await fetch("/api/memberships", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Error al asignar membresia"); + } + + setShowAssignMembership(false); + await Promise.all([fetchClients(), fetchStats()]); + + // Update selected client if viewing details + if (selectedClient) { + await fetchClientDetails(selectedClient.id); + } + } catch (err) { + throw err; + } finally { + setFormLoading(false); + } + }; + + // Handle row click to view details + const handleRowClick = (client: Client) => { + fetchClientDetails(client.id); + }; + + // Calculate pagination + const totalPages = Math.ceil(totalClients / ITEMS_PER_PAGE); + + return ( +
+ {/* Header */} +
+
+

Clientes

+

+ Gestiona los clientes de tu centro +

+
+ +
+ + {/* Error Message */} + {error && ( +
+
+ + + +

{error}

+ +
+
+ )} + + {/* Stats Cards */} +
+ {loadingStats ? ( + <> + + + + + ) : ( + <> + + + + } + /> + + + + } + /> + + + + } + /> + + )} +
+ + {/* Filters */} + + +
+ {/* Search */} +
+ setSearchQuery(e.target.value)} + className="w-full" + /> +
+ + {/* Membership Filter */} +
+ {membershipFilters.map((filter) => ( + + ))} +
+
+
+
+ + {/* Clients Table */} + + setEditingClient(client)} + onDelete={handleDeleteClient} + isLoading={loadingClients} + currentPage={currentPage} + totalPages={totalPages} + onPageChange={setCurrentPage} + /> + + + {/* Create Client Form Modal */} + {showCreateForm && ( + setShowCreateForm(false)} + isLoading={formLoading} + mode="create" + /> + )} + + {/* Edit Client Form Modal */} + {editingClient && ( + setEditingClient(null)} + isLoading={formLoading} + mode="edit" + /> + )} + + {/* Client Detail Dialog */} + {selectedClient && !editingClient && ( + setSelectedClient(null)} + onEdit={() => { + setEditingClient(selectedClient); + }} + onAssignMembership={() => { + setShowAssignMembership(true); + }} + /> + )} + + {/* Assign Membership Dialog */} + {showAssignMembership && selectedClient && ( + setShowAssignMembership(false)} + onAssign={handleAssignMembership} + isLoading={formLoading} + /> + )} +
+ ); +} diff --git a/apps/web/app/api/clients/[id]/route.ts b/apps/web/app/api/clients/[id]/route.ts new file mode 100644 index 0000000..28207d2 --- /dev/null +++ b/apps/web/app/api/clients/[id]/route.ts @@ -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 = {}; + + 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 } + ); + } +} diff --git a/apps/web/components/clients/client-detail-dialog.tsx b/apps/web/components/clients/client-detail-dialog.tsx new file mode 100644 index 0000000..dff1852 --- /dev/null +++ b/apps/web/components/clients/client-detail-dialog.tsx @@ -0,0 +1,430 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { formatCurrency, formatDate, cn } from "@/lib/utils"; + +interface ClientMembership { + id: string; + status: string; + startDate: string; + endDate: string; + remainingHours: number | null; + plan: { + id: string; + name: string; + price: number | string; + durationMonths: number; + courtHours: number | null; + }; +} + +interface ClientDetail { + id: string; + firstName: string; + lastName: string; + email: string | null; + phone: string | null; + avatar?: string | null; + level: string | null; + notes: string | null; + isActive: boolean; + createdAt: string; + memberships?: ClientMembership[]; + _count?: { + bookings: number; + }; + stats?: { + totalBookings: number; + totalSpent: number; + balance: number; + }; +} + +interface ClientDetailDialogProps { + client: ClientDetail; + onClose: () => void; + onEdit?: () => void; + onAssignMembership?: () => void; + onAddBalance?: () => void; +} + +export function ClientDetailDialog({ + client, + onClose, + onEdit, + onAssignMembership, + onAddBalance, +}: ClientDetailDialogProps) { + // Get initials for avatar fallback + const getInitials = (firstName: string, lastName: string) => { + return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase(); + }; + + // Get active membership + const activeMembership = client.memberships?.find( + (m) => m.status === "ACTIVE" + ); + + // Calculate hours used if there's an active membership with hours + const hoursTotal = activeMembership?.plan.courtHours || 0; + const hoursRemaining = activeMembership?.remainingHours || 0; + const hoursUsed = hoursTotal - hoursRemaining; + + // Handle click outside to close + const handleOverlayClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose(); + } + }; + + return ( +
+ + +
+ Detalle del Cliente + +
+
+ + + {/* Profile Section */} +
+ {client.avatar ? ( + {`${client.firstName} + ) : ( +
+ + {getInitials(client.firstName, client.lastName)} + +
+ )} + +
+
+

+ {client.firstName} {client.lastName} +

+ + {client.isActive ? "Activo" : "Inactivo"} + +
+ + {client.level && ( +

+ Nivel: {client.level} +

+ )} + +
+ {client.email && ( +
+ + + + {client.email} +
+ )} + {client.phone && ( +
+ + + + {client.phone} +
+ )} +
+
+
+ + {/* Membership Section */} +
+
+

Membresia

+
+
+ {activeMembership ? ( +
+
+ Plan: + + {activeMembership.plan.name} + +
+ +
+ Precio: + + {formatCurrency(Number(activeMembership.plan.price))} + +
+ +
+ Inicio: + + {formatDate(activeMembership.startDate)} + +
+ +
+ Vencimiento: + + {formatDate(activeMembership.endDate)} + +
+ + {hoursTotal > 0 && ( +
+
+ + Horas usadas: + + + {hoursUsed} / {hoursTotal}h + +
+
+
= hoursTotal + ? "bg-red-500" + : hoursUsed >= hoursTotal * 0.75 + ? "bg-yellow-500" + : "bg-accent-500" + )} + style={{ + width: `${Math.min( + (hoursUsed / hoursTotal) * 100, + 100 + )}%`, + }} + /> +
+
+ )} +
+ ) : ( +
+ + + +

Sin membresia activa

+ {onAssignMembership && ( + + )} +
+ )} +
+
+ + {/* Stats Section */} +
+
+

+ {client.stats?.totalBookings || client._count?.bookings || 0} +

+

Reservas

+
+ +
+

+ {formatCurrency(client.stats?.totalSpent || 0)} +

+

Total Gastado

+
+ +
+

= 0 + ? "text-green-600" + : "text-red-600" + )} + > + {formatCurrency(client.stats?.balance || 0)} +

+

Saldo

+
+
+ + {/* Notes Section */} + {client.notes && ( +
+
+

Notas

+
+
+

+ {client.notes} +

+
+
+ )} + + {/* Member Since */} +
+ Cliente desde {formatDate(client.createdAt)} +
+ + {/* Action Buttons */} +
+ {onEdit && ( + + )} + + {activeMembership && onAssignMembership && ( + + )} + + {!activeMembership && onAssignMembership && ( + + )} + + {onAddBalance && ( + + )} +
+ + +
+ ); +} diff --git a/apps/web/components/clients/client-form.tsx b/apps/web/components/clients/client-form.tsx new file mode 100644 index 0000000..447e01c --- /dev/null +++ b/apps/web/components/clients/client-form.tsx @@ -0,0 +1,313 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"; + +interface ClientFormData { + firstName: string; + lastName: string; + email: string; + phone: string; + avatar?: string; +} + +interface ClientFormProps { + initialData?: Partial; + onSubmit: (data: ClientFormData) => Promise; + onCancel: () => void; + isLoading?: boolean; + mode?: "create" | "edit"; +} + +export function ClientForm({ + initialData, + onSubmit, + onCancel, + isLoading = false, + mode = "create", +}: ClientFormProps) { + const [formData, setFormData] = useState({ + firstName: "", + lastName: "", + email: "", + phone: "", + avatar: "", + }); + + const [errors, setErrors] = useState>({}); + + // Initialize form with initial data + useEffect(() => { + if (initialData) { + setFormData({ + firstName: initialData.firstName || "", + lastName: initialData.lastName || "", + email: initialData.email || "", + phone: initialData.phone || "", + avatar: initialData.avatar || "", + }); + } + }, [initialData]); + + // Validate email format + const isValidEmail = (email: string): boolean => { + if (!email) return true; // Email is optional + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + }; + + // Validate form + const validateForm = (): boolean => { + const newErrors: Record = {}; + + if (!formData.firstName.trim()) { + newErrors.firstName = "El nombre es requerido"; + } + + if (!formData.lastName.trim()) { + newErrors.lastName = "El apellido es requerido"; + } + + if (formData.email && !isValidEmail(formData.email)) { + newErrors.email = "Formato de email invalido"; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + // Handle form submission + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + try { + await onSubmit(formData); + } catch (err) { + // Error handling is done in parent component + console.error("Error submitting form:", err); + } + }; + + // Handle input change + const handleChange = ( + e: React.ChangeEvent + ) => { + const { name, value } = e.target; + setFormData((prev) => ({ + ...prev, + [name]: value, + })); + + // Clear error when user starts typing + if (errors[name]) { + setErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors[name]; + return newErrors; + }); + } + }; + + // Handle click outside to close + const handleOverlayClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onCancel(); + } + }; + + return ( +
+ + +
+ + {mode === "create" ? "Nuevo Cliente" : "Editar Cliente"} + + +
+
+ +
+ + {/* First Name */} +
+ + + {errors.firstName && ( +

{errors.firstName}

+ )} +
+ + {/* Last Name */} +
+ + + {errors.lastName && ( +

{errors.lastName}

+ )} +
+ + {/* Email */} +
+ + + {errors.email && ( +

{errors.email}

+ )} +
+ + {/* Phone */} +
+ + +
+ + {/* Avatar URL */} +
+ + +

+ URL de una imagen para el avatar del cliente +

+
+ + {/* Avatar Preview */} + {formData.avatar && ( +
+ Vista previa { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + Vista previa +
+ )} +
+ + +
+ + +
+
+
+
+
+ ); +} diff --git a/apps/web/components/clients/client-table.tsx b/apps/web/components/clients/client-table.tsx new file mode 100644 index 0000000..967ded5 --- /dev/null +++ b/apps/web/components/clients/client-table.tsx @@ -0,0 +1,376 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +interface Client { + id: string; + firstName: string; + lastName: string; + email: string | null; + phone: string | null; + avatar?: string | null; + level: string | null; + isActive: boolean; + createdAt: string; + memberships?: Array<{ + id: string; + status: string; + remainingHours: number | null; + endDate: string; + plan: { + id: string; + name: string; + discountPercent: number | string | null; + }; + }>; + _count?: { + bookings: number; + }; +} + +interface ClientTableProps { + clients: Client[]; + onRowClick?: (client: Client) => void; + onEdit?: (client: Client) => void; + onDelete?: (client: Client) => void; + isLoading?: boolean; + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; +} + +// Loading skeleton for table rows +function TableSkeleton() { + return ( + <> + {[...Array(5)].map((_, i) => ( + + +
+
+
+
+
+
+
+ + +
+ + +
+ + +
+ + +
+ + +
+
+
+
+ + + ))} + + ); +} + +export function ClientTable({ + clients, + onRowClick, + onEdit, + onDelete, + isLoading = false, + currentPage, + totalPages, + onPageChange, +}: ClientTableProps) { + // Get initials for avatar fallback + const getInitials = (firstName: string, lastName: string) => { + return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase(); + }; + + // Get membership badge info + const getMembershipBadge = (client: Client) => { + const activeMembership = client.memberships?.[0]; + if (activeMembership && activeMembership.status === "ACTIVE") { + return { + label: activeMembership.plan.name, + className: "bg-accent-100 text-accent-700 border-accent-300", + }; + } + return { + label: "Sin membresia", + className: "bg-gray-100 text-gray-600 border-gray-200", + }; + }; + + if (isLoading) { + return ( +
+ + + + + + + + + + + + + + +
+ Cliente + + Email + + Telefono + + Membresia + + Reservas + + Acciones +
+
+ ); + } + + if (clients.length === 0) { + return ( +
+
+ + + +

No hay clientes

+

Agrega tu primer cliente para comenzar

+
+
+ ); + } + + return ( +
+
+ + + + + + + + + + + + + {clients.map((client) => { + const membershipBadge = getMembershipBadge(client); + + return ( + onRowClick?.(client)} + > + {/* Client Name with Avatar */} + + + {/* Email */} + + + {/* Phone */} + + + {/* Membership Status */} + + + {/* Bookings Count */} + + + {/* Actions */} + + + ); + })} + +
+ Cliente + + Email + + Telefono + + Membresia + + Reservas + + Acciones +
+
+ {client.avatar ? ( + {`${client.firstName} + ) : ( +
+ + {getInitials(client.firstName, client.lastName)} + +
+ )} +
+

+ {client.firstName} {client.lastName} +

+ {client.level && ( +

+ Nivel: {client.level} +

+ )} +
+
+
+ {client.email || ( + - + )} + + {client.phone || ( + - + )} + + + {membershipBadge.label} + + + {client._count?.bookings || 0} + +
e.stopPropagation()} + > + + +
+
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+
+ Pagina {currentPage} de {totalPages} +
+
+ + +
+
+ )} +
+ ); +}