Files
gruperly/apps/web/src/routes/group-detail.tsx
Jose Selesan fc30927b8b feat: manage waitlist and remove group members
- Add group-waitlist module (list/promote/remove entries)
- Remove attendee with optional atomic promotion from waitlist
- Block removal when attendee has payments (attendee_has_payments)
- Waitlist detail modal matching members UI; optimistic add-to-waitlist
- Responsive tweaks for the members/waitlist tabs
2026-09-22 17:04:41 -03:00

1609 lines
64 KiB
TypeScript

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<AttendeeDto | null>(null);
const [activeTab, setActiveTab] = useState<'quick' | 'bulk'>('quick');
const [listSection, setListSection] = useState<'members' | 'waitlist'>('members');
const [attendeeToRemove, setAttendeeToRemove] = useState<AttendeeDto | null>(null);
const [selectedWaitlistEntry, setSelectedWaitlistEntry] = useState<GroupWaitlistEntryDto | null>(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<ParsedContactRow[]>([]);
const [fileName, setFileName] = useState<string | null>(null);
const [isParsingFile, setIsParsingFile] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Capacity & waitlist state
const [isCapacityModalOpen, setIsCapacityModalOpen] = useState(false);
const [pendingCapacityPayload, setPendingCapacityPayload] = useState<CreateAttendee | null>(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<GroupWaitlistList>(['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<GroupWaitlistList>(['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<GroupWaitlistList>(['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<HTMLInputElement>) => {
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 (
<div className="flex items-center justify-center py-20">
<Loader2 className="size-8 animate-spin text-accent" />
</div>
);
}
if (groupQuery.isError || !group) {
return (
<div className="rounded-xl border border-danger/20 bg-danger-soft p-6 text-danger">
<h2 className="font-semibold text-lg">Grupo no encontrado</h2>
<p className="mt-2 text-sm">No se pudo cargar la información de este grupo.</p>
<Button variant="outline" className="mt-4" onClick={() => void navigate({ to: '/groups' })}>
Volver a Grupos
</Button>
</div>
);
}
return (
<section className="space-y-6 animate-fade-in">
{/* Navigation & Header */}
<div>
<Link
to="/groups"
className="hidden lg:inline-flex items-center gap-1 text-sm text-foreground/60 hover:text-primary transition-colors mb-3"
>
<ArrowLeft className="size-4" />
<span>Volver a grupos</span>
</Link>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<div className="flex items-center gap-2.5">
<h1 className="text-2xl font-bold text-primary">{group.name}</h1>
<Badge variant="success">Activo</Badge>
</div>
{group.description ? (
<p className="mt-1 text-sm text-foreground/70">{group.description}</p>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2.5">
<Button
variant="outline"
onClick={() => setIsInviteModalOpen(true)}
className="gap-2 border-border"
>
<Share2 className="size-4 text-accent" />
<span>Compartir Link</span>
</Button>
<Button
variant="primary"
onClick={() => setIsAddAttendeeModalOpen(true)}
className="gap-2"
>
<UserPlus className="size-4" />
<span>Agregar Miembros</span>
</Button>
</div>
</div>
</div>
{/* Group Details Card */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 rounded-xl border border-border bg-surface p-4 text-sm">
<div className="flex items-center gap-3">
<CalendarClock className="size-5 text-accent shrink-0" />
<div>
<p className="text-xs font-medium text-foreground/50 uppercase">Horario</p>
<p className="font-medium text-primary">
{(group.days?.length ?? 0) > 0 ? formatSchedule(group.days ?? [], group.time) : 'Sin horario definido'}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<Users className="size-5 text-accent shrink-0" />
<div>
<p className="text-xs font-medium text-foreground/50 uppercase">Miembros & Cupo</p>
<p className="font-medium text-primary">
{attendees.length} inscritos {waitlistTotal > 0 ? ` · ${waitlistTotal} en espera` : ''}
{group.capacity ? ` · Cupo de ${group.capacity}` : ''}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="size-5 flex items-center justify-center font-bold text-accent shrink-0">
$
</div>
<div>
<p className="text-xs font-medium text-foreground/50 uppercase">Cobro</p>
<p className="font-medium text-primary">
{group.price != null ? formatPrice(group.price) : 'Sin precio'}
{group.billingType ? ` · ${BILLING_LABELS[group.billingType]}` : ''}
{group.dueDay ? ` · Vence día ${group.dueDay}` : ''}
</p>
</div>
</div>
</div>
{/* Miembros & Lista de espera */}
<div className="rounded-xl border border-border bg-surface p-5 space-y-4">
<div className="flex flex-col gap-3">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-center rounded-xl bg-primary-soft p-1 w-full sm:w-[26rem]">
<button
type="button"
onClick={() => setListSection('members')}
className={`flex flex-1 items-center justify-center gap-1.5 py-2 text-xs font-semibold rounded-lg transition-all ${
listSection === 'members'
? 'bg-surface text-primary shadow-xs'
: 'text-foreground/60 hover:text-primary'
}`}
>
<Users className="size-4 shrink-0" />
<span className="whitespace-nowrap">Miembros ({attendeesTotal})</span>
</button>
<button
type="button"
onClick={() => setListSection('waitlist')}
className={`flex flex-1 items-center justify-center gap-1.5 py-2 text-xs font-semibold rounded-lg transition-all ${
listSection === 'waitlist'
? 'bg-surface text-primary shadow-xs'
: 'text-foreground/60 hover:text-primary'
}`}
>
<Clock className="size-4 shrink-0" />
<span className="whitespace-nowrap">Lista de espera ({waitlistTotal})</span>
</button>
</div>
{listSection === 'members' ? (
<div className="relative w-full sm:w-64">
<Search className="absolute left-3 top-2.5 size-4 text-foreground/40" />
<Input
placeholder="Buscar por nombre o teléfono..."
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
className="pl-9 h-9 text-xs"
/>
</div>
) : null}
</div>
<p className="text-xs text-foreground/60">
{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.'}
</p>
</div>
{listSection === 'members' ? (
<>
{attendeesQuery.isPending ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-6 animate-spin text-accent" />
</div>
) : null}
{attendeesQuery.isSuccess && attendees.length === 0 ? (
<div className="rounded-xl border border-dashed border-border py-12 px-4 text-center">
<Users className="size-10 text-foreground/30 mx-auto mb-2" />
<p className="font-medium text-primary">Todavía no hay miembros en este grupo</p>
<p className="text-sm text-foreground/60 mt-1 max-w-sm mx-auto">
Puedes compartir el enlace de invitación único o agregar miembros de forma manual o masiva.
</p>
<div className="mt-4 flex items-center justify-center gap-3">
<Button
variant="outline"
size="sm"
onClick={() => setIsInviteModalOpen(true)}
className="gap-2"
>
<Share2 className="size-4" />
Compartir Enlace
</Button>
<Button
variant="primary"
size="sm"
onClick={() => setIsAddAttendeeModalOpen(true)}
className="gap-2"
>
<Plus className="size-4" />
Agregar Miembros
</Button>
</div>
</div>
) : null}
{attendeesQuery.isSuccess && attendees.length > 0 && filteredAttendees.length === 0 ? (
<div className="py-8 text-center text-sm text-foreground/60">
No se encontraron miembros que coincidan con la búsqueda.
</div>
) : null}
{filteredAttendees.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="border-b border-border text-xs uppercase text-foreground/60 font-semibold">
<tr>
<th className="pb-3 px-3">Nombre</th>
<th className="pb-3 px-3">Teléfono</th>
<th className="pb-3 px-3 w-8" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{filteredAttendees.map((attendee) => (
<tr
key={attendee.id}
className="hover:bg-primary-soft/50 transition-colors cursor-pointer"
onClick={() => setSelectedAttendee(attendee)}
>
<td className="py-3 px-3">
<div className="flex items-center gap-2.5">
<div className="size-8 rounded-full bg-accent/10 text-accent font-semibold flex items-center justify-center text-xs">
{attendee.fullName.charAt(0).toUpperCase()}
</div>
<span className="font-medium text-primary">{attendee.fullName}</span>
</div>
</td>
<td className="py-3 px-3 text-foreground/70">
{attendee.phone || <span className="text-foreground/40">—</span>}
</td>
<td className="py-3 px-1 text-foreground/30">
<ChevronRight className="size-4" />
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</>
) : (
<div className="space-y-4">
{waitlistQuery.isPending ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-6 animate-spin text-accent" />
</div>
) : null}
{waitlistQuery.isSuccess && waitlistEntries.length === 0 ? (
<div className="rounded-xl border border-dashed border-border py-12 px-4 text-center">
<Clock className="size-10 text-foreground/30 mx-auto mb-2" />
<p className="font-medium text-primary">No hay nadie en la lista de espera</p>
<p className="text-sm text-foreground/60 mt-1 max-w-sm mx-auto">
Cuando el grupo alcance su cupo, las personas podrán sumarse a la espera y podrás pasarlas al grupo desde aquí.
</p>
</div>
) : null}
{waitlistEntries.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="border-b border-border text-xs uppercase text-foreground/60 font-semibold">
<tr>
<th className="pb-3 px-3">Nombre</th>
<th className="pb-3 px-3">Teléfono</th>
<th className="pb-3 px-3 w-8" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{waitlistEntries.map((entry) => (
<tr
key={entry.id}
className="hover:bg-primary-soft/50 transition-colors cursor-pointer"
onClick={() => setSelectedWaitlistEntry(entry)}
>
<td className="py-3 px-3">
<div className="flex items-center gap-2.5">
<div className="size-8 rounded-full bg-accent/10 text-accent font-semibold flex items-center justify-center text-xs">
{entry.fullName.charAt(0).toUpperCase()}
</div>
<div className="min-w-0">
<p className="font-medium text-primary truncate">{entry.fullName}</p>
{entry.notes ? (
<p className="text-xs text-foreground/50 truncate">{entry.notes}</p>
) : null}
</div>
</div>
</td>
<td className="py-3 px-3 text-foreground/70">
{entry.phone}
</td>
<td className="py-3 px-1 text-foreground/30">
<ChevronRight className="size-4" />
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</div>
)}
</div>
{/* MODAL: COMPARTIR LINK DE INVITACION */}
<Modal
isOpen={isInviteModalOpen}
onClose={() => 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"
>
<div className="space-y-5">
{inviteTokenQuery.isPending ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="size-6 animate-spin text-accent" />
</div>
) : (
<>
<div>
<Label htmlFor="invite-link-input" className="mb-1.5 block">
Enlace único del grupo
</Label>
<div className="flex items-center gap-2">
<Input
id="invite-link-input"
readOnly
value={inviteTokenQuery.data?.inviteUrl ?? ''}
className="font-mono text-xs bg-primary-soft/50 selection:bg-accent selection:text-white"
/>
<Button
variant="outline"
onClick={() => void handleCopyLink()}
className="shrink-0 gap-1.5 px-3"
title="Copiar al portapapeles"
>
<Copy className="size-4" />
<span>Copiar</span>
</Button>
</div>
</div>
<div className="rounded-xl bg-accent-soft p-4 text-xs text-foreground/80 space-y-2.5 border border-accent/20">
<div className="flex items-center justify-between gap-2">
<p className="font-semibold text-primary">Vista previa del mensaje para WhatsApp:</p>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => void handleCopyMessage()}
className="h-7 px-2 text-xs text-primary hover:text-primary/80 gap-1.5 shrink-0"
title="Copiar mensaje completo al portapapeles"
>
<Copy className="size-3.5" />
<span>Copiar mensaje</span>
</Button>
</div>
<div className="rounded-lg bg-surface/70 p-3 border border-accent/10 whitespace-pre-line font-sans text-xs leading-relaxed text-foreground">
{`¡Hola! 👋 Te invito a unirte al grupo `}
<strong>{group.name}</strong>.
{'\n\n'}
Completa tus datos de inscripción en el siguiente enlace:
{'\n'}
<span className="text-accent underline break-all">
{inviteTokenQuery.data?.inviteUrl}
</span>
</div>
</div>
<div className="flex flex-col sm:flex-row items-center gap-2.5 pt-2">
<Button
variant="primary"
onClick={() => void handleOpenWhatsApp()}
className="w-full sm:flex-1 gap-2 bg-[#25D366] hover:bg-[#1EBE5D] text-white border-0"
>
<MessageCircle className="size-4" />
<span>Abrir en WhatsApp</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => regenerateTokenMutation.mutate()}
disabled={regenerateTokenMutation.isPending}
className="w-full sm:w-auto text-xs text-foreground/60 hover:text-danger gap-1.5"
title="Invalida el enlace anterior y genera uno nuevo"
>
<RefreshCw
className={`size-3.5 ${regenerateTokenMutation.isPending ? 'animate-spin' : ''}`}
/>
<span>Regenerar enlace</span>
</Button>
</div>
<p className="text-[11px] text-foreground/50 text-center sm:text-left leading-normal">
💡 Al hacer clic en <strong>Abrir en WhatsApp</strong>, el mensaje completo se copia automáticamente al portapapeles. Si WhatsApp Web solo carga el enlace, puedes pegarlo directamente con <kbd className="px-1 py-0.5 rounded bg-primary-soft text-primary font-mono text-[10px]">Ctrl+V</kbd> o <kbd className="px-1 py-0.5 rounded bg-primary-soft text-primary font-mono text-[10px]">Cmd+V</kbd>.
</p>
</>
)}
</div>
</Modal>
{/* MODAL: AGREGAR ALUMNOS (CARGA INDIVIDUAL / MASIVA) */}
<Modal
isOpen={isAddAttendeeModalOpen}
onClose={() => setIsAddAttendeeModalOpen(false)}
title="Agregar Miembros al Grupo"
description="Agrega miembros rápidamente completando sus datos o importa una lista de contactos."
maxWidth="lg"
>
<div>
{/* Tabs Selector */}
<div className="flex items-center rounded-xl bg-primary-soft p-1 mb-5">
<button
type="button"
onClick={() => setActiveTab('quick')}
className={`flex-1 py-2 text-xs font-semibold rounded-lg transition-all ${
activeTab === 'quick'
? 'bg-surface text-primary shadow-xs'
: 'text-foreground/60 hover:text-primary'
}`}
>
Carga Rápida (Individual)
</button>
<button
type="button"
onClick={() => setActiveTab('bulk')}
className={`flex-1 py-2 text-xs font-semibold rounded-lg transition-all ${
activeTab === 'bulk'
? 'bg-surface text-primary shadow-xs'
: 'text-foreground/60 hover:text-primary'
}`}
>
Carga Masiva (CSV / Excel)
</button>
</div>
{/* TAB 1: CARGA RAPIDA */}
{activeTab === 'quick' ? (
<form onSubmit={handleQuickSubmit} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<Label htmlFor="quick-first-name" className="mb-1 block text-xs">
Nombre <span className="text-danger">*</span>
</Label>
<Input
id="quick-first-name"
placeholder="Ej. Lucas"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
required
/>
</div>
<div>
<Label htmlFor="quick-last-name" className="mb-1 block text-xs">
Apellido
</Label>
<Input
id="quick-last-name"
placeholder="Ej. González"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
/>
</div>
</div>
<div>
<Label htmlFor="quick-phone" className="mb-1 block text-xs">
Teléfono / WhatsApp <span className="text-danger">*</span>
</Label>
<Input
id="quick-phone"
placeholder="Ej. +54 9 11 2345-6789"
value={phone}
onChange={(e) => setPhone(e.target.value)}
required
/>
<p className="mt-1 text-[11px] text-foreground/50">
Usado para contacto y validación de duplicados.
</p>
</div>
<div>
<Label htmlFor="quick-email" className="mb-1 block text-xs">
Email (opcional)
</Label>
<Input
id="quick-email"
type="email"
placeholder="alumno@ejemplo.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div>
<Label htmlFor="quick-notes" className="mb-1 block text-xs">
Notas u Observaciones (opcional)
</Label>
<Input
id="quick-notes"
placeholder="Ej. Nivel intermedio, trae materiales propios"
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</div>
<div className="flex items-center justify-end gap-2.5 pt-3 border-t border-border">
<Button
variant="ghost"
onClick={() => setIsAddAttendeeModalOpen(false)}
>
Cancelar
</Button>
<Button
variant="primary"
type="submit"
disabled={createAttendeeMutation.isPending}
className="gap-2"
>
{createAttendeeMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Check className="size-4" />
)}
<span>Guardar Miembro</span>
</Button>
</div>
</form>
) : null}
{/* TAB 2: CARGA MASIVA */}
{activeTab === 'bulk' ? (
<div className="space-y-4">
{/* Dropzone */}
<div
onDragOver={(e) => {
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'
}`}
>
<input
ref={fileInputRef}
type="file"
accept=".csv,.xlsx,.xls,.txt"
className="hidden"
onChange={handleFileSelect}
/>
<UploadCloud className="size-10 text-accent mb-2" />
<p className="font-semibold text-sm text-primary">
Arrastra tu archivo aquí o haz clic para seleccionarlo
</p>
<p className="text-xs text-foreground/60 mt-1">
Formatos soportados: CSV (.csv) o Excel (.xlsx)
</p>
{fileName ? (
<div className="mt-3 flex items-center gap-2 rounded-lg bg-surface border border-border px-3 py-1.5 text-xs text-primary font-medium">
<FileSpreadsheet className="size-4 text-accent" />
<span>{fileName}</span>
</div>
) : null}
</div>
{/* Template Download Link */}
<div className="flex items-center justify-between text-xs px-1">
<span className="text-foreground/60">
¿No tienes el archivo listo? Usa nuestra plantilla.
</span>
<button
type="button"
onClick={downloadAttendeeTemplateCsv}
className="inline-flex items-center gap-1 font-medium text-accent hover:text-accent-strong transition-colors"
>
<Download className="size-3.5" />
<span>Descargar plantilla CSV</span>
</button>
</div>
{isParsingFile ? (
<div className="flex items-center justify-center py-6 gap-2 text-sm text-foreground/60">
<Loader2 className="size-4 animate-spin text-accent" />
<span>Leyendo y analizando archivo...</span>
</div>
) : null}
{/* Interactive Preview Table */}
{parsedContacts.length > 0 ? (
<div className="space-y-3 pt-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs">
<span className="font-semibold text-primary">Vista Previa:</span>
<Badge variant="success">{validRowsCount} listos para importar</Badge>
{invalidRowsCount > 0 ? (
<Badge variant="danger">{invalidRowsCount} con errores</Badge>
) : null}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
setParsedContacts([]);
setFileName(null);
}}
className="text-xs text-foreground/50 hover:text-danger h-7 px-2"
>
Limpiar lista
</Button>
</div>
<div className="max-h-60 overflow-y-auto rounded-xl border border-border">
<table className="w-full text-left text-xs">
<thead className="sticky top-0 bg-surface border-b border-border font-semibold text-foreground/70">
<tr>
<th className="py-2 px-3">Nombre</th>
<th className="py-2 px-3">Teléfono</th>
<th className="py-2 px-3">Email</th>
<th className="py-2 px-3">Estado</th>
<th className="py-2 px-2 text-right"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{parsedContacts.map((c) => (
<tr
key={c.id}
className={!c.isValid ? 'bg-danger-soft/30' : 'hover:bg-primary-soft/30'}
>
<td className="py-2 px-3 font-medium text-primary">
{c.fullName || <span className="text-danger italic">Sin nombre</span>}
</td>
<td className="py-2 px-3 text-foreground/80">
{c.phone || <span className="text-danger italic">Sin teléfono</span>}
</td>
<td className="py-2 px-3 text-foreground/60">
{c.email || '—'}
</td>
<td className="py-2 px-3">
{c.isValid ? (
<Badge variant="success" className="text-[10px] py-0">Válido</Badge>
) : (
<Badge variant="danger" className="text-[10px] py-0" title={c.error}>
{c.error}
</Badge>
)}
</td>
<td className="py-2 px-2 text-right">
<button
type="button"
onClick={() => removeContactRow(c.id)}
className="p-1 text-foreground/40 hover:text-danger transition-colors rounded"
title="Eliminar fila"
>
<Trash2 className="size-3.5" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-end gap-2.5 pt-3 border-t border-border">
<Button
variant="ghost"
onClick={() => setIsAddAttendeeModalOpen(false)}
>
Cancelar
</Button>
<Button
variant="primary"
onClick={handleConfirmBulkImport}
disabled={bulkImportMutation.isPending || validRowsCount === 0}
className="gap-2"
>
{bulkImportMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Check className="size-4" />
)}
<span>Confirmar e Importar ({validRowsCount})</span>
</Button>
</div>
</div>
) : null}
</div>
) : null}
</div>
</Modal>
{/* MODAL: CUPO ALCANZADO (ALTA INDIVIDUAL) */}
<Modal
isOpen={isCapacityModalOpen}
onClose={() => {
setPendingCapacityPayload(null);
setIsCapacityModalOpen(false);
}}
title="Cupo alcanzado"
description="El grupo llegó a su cupo máximo de miembros."
maxWidth="md"
>
{pendingCapacityPayload ? (
<div className="space-y-4">
<p className="text-sm text-foreground/70">
El grupo <strong className="text-primary">{group.name}</strong> ya alcanzó su cupo de{' '}
<strong className="text-primary">{group.capacity}</strong> miembros. ¿Qué deseas hacer con{' '}
<strong className="text-primary">
{`${pendingCapacityPayload.firstName.trim()} ${pendingCapacityPayload.lastName.trim()}`.trim()}
</strong>
?
</p>
<div className="flex flex-col gap-2.5">
<Button
variant="primary"
onClick={() => createAttendeeForceMutation.mutate({ ...pendingCapacityPayload })}
disabled={createAttendeeForceMutation.isPending || addToWaitlistMutation.isPending}
className="gap-2"
>
{createAttendeeForceMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<UserPlus className="size-4" />
)}
<span>Agregar de todos modos</span>
</Button>
<Button
variant="outline"
onClick={() => addToWaitlistMutation.mutate({ ...pendingCapacityPayload })}
disabled={createAttendeeForceMutation.isPending || addToWaitlistMutation.isPending}
className="gap-2"
>
{addToWaitlistMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Clock className="size-4" />
)}
<span>Sumar a la lista de espera</span>
</Button>
</div>
<p className="text-[11px] text-foreground/50 text-center">
Si eliges agregarlo de todos modos, el grupo quedará por encima de su cupo.
</p>
</div>
) : null}
</Modal>
{/* MODAL: AVISO DE CUPO EN CARGA MASIVA */}
<Modal
isOpen={isBulkCapacityModalOpen}
onClose={() => setIsBulkCapacityModalOpen(false)}
title="Aviso de cupo"
description="La carga supera el cupo del grupo."
maxWidth="md"
>
<div className="space-y-4">
<p className="text-sm text-foreground/70">
Estás por importar <strong className="text-primary">{validRowsCount}</strong> miembro(s), pero el grupo ya
tiene <strong className="text-primary">{attendeesTotal}</strong> inscrito(s) y su cupo es de{' '}
<strong className="text-primary">{group.capacity}</strong>. ¿Deseas importarlos de todos modos?
</p>
<div className="flex items-center justify-end gap-2.5 pt-2">
<Button variant="ghost" onClick={() => setIsBulkCapacityModalOpen(false)}>
Cancelar
</Button>
<Button
variant="primary"
onClick={() => {
setIsBulkCapacityModalOpen(false);
doBulkImport();
}}
className="gap-2"
>
<Check className="size-4" />
Importar de todos modos
</Button>
</div>
</div>
</Modal>
{/* MODAL: DETALLE DEL PARTICIPANTE */}
<Modal
isOpen={selectedAttendee !== null}
onClose={() => setSelectedAttendee(null)}
title="Detalle del Participante"
maxWidth="sm"
>
{selectedAttendee ? (
<div className="space-y-5">
{/* Header: avatar + name */}
<div className="flex items-center gap-3">
<div className="size-12 rounded-full bg-accent/10 text-accent font-bold flex items-center justify-center text-lg">
{selectedAttendee.fullName.charAt(0).toUpperCase()}
</div>
<div className="min-w-0">
<p className="text-lg font-semibold text-primary truncate">
{selectedAttendee.fullName}
</p>
<p className="text-xs text-foreground/50">
Incorporado el{' '}
{new Date(selectedAttendee.createdAt).toLocaleDateString('es-ES', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</p>
</div>
</div>
{/* Info rows */}
<div className="space-y-1 rounded-xl border border-border bg-primary-soft/30 divide-y divide-border">
<DetailRow
icon={<Phone className="size-4 text-accent" />}
label="Teléfono"
>
{selectedAttendee.phone ? (
<div className="flex items-center gap-2">
<span className="text-primary">{selectedAttendee.phone}</span>
<a
href={`https://wa.me/${selectedAttendee.phone.replace(/\D/g, '')}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs font-medium text-success hover:text-success/80 transition-colors"
onClick={(e) => e.stopPropagation()}
>
<MessageCircle className="size-3.5" />
<span>WhatsApp</span>
</a>
</div>
) : (
<span className="text-foreground/40">Sin teléfono</span>
)}
</DetailRow>
<DetailRow
icon={<Mail className="size-4 text-accent" />}
label="Email"
>
{selectedAttendee.email ? (
<a
href={`mailto:${selectedAttendee.email}`}
className="text-primary hover:text-accent transition-colors"
onClick={(e) => e.stopPropagation()}
>
{selectedAttendee.email}
</a>
) : (
<span className="text-foreground/40">Sin email</span>
)}
</DetailRow>
{(selectedAttendee.guardianName || selectedAttendee.guardianPhone) ? (
<>
<DetailRow
icon={<ShieldCheck className="size-4 text-accent" />}
label="Responsable"
>
<span className="text-primary">
{selectedAttendee.guardianName || <span className="text-foreground/40">—</span>}
</span>
</DetailRow>
{selectedAttendee.guardianPhone ? (
<DetailRow
icon={<Phone className="size-4 text-accent" />}
label="Tel. Responsable"
>
<div className="flex items-center gap-2">
<span className="text-primary">{selectedAttendee.guardianPhone}</span>
<a
href={`https://wa.me/${selectedAttendee.guardianPhone.replace(/\D/g, '')}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs font-medium text-success hover:text-success/80 transition-colors"
onClick={(e) => e.stopPropagation()}
>
<MessageCircle className="size-3.5" />
</a>
</div>
</DetailRow>
) : null}
</>
) : null}
<DetailRow
icon={<StickyNote className="size-4 text-accent" />}
label="Notas"
>
{selectedAttendee.notes ? (
<span className="text-primary text-xs leading-relaxed">{selectedAttendee.notes}</span>
) : (
<span className="text-foreground/40">Sin notas</span>
)}
</DetailRow>
</div>
<div className="pt-2 border-t border-border">
<Button
variant="ghost"
onClick={() => setAttendeeToRemove(selectedAttendee)}
className="w-full gap-2 text-danger border border-danger/20 hover:bg-danger/10 hover:text-danger"
>
<UserMinus className="size-4" />
Quitar del grupo
</Button>
</div>
</div>
) : null}
</Modal>
{/* MODAL: CONFIRMAR QUITAR MIEMBRO */}
<Modal
isOpen={attendeeToRemove !== null}
onClose={() => setAttendeeToRemove(null)}
title="Quitar del grupo"
maxWidth="md"
>
{attendeeToRemove ? (
<div className="space-y-4">
<p className="text-sm text-foreground/70">
{attendeeToRemove.fullName} dejará de ser miembro del grupo{' '}
<strong className="text-primary">{group.name}</strong>
{firstWaitlistEntry ? ' y el cupo quedará libre.' : '.'}
{attendeeToRemove.phone ? (
<span className="block mt-1 text-xs text-foreground/50">
Teléfono: {attendeeToRemove.phone}
</span>
) : null}
</p>
{firstWaitlistEntry ? (
<div className="rounded-xl border border-accent/20 bg-accent-soft p-4 space-y-3">
<div className="flex items-center gap-2 text-xs font-semibold text-primary">
<Clock className="size-4 text-accent" />
<span>
Hay {waitlistTotal} persona{waitlistTotal === 1 ? '' : 's'} esperando un cupo
</span>
</div>
<p className="text-sm text-foreground/80">
¿Quieres pasar a{' '}
<strong className="text-primary">{firstWaitlistEntry.fullName}</strong>, el primero de
la lista de espera, al grupo?
</p>
<div className="flex flex-col gap-2.5 pt-1">
<Button
variant="primary"
onClick={() =>
removeAttendeeMutation.mutate({
attendeeId: attendeeToRemove.id,
promoteFromWaitlist: true,
})
}
disabled={removeAttendeeMutation.isPending}
className="gap-2"
>
{removeAttendeeMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<UserCheck className="size-4" />
)}
<span>Quitar y pasar a {firstWaitlistEntry.fullName} al grupo</span>
</Button>
<Button
variant="outline"
onClick={() =>
removeAttendeeMutation.mutate({
attendeeId: attendeeToRemove.id,
promoteFromWaitlist: false,
})
}
disabled={removeAttendeeMutation.isPending}
className="gap-2"
>
{removeAttendeeMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<UserMinus className="size-4" />
)}
<span>Solo quitar del grupo</span>
</Button>
</div>
</div>
) : (
<div className="flex items-center justify-end gap-2.5 pt-2 border-t border-border">
<Button variant="ghost" onClick={() => setAttendeeToRemove(null)}>
Cancelar
</Button>
<Button
variant="primary"
onClick={() =>
removeAttendeeMutation.mutate({
attendeeId: attendeeToRemove.id,
promoteFromWaitlist: false,
})
}
disabled={removeAttendeeMutation.isPending}
className="gap-2"
>
{removeAttendeeMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Trash2 className="size-4" />
)}
<span>Quitar del grupo</span>
</Button>
</div>
)}
</div>
) : null}
</Modal>
{/* MODAL: DETALLE LISTA DE ESPERA */}
<Modal
isOpen={selectedWaitlistEntry !== null}
onClose={() => setSelectedWaitlistEntry(null)}
title="Detalle de la lista de espera"
maxWidth="sm"
>
{selectedWaitlistEntry ? (
<div className="space-y-5">
{/* Header: avatar + name */}
<div className="flex items-center gap-3">
<div className="size-12 rounded-full bg-accent/10 text-accent font-bold flex items-center justify-center text-lg">
{selectedWaitlistEntry.fullName.charAt(0).toUpperCase()}
</div>
<div className="min-w-0">
<p className="text-lg font-semibold text-primary truncate">
{selectedWaitlistEntry.fullName}
</p>
<p className="text-xs text-foreground/50">
En espera desde el{' '}
{new Date(selectedWaitlistEntry.createdAt).toLocaleDateString('es-ES', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</p>
</div>
</div>
{/* Info rows */}
<div className="space-y-1 rounded-xl border border-border bg-primary-soft/30 divide-y divide-border">
<DetailRow icon={<Phone className="size-4 text-accent" />} label="Teléfono">
{selectedWaitlistEntry.phone ? (
<div className="flex items-center gap-2">
<span className="text-primary">{selectedWaitlistEntry.phone}</span>
<a
href={`https://wa.me/${selectedWaitlistEntry.phone.replace(/\D/g, '')}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs font-medium text-success hover:text-success/80 transition-colors"
onClick={(e) => e.stopPropagation()}
>
<MessageCircle className="size-3.5" />
<span>WhatsApp</span>
</a>
</div>
) : (
<span className="text-foreground/40">Sin teléfono</span>
)}
</DetailRow>
{selectedWaitlistEntry.email ? (
<DetailRow icon={<Mail className="size-4 text-accent" />} label="Email">
<a
href={`mailto:${selectedWaitlistEntry.email}`}
className="text-primary hover:text-accent transition-colors"
onClick={(e) => e.stopPropagation()}
>
{selectedWaitlistEntry.email}
</a>
</DetailRow>
) : null}
<DetailRow icon={<StickyNote className="size-4 text-accent" />} label="Notas">
{selectedWaitlistEntry.notes ? (
<span className="text-primary text-xs leading-relaxed">
{selectedWaitlistEntry.notes}
</span>
) : (
<span className="text-foreground/40">Sin notas</span>
)}
</DetailRow>
</div>
{/* Footer: status + actions */}
{!hasFreeCapacity ? (
<p className="rounded-xl border border-border bg-primary-soft/30 px-4 py-3 text-xs text-foreground/70">
El grupo alcanzó su cupo de miembros. Quita un miembro o aumenta el cupo para poder pasar
a esta persona al grupo.
</p>
) : null}
<div className="grid grid-cols-1 gap-2.5 pt-2 border-t border-border">
<Button
variant="primary"
onClick={() => {
const entry = selectedWaitlistEntry;
setSelectedWaitlistEntry(null);
promoteWaitlistMutation.mutate(entry.id);
}}
disabled={promoteWaitlistMutation.isPending || !hasFreeCapacity}
className="gap-2"
>
{promoteWaitlistMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<UserCheck className="size-4" />
)}
<span>Pasar al grupo</span>
</Button>
<Button
variant="ghost"
onClick={() => {
const entry = selectedWaitlistEntry;
setSelectedWaitlistEntry(null);
removeWaitlistEntryMutation.mutate(entry);
}}
disabled={removeWaitlistEntryMutation.isPending}
className="gap-2 text-danger border border-danger/20 hover:bg-danger/10 hover:text-danger"
>
<Trash2 className="size-4" />
Quitar de la lista de espera
</Button>
</div>
</div>
) : null}
</Modal>
</section>
);
}
function DetailRow({ icon, label, children }: { icon: ReactNode; label: string; children: ReactNode }) {
return (
<div className="flex items-start gap-3 px-4 py-3">
<div className="mt-0.5 shrink-0">{icon}</div>
<div className="min-w-0 flex-1">
<p className="text-[11px] font-medium text-foreground/50 uppercase tracking-wide mb-0.5">
{label}
</p>
<div className="text-sm">{children}</div>
</div>
</div>
);
}