feat: add group waitlist functionality

- Introduced GroupWaitlistEntry model in Prisma schema to manage waitlisted users for groups.
- Implemented API route to add users to the group waitlist with validation.
- Enhanced attendee creation logic to handle group capacity and waitlisting scenarios.
- Added new problem builders for handling waitlist-related errors.
- Updated frontend to support waitlist interactions, including modals for capacity warnings.
- Created tests for waitlist functionality, ensuring proper handling of full groups and existing waitlist entries.
This commit is contained in:
Jose Selesan
2026-09-22 16:28:54 -03:00
parent a937c827bb
commit 785d54df7e
16 changed files with 848 additions and 31 deletions

View File

@@ -1,7 +1,7 @@
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 } from '@gruperly/shared';
import type { AttendeeDto, CreateAttendee, CreateAttendeeResult, CreateGroupWaitlistEntry } from '@gruperly/shared';
import {
ArrowLeft,
CalendarClock,
@@ -26,9 +26,10 @@ import {
UserPlus,
Users,
} from 'lucide-react';
import type { CreateAttendee } from '@gruperly/shared';
import { Badge, Button, Input, Label, Modal, useToast } from '../components/ui';
import {
addToGroupWaitlist,
ApiError,
bulkCreateAttendees,
createAttendee,
getGroup,
@@ -70,6 +71,11 @@ export function GroupDetailView() {
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('');
@@ -105,23 +111,72 @@ export function GroupDetailView() {
});
// 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),
onSuccess: (created) => {
toast.success(`Miembro ${created.fullName} agregado con éxito.`);
queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] });
setFirstName('');
setLastName('');
setPhone('');
setEmail('');
setNotes('');
setIsAddAttendeeModalOpen(false);
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),
onSuccess: (entry) => {
toast.info(`${entry.fullName} fue agregado a la lista de espera del grupo.`);
queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] });
closeAddAttendeeFlow();
},
onError: (err: Error) => {
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 }>) =>
@@ -240,7 +295,7 @@ export function GroupDetailView() {
setParsedContacts((prev) => prev.filter((c) => c.id !== id));
};
const handleConfirmBulkImport = () => {
const doBulkImport = () => {
const validRows = parsedContacts.filter((c) => c.isValid);
if (validRows.length === 0) {
toast.error('No hay miembros válidos para importar.');
@@ -258,8 +313,21 @@ export function GroupDetailView() {
);
};
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 filteredAttendees = attendees.filter((a) => {
const q = searchFilter.toLowerCase();
return (
@@ -880,6 +948,95 @@ export function GroupDetailView() {
</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}