import { useState, useRef, type ReactNode } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useParams, useNavigate, Link } from '@tanstack/react-router'; import type { AttendeeDto, CreateAttendee, CreateAttendeeResult, CreateGroupWaitlistEntry, GroupWaitlistEntryDto, GroupWaitlistList, } from '@gruperly/shared'; import { ArrowLeft, CalendarClock, Check, ChevronRight, Clock, Copy, Download, FileSpreadsheet, Loader2, Mail, MessageCircle, Phone, Plus, RefreshCw, Search, Share2, ShieldCheck, StickyNote, Trash2, UploadCloud, UserCheck, UserMinus, UserPlus, Users, } from 'lucide-react'; import { Badge, Button, Input, Label, Modal, useToast } from '../components/ui'; import { addToGroupWaitlist, ApiError, bulkCreateAttendees, createAttendee, getGroup, getGroupAttendees, getGroupWaitlist, getInviteToken, promoteGroupWaitlistEntry, removeGroupAttendee, removeGroupWaitlistEntry, } 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 [selectedAttendee, setSelectedAttendee] = useState(null); const [activeTab, setActiveTab] = useState<'quick' | 'bulk'>('quick'); const [listSection, setListSection] = useState<'members' | 'waitlist'>('members'); const [attendeeToRemove, setAttendeeToRemove] = useState(null); const [selectedWaitlistEntry, setSelectedWaitlistEntry] = useState(null); // 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); // Capacity & waitlist state const [isCapacityModalOpen, setIsCapacityModalOpen] = useState(false); const [pendingCapacityPayload, setPendingCapacityPayload] = useState(null); const [isBulkCapacityModalOpen, setIsBulkCapacityModalOpen] = useState(false); // 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), }); const waitlistQuery = useQuery({ queryKey: ['group-waitlist', groupId], queryFn: () => getGroupWaitlist(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 resetQuickForm = () => { setFirstName(''); setLastName(''); setPhone(''); setEmail(''); setNotes(''); }; const closeAddAttendeeFlow = () => { resetQuickForm(); setPendingCapacityPayload(null); setIsCapacityModalOpen(false); setIsAddAttendeeModalOpen(false); }; const handleCreateSuccess = (result: CreateAttendeeResult) => { if (result.outcome === 'created') { toast.success(`Miembro ${result.attendee.fullName} agregado con éxito.`); } else { toast.info(result.message); } queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] }); closeAddAttendeeFlow(); }; const createAttendeeMutation = useMutation({ mutationFn: (payload: CreateAttendee) => createAttendee(groupId, payload), onMutate: (payload) => setPendingCapacityPayload(payload), onSuccess: handleCreateSuccess, onError: (err: Error) => { if (err instanceof ApiError && err.problem?.code === 'group_capacity_reached') { setIsCapacityModalOpen(true); return; } toast.error(err.message || 'Error al agregar miembro.'); }, }); // Force add (owner overrides the full capacity) const createAttendeeForceMutation = useMutation({ mutationFn: (payload: CreateAttendee) => createAttendee(groupId, payload, { allowOverflow: true }), onSuccess: handleCreateSuccess, onError: (err: Error) => { toast.error(err.message || 'Error al agregar miembro.'); }, }); // Add to group waitlist const addToWaitlistMutation = useMutation({ mutationFn: (payload: CreateGroupWaitlistEntry) => addToGroupWaitlist(groupId, payload), onMutate: async (payload: CreateGroupWaitlistEntry) => { await queryClient.cancelQueries({ queryKey: ['group-waitlist', groupId] }); const previousWaitlist = queryClient.getQueryData(['group-waitlist', groupId]); if (previousWaitlist) { const optimisticEntry: GroupWaitlistEntryDto = { id: `temp-${Date.now()}`, groupId, fullName: `${payload.firstName} ${payload.lastName ?? ''}`.trim(), phone: payload.phone, email: payload.email || null, notes: payload.notes ?? null, status: 'PENDING', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; queryClient.setQueryData(['group-waitlist', groupId], { data: [optimisticEntry, ...previousWaitlist.data], pagination: { ...previousWaitlist.pagination, total: previousWaitlist.pagination.total + 1, }, }); } return { previousWaitlist }; }, onSuccess: (entry) => { toast.info(`${entry.fullName} fue agregado a la lista de espera del grupo.`); queryClient.invalidateQueries({ queryKey: ['group-waitlist', groupId] }); queryClient.invalidateQueries({ queryKey: ['group', groupId] }); closeAddAttendeeFlow(); }, onError: (err: Error, _payload, context) => { if (context?.previousWaitlist) { queryClient.setQueryData(['group-waitlist', groupId], context.previousWaitlist); } if (err instanceof ApiError && err.problem?.code === 'already_waitlisted') { toast.info('Este número ya está en la lista de espera del grupo.'); } else { toast.error(err.message || 'Error al agregar a la lista de espera.'); } setPendingCapacityPayload(null); setIsCapacityModalOpen(false); }, }); // 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 miembros.'); }, }); // Waitlist & removal mutations const invalidateGroupQueries = () => { queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] }); queryClient.invalidateQueries({ queryKey: ['group-waitlist', groupId] }); queryClient.invalidateQueries({ queryKey: ['group', groupId] }); }; const removeAttendeeMutation = useMutation({ mutationFn: (payload: { attendeeId: string; promoteFromWaitlist: boolean }) => removeGroupAttendee(groupId, payload.attendeeId, { promoteFromWaitlist: payload.promoteFromWaitlist }), onSuccess: (result, payload) => { const removedName = attendees.find((a) => a.id === payload.attendeeId)?.fullName ?? 'El miembro'; if (result.promoted) { toast.success(`${removedName} fue quitado del grupo y ${result.promoted.fullName} pasó de la lista de espera al grupo.`); } else { toast.success(`${removedName} fue quitado del grupo.`); } invalidateGroupQueries(); setSelectedAttendee(null); setAttendeeToRemove(null); }, onError: (err: Error) => { if (err instanceof ApiError && err.problem?.code === 'attendee_has_payments') { toast.error(err.problem.detail ?? 'Este miembro tiene cobros asociados.'); } else { toast.error(err.message || 'Error al quitar al miembro del grupo.'); } setAttendeeToRemove(null); }, }); const promoteWaitlistMutation = useMutation({ mutationFn: (entryId: string) => promoteGroupWaitlistEntry(groupId, entryId), onSuccess: (result) => { toast.success(`${result.attendee.fullName} pasó de la lista de espera al grupo.`); invalidateGroupQueries(); }, onError: (err: Error) => { if (err instanceof ApiError && err.problem?.code === 'group_capacity_reached') { toast.error('El grupo alcanzó su cupo. Quita un miembro o aumenta el cupo primero.'); } else { toast.error(err.message || 'No se pudo pasar al miembro al grupo.'); } }, }); const removeWaitlistEntryMutation = useMutation({ mutationFn: (entry: GroupWaitlistEntryDto) => removeGroupWaitlistEntry(groupId, entry.id), onSuccess: (_result, entry) => { toast.success(`${entry.fullName} fue quitado de la lista de espera.`); invalidateGroupQueries(); }, onError: (err: Error) => { toast.error(err.message || 'No se pudo quitar de la lista de espera.'); }, }); 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 handleCopyMessage = async () => { if (!whatsappMessage) return; try { await navigator.clipboard.writeText(whatsappMessage); toast.success('¡Mensaje copiado al portapapeles!'); } catch { toast.error('No se pudo copiar automáticamente. Copia el texto manualmente.'); } }; const handleOpenWhatsApp = async () => { if (!whatsappShareUrl) return; try { if (whatsappMessage) { await navigator.clipboard.writeText(whatsappMessage); toast.success('¡Abriendo WhatsApp! Mensaje copiado al portapapeles.'); } } catch { // Ignorar si clipboard falla } window.open(whatsappShareUrl, '_blank', 'noopener,noreferrer'); }; 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 doBulkImport = () => { const validRows = parsedContacts.filter((c) => c.isValid); if (validRows.length === 0) { toast.error('No hay miembros 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 handleConfirmBulkImport = () => { if (validRowsCount === 0) { toast.error('No hay miembros válidos para importar.'); return; } if (group?.capacity != null && attendeesTotal + validRowsCount > group.capacity) { setIsBulkCapacityModalOpen(true); return; } doBulkImport(); }; const group = groupQuery.data; const attendees = attendeesQuery.data?.data ?? []; const attendeesTotal = attendeesQuery.data?.pagination?.total ?? attendees.length; const waitlistEntries = waitlistQuery.data?.data ?? []; const waitlistTotal = waitlistQuery.data?.pagination?.total ?? waitlistEntries.length; const firstWaitlistEntry = waitlistEntries[0] ?? null; const hasFreeCapacity = group?.capacity == null || attendeesTotal < group.capacity; 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 whatsappMessage = group && inviteTokenQuery.data?.inviteUrl ? `¡Hola! 👋 Te invito a unirte al grupo *${group.name}*.\n\nCompleta tus datos de inscripción en el siguiente enlace:\n${inviteTokenQuery.data.inviteUrl}` : ''; const whatsappShareUrl = whatsappMessage ? `https://wa.me/?text=${encodeURIComponent(whatsappMessage)}` : ''; 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'}

