import { useState, useRef } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useParams, useNavigate, Link } from '@tanstack/react-router'; import { ArrowLeft, CalendarClock, Check, Copy, Download, FileSpreadsheet, Loader2, MessageCircle, Plus, RefreshCw, Search, Share2, Trash2, UploadCloud, UserPlus, Users, } from 'lucide-react'; import type { CreateAttendee } from '@gruperly/shared'; import { Badge, Button, Input, Label, Modal, useToast } from '../components/ui'; import { bulkCreateAttendees, createAttendee, getGroup, getGroupAttendees, getInviteToken, } from '../lib/api'; import { downloadAttendeeTemplateCsv, normalizeRowsToContacts, parseCsvContent, parseXlsxContent, type ParsedContactRow, } from '../lib/file-parser'; import { BILLING_LABELS, formatPrice, formatSchedule } from '../lib/format'; export function GroupDetailView() { const { groupId } = useParams({ strict: false }) as { groupId: string }; const navigate = useNavigate(); const queryClient = useQueryClient(); const toast = useToast(); // Modals & Tabs const [isInviteModalOpen, setIsInviteModalOpen] = useState(false); const [isAddAttendeeModalOpen, setIsAddAttendeeModalOpen] = useState(false); const [activeTab, setActiveTab] = useState<'quick' | 'bulk'>('quick'); // Quick form state const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); const [phone, setPhone] = useState(''); const [email, setEmail] = useState(''); const [notes, setNotes] = useState(''); // Bulk upload state const [parsedContacts, setParsedContacts] = useState([]); const [fileName, setFileName] = useState(null); const [isParsingFile, setIsParsingFile] = useState(false); const [isDragging, setIsDragging] = useState(false); const fileInputRef = useRef(null); // Attendees search filter const [searchFilter, setSearchFilter] = useState(''); // Queries const groupQuery = useQuery({ queryKey: ['group', groupId], queryFn: () => getGroup(groupId), enabled: Boolean(groupId), }); const inviteTokenQuery = useQuery({ queryKey: ['invite-token', groupId], queryFn: () => getInviteToken(groupId), enabled: Boolean(groupId) && isInviteModalOpen, }); const attendeesQuery = useQuery({ queryKey: ['group-attendees', groupId], queryFn: () => getGroupAttendees(groupId, 1, 100), enabled: Boolean(groupId), }); // Regenerate invite token mutation const regenerateTokenMutation = useMutation({ mutationFn: () => getInviteToken(groupId, true), onSuccess: (data) => { queryClient.setQueryData(['invite-token', groupId], data); toast.success('Se generó un nuevo enlace de invitación.'); }, onError: () => { toast.error('No se pudo regenerar el enlace de invitación.'); }, }); // Quick add attendee mutation const createAttendeeMutation = useMutation({ mutationFn: (payload: CreateAttendee) => createAttendee(groupId, payload), onSuccess: (created) => { toast.success(`Alumno ${created.fullName} agregado con éxito.`); queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] }); setFirstName(''); setLastName(''); setPhone(''); setEmail(''); setNotes(''); setIsAddAttendeeModalOpen(false); }, onError: (err: Error) => { toast.error(err.message || 'Error al agregar alumno.'); }, }); // Bulk import mutation const bulkImportMutation = useMutation({ mutationFn: (attendees: Array<{ firstName: string; lastName: string; fullName: string; phone: string; email?: string; notes?: string }>) => bulkCreateAttendees(groupId, { attendees }), onSuccess: (res) => { toast.success(res.message); queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] }); setParsedContacts([]); setFileName(null); setIsAddAttendeeModalOpen(false); }, onError: (err: Error) => { toast.error(err.message || 'Error al importar alumnos.'); }, }); const handleCopyLink = async () => { const url = inviteTokenQuery.data?.inviteUrl; if (!url) return; try { await navigator.clipboard.writeText(url); toast.success('¡Enlace de invitación copiado al portapapeles!'); } catch { toast.error('No se pudo copiar automáticamente. Copia el texto manualmente.'); } }; const handleQuickSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!firstName.trim() || !phone.trim()) { toast.error('Nombre y teléfono son obligatorios.'); return; } createAttendeeMutation.mutate({ firstName: firstName.trim(), lastName: lastName.trim(), phone: phone.trim(), email: email.trim() || undefined, notes: notes.trim() || undefined, }); }; const processFile = async (file: File) => { setIsParsingFile(true); setFileName(file.name); try { let rawRows: string[][] = []; const lower = file.name.toLowerCase(); if (lower.endsWith('.csv') || lower.endsWith('.txt')) { const text = await file.text(); rawRows = parseCsvContent(text); } else if (lower.endsWith('.xlsx') || lower.endsWith('.xls')) { const buffer = await file.arrayBuffer(); rawRows = await parseXlsxContent(buffer); } else { toast.error('Formato no compatible. Sube un archivo .csv o .xlsx'); setIsParsingFile(false); return; } const contacts = normalizeRowsToContacts(rawRows); if (contacts.length === 0) { toast.error('No se detectaron contactos en el archivo.'); } else { setParsedContacts(contacts); const validCount = contacts.filter((c) => c.isValid).length; toast.info(`Se detectaron ${contacts.length} filas (${validCount} válidas).`); } } catch (err: unknown) { const message = err instanceof Error ? err.message : 'Error al procesar el archivo.'; toast.error(message); } finally { setIsParsingFile(false); } }; const handleFileDrop = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { void processFile(e.dataTransfer.files[0]); } }; const handleFileSelect = (e: React.ChangeEvent) => { if (e.target.files && e.target.files.length > 0) { void processFile(e.target.files[0]); } }; const removeContactRow = (id: string) => { setParsedContacts((prev) => prev.filter((c) => c.id !== id)); }; const handleConfirmBulkImport = () => { const validRows = parsedContacts.filter((c) => c.isValid); if (validRows.length === 0) { toast.error('No hay alumnos válidos para importar.'); return; } bulkImportMutation.mutate( validRows.map((c) => ({ firstName: c.firstName, lastName: c.lastName, fullName: c.fullName, phone: c.phone, email: c.email || undefined, notes: c.notes || undefined, })), ); }; const group = groupQuery.data; const attendees = attendeesQuery.data?.data ?? []; const filteredAttendees = attendees.filter((a) => { const q = searchFilter.toLowerCase(); return ( a.fullName.toLowerCase().includes(q) || (a.phone && a.phone.includes(q)) || (a.email && a.email.toLowerCase().includes(q)) ); }); const validRowsCount = parsedContacts.filter((c) => c.isValid).length; const invalidRowsCount = parsedContacts.length - validRowsCount; const whatsappShareUrl = group && inviteTokenQuery.data?.inviteUrl ? `https://wa.me/?text=${encodeURIComponent( `¡Hola! Te invito a unirte a nuestro grupo "${group.name}". Completa tus datos de inscripción en el siguiente enlace: ${inviteTokenQuery.data.inviteUrl}`, )}` : ''; if (groupQuery.isPending) { return (
); } if (groupQuery.isError || !group) { return (

Grupo no encontrado

No se pudo cargar la información de este grupo.

); } return (
{/* Navigation & Header */}
Volver a grupos

{group.name}

Activo
{group.description ? (

{group.description}

) : null}
{/* Group Details Card */}

Horario

{(group.days?.length ?? 0) > 0 ? formatSchedule(group.days ?? [], group.time) : 'Sin horario definido'}

Alumnos & Cupo

{attendees.length} inscritos {group.capacity ? `· Cupo de ${group.capacity}` : ''}

$

Cobro

{group.price != null ? formatPrice(group.price) : 'Sin precio'} {group.billingType ? ` · ${BILLING_LABELS[group.billingType]}` : ''} {group.dueDay ? ` · Vence día ${group.dueDay}` : ''}

{/* Attendees Management Section */}

Miembros del Grupo ({attendees.length})

Listado de todos los miembros incorporados al grupo.

setSearchFilter(e.target.value)} className="pl-9 h-9 text-xs" />
{attendeesQuery.isPending ? (
) : null} {attendeesQuery.isSuccess && attendees.length === 0 ? (

Todavía no hay alumnos en este grupo

Puedes compartir el enlace de invitación único o agregar alumnos de forma manual o masiva.

) : null} {attendeesQuery.isSuccess && attendees.length > 0 && filteredAttendees.length === 0 ? (
No se encontraron alumnos que coincidan con la búsqueda.
) : null} {filteredAttendees.length > 0 ? (
{filteredAttendees.map((attendee) => { const cleanPhone = attendee.phone?.replace(/\D/g, '') ?? ''; return ( ); })}
Alumno WhatsApp / Teléfono Email Notas Incorporado
{attendee.fullName.charAt(0).toUpperCase()}
{attendee.fullName}
{attendee.phone ? ( {attendee.phone} ) : ( — )} {attendee.email || —} {attendee.notes || —} {new Date(attendee.createdAt).toLocaleDateString('es-ES')}
) : null}
{/* MODAL: COMPARTIR LINK DE INVITACION */} setIsInviteModalOpen(false)} title="Compartir Link de Invitación" description="Envía este enlace a tus alumnos para que completen sus datos e ingresen directamente al grupo." maxWidth="md" >
{inviteTokenQuery.isPending ? (
) : ( <>

Vista previa del mensaje para WhatsApp:

“¡Hola! Te invito a unirte a nuestro grupo {group.name}. Completa tus datos de inscripción en el siguiente enlace: {inviteTokenQuery.data?.inviteUrl}”

)}
{/* MODAL: AGREGAR ALUMNOS (CARGA INDIVIDUAL / MASIVA) */} setIsAddAttendeeModalOpen(false)} title="Agregar Miembros al Grupo" description="Agrega miembros rápidamente completando sus datos o importa una lista de contactos." maxWidth="lg" >
{/* Tabs Selector */}
{/* TAB 1: CARGA RAPIDA */} {activeTab === 'quick' ? (
setFirstName(e.target.value)} required />
setLastName(e.target.value)} />
setPhone(e.target.value)} required />

