diff --git a/apps/web/app/(admin)/memberships/page.tsx b/apps/web/app/(admin)/memberships/page.tsx new file mode 100644 index 0000000..4702712 --- /dev/null +++ b/apps/web/app/(admin)/memberships/page.tsx @@ -0,0 +1,597 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { PlanCard } from "@/components/memberships/plan-card"; +import { PlanForm } from "@/components/memberships/plan-form"; +import { MembershipTable } from "@/components/memberships/membership-table"; +import { AssignMembershipDialog } from "@/components/memberships/assign-membership-dialog"; +import { cn } from "@/lib/utils"; + +interface MembershipPlan { + id: string; + name: string; + description: string | null; + price: number | string; + durationMonths: number; + courtHours: number | null; + discountPercent: number | string | null; + benefits: string[] | null; + isActive: boolean; + subscriberCount: number; + benefitsSummary?: { + freeHours: number; + bookingDiscount: number; + extraBenefits: string[]; + }; +} + +interface Membership { + id: string; + startDate: string; + endDate: string; + status: "ACTIVE" | "EXPIRED" | "CANCELLED" | "SUSPENDED"; + remainingHours: number | null; + autoRenew: boolean; + isExpiring?: boolean; + daysUntilExpiry?: number | null; + plan: { + id: string; + name: string; + price: number | string; + durationMonths: number; + courtHours: number | null; + }; + client: { + id: string; + firstName: string; + lastName: string; + email: string | null; + phone: string | null; + }; + benefitsSummary?: { + freeHours: number; + hoursRemaining: number; + }; +} + +interface MembershipsResponse { + data: Membership[]; + pagination: { + total: number; + limit: number; + offset: number; + hasMore: boolean; + }; +} + +const statusFilters = [ + { value: "", label: "Todos" }, + { value: "ACTIVE", label: "Activas" }, + { value: "EXPIRED", label: "Expiradas" }, + { value: "CANCELLED", label: "Canceladas" }, +]; + +export default function MembershipsPage() { + // Plans state + const [plans, setPlans] = useState([]); + const [loadingPlans, setLoadingPlans] = useState(true); + const [showPlanForm, setShowPlanForm] = useState(false); + const [editingPlan, setEditingPlan] = useState(null); + const [planFormLoading, setPlanFormLoading] = useState(false); + + // Memberships state + const [memberships, setMemberships] = useState([]); + const [loadingMemberships, setLoadingMemberships] = useState(true); + const [statusFilter, setStatusFilter] = useState(""); + const [planFilter, setPlanFilter] = useState(""); + const [searchQuery, setSearchQuery] = useState(""); + const [showAssignDialog, setShowAssignDialog] = useState(false); + const [assignLoading, setAssignLoading] = useState(false); + + // Stats + const [stats, setStats] = useState({ + totalActive: 0, + expiringSoon: 0, + }); + + const [error, setError] = useState(null); + + // Fetch plans + const fetchPlans = useCallback(async () => { + setLoadingPlans(true); + try { + const response = await fetch("/api/membership-plans?includeInactive=true"); + if (!response.ok) throw new Error("Error al cargar planes"); + const data = await response.json(); + setPlans(data); + } catch (err) { + console.error("Error fetching plans:", err); + setError(err instanceof Error ? err.message : "Error desconocido"); + } finally { + setLoadingPlans(false); + } + }, []); + + // Fetch memberships + const fetchMemberships = useCallback(async () => { + setLoadingMemberships(true); + try { + const params = new URLSearchParams(); + if (statusFilter) params.append("status", statusFilter); + if (planFilter) params.append("planId", planFilter); + if (searchQuery) params.append("search", searchQuery); + + const response = await fetch(`/api/memberships?${params.toString()}`); + if (!response.ok) throw new Error("Error al cargar membresias"); + const data: MembershipsResponse = await response.json(); + setMemberships(data.data); + + // Calculate stats + const active = data.data.filter(m => m.status === "ACTIVE"); + const expiring = data.data.filter(m => m.isExpiring); + setStats({ + totalActive: active.length, + expiringSoon: expiring.length, + }); + } catch (err) { + console.error("Error fetching memberships:", err); + setError(err instanceof Error ? err.message : "Error desconocido"); + } finally { + setLoadingMemberships(false); + } + }, [statusFilter, planFilter, searchQuery]); + + useEffect(() => { + fetchPlans(); + }, [fetchPlans]); + + useEffect(() => { + fetchMemberships(); + }, [fetchMemberships]); + + // Debounce search + const [debouncedSearch, setDebouncedSearch] = useState(searchQuery); + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearch(searchQuery); + }, 300); + return () => clearTimeout(timer); + }, [searchQuery]); + + useEffect(() => { + if (debouncedSearch !== undefined) { + fetchMemberships(); + } + }, [debouncedSearch]); + + // Handle plan creation/update + const handlePlanSubmit = async (data: { + name: string; + description: string; + price: number; + durationMonths: number; + freeHours: number; + bookingDiscount: number; + storeDiscount: number; + extraBenefits: string; + }) => { + setPlanFormLoading(true); + try { + const extraBenefitsArray = data.extraBenefits + .split("\n") + .map(b => b.trim()) + .filter(b => b.length > 0); + + const payload = { + name: data.name, + description: data.description || undefined, + price: data.price, + durationMonths: data.durationMonths, + freeHours: data.freeHours || undefined, + bookingDiscount: data.bookingDiscount || undefined, + storeDiscount: data.storeDiscount || undefined, + extraBenefits: extraBenefitsArray.length > 0 ? extraBenefitsArray : undefined, + }; + + const url = editingPlan + ? `/api/membership-plans/${editingPlan.id}` + : "/api/membership-plans"; + const method = editingPlan ? "PUT" : "POST"; + + const response = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Error al guardar plan"); + } + + setShowPlanForm(false); + setEditingPlan(null); + await fetchPlans(); + } catch (err) { + throw err; + } finally { + setPlanFormLoading(false); + } + }; + + // Handle plan deletion + const handleDeletePlan = async (plan: MembershipPlan) => { + if (!confirm(`¿Estas seguro de eliminar el plan "${plan.name}"? Esta accion lo desactivara.`)) { + return; + } + + try { + const response = await fetch(`/api/membership-plans/${plan.id}`, { + method: "DELETE", + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Error al eliminar plan"); + } + + await fetchPlans(); + } catch (err) { + console.error("Error deleting plan:", err); + setError(err instanceof Error ? err.message : "Error desconocido"); + } + }; + + // Handle membership assignment + const handleAssignMembership = async (data: { + clientId: string; + planId: string; + startDate: string; + endDate: string; + }) => { + setAssignLoading(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"); + } + + setShowAssignDialog(false); + await Promise.all([fetchMemberships(), fetchPlans()]); + } catch (err) { + throw err; + } finally { + setAssignLoading(false); + } + }; + + // Handle membership renewal + const handleRenewMembership = async (membership: Membership) => { + // For renewal, we create a new membership with the same plan + const startDate = new Date(); + const endDate = new Date(); + endDate.setMonth(endDate.getMonth() + membership.plan.durationMonths); + + try { + const response = await fetch("/api/memberships", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + clientId: membership.client.id, + planId: membership.plan.id, + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Error al renovar membresia"); + } + + await Promise.all([fetchMemberships(), fetchPlans()]); + } catch (err) { + console.error("Error renewing membership:", err); + setError(err instanceof Error ? err.message : "Error desconocido"); + } + }; + + // Handle membership cancellation + const handleCancelMembership = async (membership: Membership) => { + if (!confirm(`¿Estas seguro de cancelar la membresia de ${membership.client.firstName} ${membership.client.lastName}?`)) { + return; + } + + try { + const response = await fetch(`/api/memberships/${membership.id}`, { + method: "DELETE", + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Error al cancelar membresia"); + } + + await Promise.all([fetchMemberships(), fetchPlans()]); + } catch (err) { + console.error("Error cancelling membership:", err); + setError(err instanceof Error ? err.message : "Error desconocido"); + } + }; + + // Filter active plans for assignment dialog + const activePlans = plans.filter(p => p.isActive); + + return ( +
+ {/* Header */} +
+
+

Membresias

+

+ Gestiona planes y membresias de tus clientes +

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

{error}

+ +
+
+ )} + + {/* Stats Cards */} +
+ + +
+
+ + + +
+
+