Miembros & Cupo

{attendees.length} inscritos {waitlistTotal > 0 ? ` · ${waitlistTotal} en espera` : ''} {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}` : ''}

{/* Miembros & Lista de espera */}
{listSection === 'members' ? (
setSearchFilter(e.target.value)} className="pl-9 h-9 text-xs" />
) : null}

{listSection === 'members' ? 'Listado de todos los miembros incorporados al grupo.' : 'Personas que esperan un cupo libre en el grupo. Al pasar a alguien al grupo, se crea automáticamente su registro como miembro.'}

{listSection === 'members' ? ( <> {attendeesQuery.isPending ? (
) : null} {attendeesQuery.isSuccess && attendees.length === 0 ? (

Todavía no hay miembros en este grupo

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

) : null} {attendeesQuery.isSuccess && attendees.length > 0 && filteredAttendees.length === 0 ? (
No se encontraron miembros que coincidan con la búsqueda.
) : null} {filteredAttendees.length > 0 ? (
{filteredAttendees.map((attendee) => ( setSelectedAttendee(attendee)} > ))}
Nombre Teléfono
{attendee.fullName.charAt(0).toUpperCase()}
{attendee.fullName}
{attendee.phone || —}
) : null} ) : (
{waitlistQuery.isPending ? (
) : null} {waitlistQuery.isSuccess && waitlistEntries.length === 0 ? (

No hay nadie en la lista de espera

Cuando el grupo alcance su cupo, las personas podrán sumarse a la espera y podrás pasarlas al grupo desde aquí.

) : null} {waitlistEntries.length > 0 ? (
{waitlistEntries.map((entry) => ( setSelectedWaitlistEntry(entry)} > ))}
Nombre Teléfono
{entry.fullName.charAt(0).toUpperCase()}

{entry.fullName}

{entry.notes ? (

{entry.notes}

) : null}
{entry.phone}
) : null}
)}
{/* MODAL: COMPARTIR LINK DE INVITACION */} setIsInviteModalOpen(false)} title="Compartir Link de Invitación" description="Envía este enlace a tus miembros 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 al grupo `} {group.name}. {'\n\n'} Completa tus datos de inscripción en el siguiente enlace: {'\n'} {inviteTokenQuery.data?.inviteUrl}