Usado para contacto y validación de duplicados.

setEmail(e.target.value)} />
setNotes(e.target.value)} />
) : null} {/* TAB 2: CARGA MASIVA */} {activeTab === 'bulk' ? (
{/* Dropzone */}
{ e.preventDefault(); setIsDragging(true); }} onDragLeave={() => setIsDragging(false)} onDrop={handleFileDrop} onClick={() => fileInputRef.current?.click()} className={`relative flex flex-col items-center justify-center p-6 rounded-xl border-2 border-dashed cursor-pointer transition-all ${ isDragging ? 'border-accent bg-accent/5' : 'border-border hover:border-accent/60 bg-primary-soft/30' }`} >

Arrastra tu archivo aquí o haz clic para seleccionarlo

Formatos soportados: CSV (.csv) o Excel (.xlsx)

{fileName ? (
{fileName}
) : null}
{/* Template Download Link */}
¿No tienes el archivo listo? Usa nuestra plantilla.
{isParsingFile ? (
Leyendo y analizando archivo...
) : null} {/* Interactive Preview Table */} {parsedContacts.length > 0 ? (
Vista Previa: {validRowsCount} listos para importar {invalidRowsCount > 0 ? ( {invalidRowsCount} con errores ) : null}
{parsedContacts.map((c) => ( ))}
Nombre Teléfono Email Estado
{c.fullName || Sin nombre} {c.phone || Sin teléfono} {c.email || '—'} {c.isValid ? ( Válido ) : ( {c.error} )}
) : null}
) : null}
); }