Membresias Activas

+

{stats.totalActive}

+
+
+
+
+ + 0 && "border-yellow-300 bg-yellow-50")}> + +
+
0 ? "bg-yellow-100" : "bg-primary-100" + )}> + 0 ? "text-yellow-600" : "text-primary-600" + )} fill="none" stroke="currentColor" viewBox="0 0 24 24"> + + +
+
+

Por Expirar

+

{stats.expiringSoon}

+
+
+
+
+ + + +
+
+ + + +
+
+

Planes Activos

+

{activePlans.length}

+
+
+
+
+ + + +
+
+ + + +
+
+

Total Suscriptores

+

+ {plans.reduce((sum, p) => sum + p.subscriberCount, 0)} +

+
+
+
+
+
+ + {/* Plans Section */} +
+
+

Planes de Membresia

+ +
+ + {loadingPlans ? ( +
+
+
+

Cargando planes...

+
+
+ ) : plans.length === 0 ? ( + + + + + +

No hay planes

+

Crea tu primer plan de membresia

+ +
+
+ ) : ( +
+ {plans.map((plan) => ( + { + setEditingPlan(p); + setShowPlanForm(true); + }} + onDelete={handleDeletePlan} + /> + ))} +
+ )} +
+ + {/* Memberships Section */} +
+
+