💡 Al hacer clic en Abrir en WhatsApp, el mensaje completo se copia automáticamente al portapapeles. Si WhatsApp Web solo carga el enlace, puedes pegarlo directamente con Ctrl+V o Cmd+V.

)}
{/* 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}
{/* MODAL: CUPO ALCANZADO (ALTA INDIVIDUAL) */} { setPendingCapacityPayload(null); setIsCapacityModalOpen(false); }} title="Cupo alcanzado" description="El grupo llegó a su cupo máximo de miembros." maxWidth="md" > {pendingCapacityPayload ? (

El grupo {group.name} ya alcanzó su cupo de{' '} {group.capacity} miembros. ¿Qué deseas hacer con{' '} {`${pendingCapacityPayload.firstName.trim()} ${pendingCapacityPayload.lastName.trim()}`.trim()} ?

Si eliges agregarlo de todos modos, el grupo quedará por encima de su cupo.

) : null}
{/* MODAL: AVISO DE CUPO EN CARGA MASIVA */} setIsBulkCapacityModalOpen(false)} title="Aviso de cupo" description="La carga supera el cupo del grupo." maxWidth="md" >

Estás por importar {validRowsCount} miembro(s), pero el grupo ya tiene {attendeesTotal} inscrito(s) y su cupo es de{' '} {group.capacity}. ¿Deseas importarlos de todos modos?

