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
This commit is contained in:
Jose Selesan
2026-09-22 17:04:41 -03:00
parent 785d54df7e
commit fc30927b8b
19 changed files with 1417 additions and 103 deletions

View File

@@ -1,7 +1,14 @@
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 } from '@gruperly/shared';
import type {
AttendeeDto,
CreateAttendee,
CreateAttendeeResult,
CreateGroupWaitlistEntry,
GroupWaitlistEntryDto,
GroupWaitlistList,
} from '@gruperly/shared';
import {
ArrowLeft,
CalendarClock,
@@ -23,6 +30,8 @@ import {
StickyNote,
Trash2,
UploadCloud,
UserCheck,
UserMinus,
UserPlus,
Users,
} from 'lucide-react';
@@ -34,7 +43,11 @@ import {
createAttendee,
getGroup,
getGroupAttendees,
getGroupWaitlist,
getInviteToken,
promoteGroupWaitlistEntry,
removeGroupAttendee,
removeGroupWaitlistEntry,
} from '../lib/api';
import {
downloadAttendeeTemplateCsv,
@@ -56,6 +69,9 @@ export function GroupDetailView() {
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('');
@@ -98,6 +114,12 @@ export function GroupDetailView() {
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),
@@ -161,12 +183,43 @@ export function GroupDetailView() {
// 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-attendees', groupId] });
queryClient.invalidateQueries({ queryKey: ['group-waitlist', groupId] });
queryClient.invalidateQueries({ queryKey: ['group', groupId] });
closeAddAttendeeFlow();
},
onError: (err: Error) => {
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 {
@@ -193,6 +246,63 @@ export function GroupDetailView() {
},
});
// 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;
@@ -328,6 +438,10 @@ export function GroupDetailView() {
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 (
@@ -375,7 +489,7 @@ export function GroupDetailView() {
<div>
<Link
to="/groups"
className="inline-flex items-center gap-1 text-sm text-foreground/60 hover:text-primary transition-colors mb-3"
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>
@@ -428,9 +542,10 @@ export function GroupDetailView() {
<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="text-xs font-medium text-foreground/50 uppercase">Miembros & Cupo</p>
<p className="font-medium text-primary">
{attendees.length} inscritos {group.capacity ? `· Cupo de ${group.capacity}` : ''}
{attendees.length} inscritos {waitlistTotal > 0 ? ` · ${waitlistTotal} en espera` : ''}
{group.capacity ? ` · Cupo de ${group.capacity}` : ''}
</p>
</div>
</div>
@@ -450,108 +565,201 @@ export function GroupDetailView() {
</div>
</div>
{/* Attendees Management Section */}
{/* Miembros & Lista de espera */}
<div className="rounded-xl border border-border bg-surface p-5 space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-primary">
Miembros del Grupo ({attendees.length})
</h2>
<p className="text-xs text-foreground/60">
Listado de todos los miembros incorporados al grupo.
</p>
<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>
<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>
<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>
{attendeesQuery.isPending ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-6 animate-spin text-accent" />
</div>
) : null}
{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)}
{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"
>
<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>
<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>
) : null}
)}
</div>
{/* MODAL: COMPARTIR LINK DE INVITACION */}
@@ -1151,6 +1359,233 @@ export function GroupDetailView() {
)}
</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>