Membresias

+ +
+ + {/* Filters */} + + +
+ {/* Search */} +
+ setSearchQuery(e.target.value)} + className="w-full" + /> +
+ + {/* Plan Filter */} +
+ +
+ + {/* Status Filter */} +
+ {statusFilters.map((filter) => ( + + ))} +
+
+
+
+ + {/* Memberships Table */} + + + +
+ + {/* Plan Form Modal */} + {showPlanForm && ( +
+
+ { + setShowPlanForm(false); + setEditingPlan(null); + }} + isLoading={planFormLoading} + mode={editingPlan ? "edit" : "create"} + /> +
+
+ )} + + {/* Assign Membership Dialog */} + {showAssignDialog && ( + setShowAssignDialog(false)} + onAssign={handleAssignMembership} + isLoading={assignLoading} + /> + )} +
+ ); +} diff --git a/apps/web/components/memberships/assign-membership-dialog.tsx b/apps/web/components/memberships/assign-membership-dialog.tsx new file mode 100644 index 0000000..c44656c --- /dev/null +++ b/apps/web/components/memberships/assign-membership-dialog.tsx @@ -0,0 +1,386 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"; +import { cn, formatCurrency, formatDate } from "@/lib/utils"; + +interface Client { + id: string; + firstName: string; + lastName: string; + email: string | null; + phone: string | null; +} + +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; +} + +interface AssignMembershipDialogProps { + plans: MembershipPlan[]; + onClose: () => void; + onAssign: (data: { + clientId: string; + planId: string; + startDate: string; + endDate: string; + }) => Promise; + isLoading?: boolean; + preselectedClient?: Client; +} + +const durationOptions = [ + { value: 1, label: "1 mes" }, + { value: 3, label: "3 meses" }, + { value: 6, label: "6 meses" }, + { value: 12, label: "12 meses" }, +]; + +export function AssignMembershipDialog({ + plans, + onClose, + onAssign, + isLoading = false, + preselectedClient, +}: AssignMembershipDialogProps) { + const [searchQuery, setSearchQuery] = useState(""); + const [clients, setClients] = useState([]); + const [selectedClient, setSelectedClient] = useState(preselectedClient || null); + const [loadingClients, setLoadingClients] = useState(false); + const [selectedPlanId, setSelectedPlanId] = useState(plans[0]?.id || ""); + const [startDate, setStartDate] = useState(() => { + const today = new Date(); + return today.toISOString().split("T")[0]; + }); + const [duration, setDuration] = useState(1); + const [endDate, setEndDate] = useState(""); + const [error, setError] = useState(null); + + // Calculate end date based on start date and duration + useEffect(() => { + if (startDate && duration) { + const start = new Date(startDate); + start.setMonth(start.getMonth() + duration); + setEndDate(start.toISOString().split("T")[0]); + } + }, [startDate, duration]); + + // Fetch clients based on search + const fetchClients = useCallback(async (search: string) => { + if (!search || search.trim().length < 2) { + setClients([]); + return; + } + + setLoadingClients(true); + try { + const response = await fetch( + `/api/clients?search=${encodeURIComponent(search)}&limit=10` + ); + if (!response.ok) { + throw new Error("Error al buscar clientes"); + } + const data: ClientsResponse = await response.json(); + setClients(data.data); + } catch (err) { + console.error("Error fetching clients:", err); + setClients([]); + } finally { + setLoadingClients(false); + } + }, []); + + // Debounce client search + useEffect(() => { + const timer = setTimeout(() => { + fetchClients(searchQuery); + }, 300); + + return () => clearTimeout(timer); + }, [searchQuery, fetchClients]); + + // Get selected plan details + const selectedPlan = plans.find(p => p.id === selectedPlanId); + + const handleSubmit = async () => { + if (!selectedClient) { + setError("Selecciona un cliente"); + return; + } + if (!selectedPlanId) { + setError("Selecciona un plan"); + return; + } + if (!startDate || !endDate) { + setError("Selecciona las fechas"); + return; + } + + setError(null); + await onAssign({ + clientId: selectedClient.id, + planId: selectedPlanId, + startDate: new Date(startDate).toISOString(), + endDate: new Date(endDate).toISOString(), + }); + }; + + // Handle click outside to close + const handleOverlayClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose(); + } + }; + + return ( +
+ + +
+ Asignar Membresia + +
+
+ + + {error && ( +
+ {error} +
+ )} + + {/* Client Selection */} + {!preselectedClient && ( +
+ + setSearchQuery(e.target.value)} + autoFocus + /> + + {/* Client search results */} +
+ {loadingClients && ( +
+
+
+ )} + + {!loadingClients && searchQuery.length >= 2 && clients.length === 0 && ( +

+ No se encontraron clientes. +

+ )} + + {!loadingClients && + clients.map((client) => ( + + ))} +
+
+ )} + + {/* Selected client summary */} + {selectedClient && ( +
+
+
+

Cliente seleccionado:

+

+ {selectedClient.firstName} {selectedClient.lastName} +

+
+ {!preselectedClient && ( + + )} +
+
+ )} + + {/* Plan Selection */} +
+ + + + {/* Selected plan details */} + {selectedPlan && ( +
+
+ Precio: + + {formatCurrency(Number(selectedPlan.price))} + +
+ {selectedPlan.courtHours && ( +
+ Horas incluidas: + + {selectedPlan.courtHours}h + +
+ )} + {selectedPlan.discountPercent && Number(selectedPlan.discountPercent) > 0 && ( +
+ Descuento: + + {Number(selectedPlan.discountPercent)}% + +
+ )} +
+ )} +
+ + {/* Date Selection */} +
+
+ + setStartDate(e.target.value)} + min={new Date().toISOString().split("T")[0]} + /> +
+
+ + +
+
+ + {/* Calculated End Date */} + {endDate && ( +
+
+ + + +
+

La membresia expirara el:

+

{formatDate(endDate)}

+
+
+
+ )} + + + +
+ + +
+
+ +
+ ); +} diff --git a/apps/web/components/memberships/membership-table.tsx b/apps/web/components/memberships/membership-table.tsx new file mode 100644 index 0000000..8cbb59f --- /dev/null +++ b/apps/web/components/memberships/membership-table.tsx @@ -0,0 +1,278 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { formatCurrency, formatDate, cn } from "@/lib/utils"; + +interface Membership { + id: string; + startDate: string; + endDate: string; + status: "ACTIVE" | "EXPIRED" | "CANCELLED" | "SUSPENDED"; + remainingHours: number | null; + autoRenew: boolean; + isExpiring?: boolean; + daysUntilExpiry?: number | null; + plan: { + id: string; + name: string; + price: number | string; + durationMonths: number; + courtHours: number | null; + }; + client: { + id: string; + firstName: string; + lastName: string; + email: string | null; + phone: string | null; + }; + benefitsSummary?: { + freeHours: number; + hoursRemaining: number; + }; +} + +interface MembershipTableProps { + memberships: Membership[]; + onRenew?: (membership: Membership) => void; + onCancel?: (membership: Membership) => void; + onViewDetails?: (membership: Membership) => void; + isLoading?: boolean; +} + +const statusConfig: Record = { + ACTIVE: { + label: "Activa", + className: "bg-green-100 text-green-700 border-green-300", + }, + EXPIRED: { + label: "Expirada", + className: "bg-red-100 text-red-700 border-red-300", + }, + CANCELLED: { + label: "Cancelada", + className: "bg-gray-100 text-gray-700 border-gray-300", + }, + SUSPENDED: { + label: "Suspendida", + className: "bg-yellow-100 text-yellow-700 border-yellow-300", + }, +}; + +export function MembershipTable({ + memberships, + onRenew, + onCancel, + onViewDetails, + isLoading = false, +}: MembershipTableProps) { + if (isLoading) { + return ( +
+
+
+

Cargando membresias...

+
+
+ ); + } + + if (memberships.length === 0) { + return ( +
+
+ + + +

No hay membresias

+

Asigna una membresia a un cliente para comenzar

+
+
+ ); + } + + return ( +
+ + + + + + + + + + + + + + {memberships.map((membership) => { + const status = statusConfig[membership.status] || statusConfig.ACTIVE; + const hoursTotal = membership.plan.courtHours || membership.benefitsSummary?.freeHours || 0; + const hoursRemaining = membership.remainingHours ?? membership.benefitsSummary?.hoursRemaining ?? 0; + const hoursUsed = hoursTotal - hoursRemaining; + + return ( + + {/* Client */} + + + {/* Plan */} + + + {/* Start Date */} + + + {/* End Date */} + + + {/* Hours Used */} + + + {/* Status */} + + + {/* Actions */} + + + ); + })} + +
+ Cliente + + Plan + + Inicio + + Fin + + Horas Usadas + + Estado + + Acciones +
+
+

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

+

+ {membership.client.email || membership.client.phone || "Sin contacto"} +

+
+
+ + {membership.plan.name} + + + {formatDate(membership.startDate)} + +
+

+ {formatDate(membership.endDate)} +

+ {membership.isExpiring && membership.status === "ACTIVE" && ( +

+ + + + Expira en {membership.daysUntilExpiry} dias +

+ )} +
+
+ {hoursTotal > 0 ? ( +
+

+ {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)}%` }} + /> +
+
+ ) : ( + - + )} +
+ + {status.label} + + +
+ {membership.status === "ACTIVE" && ( + <> + + + + )} + {(membership.status === "EXPIRED" || membership.status === "CANCELLED") && ( + + )} +
+
+
+ ); +} diff --git a/apps/web/components/memberships/plan-card.tsx b/apps/web/components/memberships/plan-card.tsx new file mode 100644 index 0000000..f2a6fa9 --- /dev/null +++ b/apps/web/components/memberships/plan-card.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { formatCurrency, cn } from "@/lib/utils"; + +interface MembershipPlan { + id: string; + name: string; + description: string | null; + price: number | string; + durationMonths: number; + courtHours: number | null; + discountPercent: number | string | null; + benefits: string[] | null; + isActive: boolean; + subscriberCount: number; + benefitsSummary?: { + freeHours: number; + bookingDiscount: number; + extraBenefits: string[]; + }; +} + +interface PlanCardProps { + plan: MembershipPlan; + onEdit?: (plan: MembershipPlan) => void; + onDelete?: (plan: MembershipPlan) => void; + isAdmin?: boolean; +} + +export function PlanCard({ plan, onEdit, onDelete, isAdmin = false }: PlanCardProps) { + const price = typeof plan.price === "string" ? parseFloat(plan.price) : plan.price; + const discountPercent = plan.benefitsSummary?.bookingDiscount ?? + (plan.discountPercent ? Number(plan.discountPercent) : 0); + const freeHours = plan.benefitsSummary?.freeHours ?? plan.courtHours ?? 0; + const extraBenefits = plan.benefitsSummary?.extraBenefits ?? plan.benefits ?? []; + + // Extract store discount from benefits array if present + const storeDiscountBenefit = extraBenefits.find(b => b.includes("store discount")); + const storeDiscount = storeDiscountBenefit + ? parseInt(storeDiscountBenefit.match(/(\d+)%/)?.[1] || "0", 10) + : 0; + const otherBenefits = extraBenefits.filter(b => !b.includes("store discount")); + + return ( + + +
+ {plan.name} + 0 + ? "bg-accent-100 text-accent-700" + : "bg-primary-100 text-primary-600" + )}> + {plan.subscriberCount} {plan.subscriberCount === 1 ? "suscriptor" : "suscriptores"} + +
+ {plan.description && ( +

{plan.description}

+ )} +
+ + + {/* Price */} +
+
+ {formatCurrency(price)} +
+
+ /{plan.durationMonths} {plan.durationMonths === 1 ? "mes" : "meses"} +
+
+ + {/* Key Benefits */} +
+ {/* Free Hours */} + {freeHours > 0 && ( +
+
+ + + +
+
+

{freeHours} horas gratis

+

de cancha al mes

+
+
+ )} + + {/* Booking Discount */} + {discountPercent > 0 && ( +
+
+ + + +
+
+

{discountPercent}% descuento

+

en reservas adicionales

+
+
+ )} + + {/* Store Discount */} + {storeDiscount > 0 && ( +
+
+ + + +
+
+

{storeDiscount}% descuento

+

en tienda

+
+
+ )} + + {/* Other Benefits */} + {otherBenefits.length > 0 && ( +
+

Beneficios adicionales:

+
    + {otherBenefits.map((benefit, index) => ( +
  • + + + + {benefit} +
  • + ))} +
+
+ )} +
+
+ + {isAdmin && ( + + + + + )} +
+ ); +} diff --git a/apps/web/components/memberships/plan-form.tsx b/apps/web/components/memberships/plan-form.tsx new file mode 100644 index 0000000..5e2fc0d --- /dev/null +++ b/apps/web/components/memberships/plan-form.tsx @@ -0,0 +1,314 @@ +"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 PlanFormData { + name: string; + description: string; + price: number; + durationMonths: number; + freeHours: number; + bookingDiscount: number; + storeDiscount: number; + extraBenefits: string; +} + +interface MembershipPlan { + id: string; + name: string; + description: string | null; + price: number | string; + durationMonths: number; + courtHours: number | null; + discountPercent: number | string | null; + benefits: string[] | null; + benefitsSummary?: { + freeHours: number; + bookingDiscount: number; + extraBenefits: string[]; + }; +} + +interface PlanFormProps { + initialData?: MembershipPlan; + onSubmit: (data: PlanFormData) => Promise; + onCancel: () => void; + isLoading?: boolean; + mode?: "create" | "edit"; +} + +const durationOptions = [ + { value: 1, label: "1 mes" }, + { value: 3, label: "3 meses" }, + { value: 6, label: "6 meses" }, + { value: 12, label: "12 meses" }, +]; + +export function PlanForm({ + initialData, + onSubmit, + onCancel, + isLoading = false, + mode = "create", +}: PlanFormProps) { + // Extract store discount from benefits if present + const extractStoreDiscount = (benefits: string[] | null): number => { + if (!benefits) return 0; + const storeDiscountBenefit = benefits.find(b => b.includes("store discount")); + if (storeDiscountBenefit) { + const match = storeDiscountBenefit.match(/(\d+)%/); + return match ? parseInt(match[1], 10) : 0; + } + return 0; + }; + + const getOtherBenefits = (benefits: string[] | null): string => { + if (!benefits) return ""; + return benefits + .filter(b => !b.includes("store discount")) + .join("\n"); + }; + + const [formData, setFormData] = useState({ + name: initialData?.name || "", + description: initialData?.description || "", + price: initialData?.price ? Number(initialData.price) : 0, + durationMonths: initialData?.durationMonths || 1, + freeHours: initialData?.benefitsSummary?.freeHours ?? initialData?.courtHours ?? 0, + bookingDiscount: initialData?.benefitsSummary?.bookingDiscount ?? + (initialData?.discountPercent ? Number(initialData.discountPercent) : 0), + storeDiscount: extractStoreDiscount(initialData?.benefits || null), + extraBenefits: getOtherBenefits(initialData?.benefitsSummary?.extraBenefits || initialData?.benefits || null), + }); + const [errors, setErrors] = useState>({}); + + const handleChange = ( + e: React.ChangeEvent + ) => { + const { name, value, type } = e.target; + setFormData((prev) => ({ + ...prev, + [name]: type === "number" ? parseFloat(value) || 0 : value, + })); + // Clear error when field is modified + if (errors[name]) { + setErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors[name]; + return newErrors; + }); + } + }; + + const validate = (): boolean => { + const newErrors: Record = {}; + + if (!formData.name.trim()) { + newErrors.name = "El nombre es requerido"; + } + if (formData.price <= 0) { + newErrors.price = "El precio debe ser mayor a 0"; + } + if (formData.bookingDiscount < 0 || formData.bookingDiscount > 100) { + newErrors.bookingDiscount = "El descuento debe estar entre 0 y 100"; + } + if (formData.storeDiscount < 0 || formData.storeDiscount > 100) { + newErrors.storeDiscount = "El descuento debe estar entre 0 y 100"; + } + if (formData.freeHours < 0) { + newErrors.freeHours = "Las horas gratis no pueden ser negativas"; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!validate()) return; + await onSubmit(formData); + }; + + return ( + +
+ + + {mode === "create" ? "Nuevo Plan de Membresia" : "Editar Plan"} + + + + {/* Name */} +
+ + + {errors.name && ( +

{errors.name}

+ )} +
+ + {/* Description */} +
+ +