{/* MODAL: DETALLE DEL PARTICIPANTE */} setSelectedAttendee(null)} title="Detalle del Participante" maxWidth="sm" > {selectedAttendee ? (
{/* Header: avatar + name */}
{selectedAttendee.fullName.charAt(0).toUpperCase()}

{selectedAttendee.fullName}

Incorporado el{' '} {new Date(selectedAttendee.createdAt).toLocaleDateString('es-ES', { day: 'numeric', month: 'long', year: 'numeric', })}

{/* Info rows */}
} label="Teléfono" > {selectedAttendee.phone ? (
{selectedAttendee.phone} e.stopPropagation()} > WhatsApp
) : ( Sin teléfono )}
} label="Email" > {selectedAttendee.email ? ( e.stopPropagation()} > {selectedAttendee.email} ) : ( Sin email )} {(selectedAttendee.guardianName || selectedAttendee.guardianPhone) ? ( <> } label="Responsable" > {selectedAttendee.guardianName || —} {selectedAttendee.guardianPhone ? ( } label="Tel. Responsable" >
{selectedAttendee.guardianPhone} e.stopPropagation()} >
) : null} ) : null} } label="Notas" > {selectedAttendee.notes ? ( {selectedAttendee.notes} ) : ( Sin notas )}
) : null}
{/* MODAL: CONFIRMAR QUITAR MIEMBRO */} setAttendeeToRemove(null)} title="Quitar del grupo" maxWidth="md" > {attendeeToRemove ? (

{attendeeToRemove.fullName} dejará de ser miembro del grupo{' '} {group.name} {firstWaitlistEntry ? ' y el cupo quedará libre.' : '.'} {attendeeToRemove.phone ? ( Teléfono: {attendeeToRemove.phone} ) : null}

{firstWaitlistEntry ? (
Hay {waitlistTotal} persona{waitlistTotal === 1 ? '' : 's'} esperando un cupo

¿Quieres pasar a{' '} {firstWaitlistEntry.fullName}, el primero de la lista de espera, al grupo?

) : (
)}
) : null}
{/* MODAL: DETALLE LISTA DE ESPERA */} setSelectedWaitlistEntry(null)} title="Detalle de la lista de espera" maxWidth="sm" > {selectedWaitlistEntry ? (
{/* Header: avatar + name */}
{selectedWaitlistEntry.fullName.charAt(0).toUpperCase()}

{selectedWaitlistEntry.fullName}

En espera desde el{' '} {new Date(selectedWaitlistEntry.createdAt).toLocaleDateString('es-ES', { day: 'numeric', month: 'long', year: 'numeric', })}

{/* Info rows */}
} label="Teléfono"> {selectedWaitlistEntry.phone ? (
{selectedWaitlistEntry.phone} e.stopPropagation()} > WhatsApp
) : ( Sin teléfono )}
{selectedWaitlistEntry.email ? ( } label="Email"> e.stopPropagation()} > {selectedWaitlistEntry.email} ) : null} } label="Notas"> {selectedWaitlistEntry.notes ? ( {selectedWaitlistEntry.notes} ) : ( Sin notas )}
{/* Footer: status + actions */} {!hasFreeCapacity ? (

El grupo alcanzó su cupo de miembros. Quita un miembro o aumenta el cupo para poder pasar a esta persona al grupo.

) : null}
) : null}
); } function DetailRow({ icon, label, children }: { icon: ReactNode; label: string; children: ReactNode }) { return (
{icon}

{label}

{children}
); }