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:
@@ -6,13 +6,17 @@ import type {
|
||||
ConnectPayment,
|
||||
ConnectPaymentResult,
|
||||
CreateAttendee,
|
||||
CreateAttendeeResult,
|
||||
CreateFirstGroup,
|
||||
CreateFirstGroupResult,
|
||||
CreateGroupWaitlistEntry,
|
||||
GroupDto,
|
||||
GroupInviteInfoDto,
|
||||
GroupList,
|
||||
GroupWaitlistEntryDto,
|
||||
InviteTokenResult,
|
||||
JoinGroupViaInvite,
|
||||
JoinGroupViaInviteResult,
|
||||
OnboardingStatusDto,
|
||||
ProblemDetails,
|
||||
} from '@gruperly/shared'
|
||||
@@ -87,13 +91,26 @@ export const getInviteInfo = (token: string) =>
|
||||
apiFetch<GroupInviteInfoDto>(`/api/v1/invitations/${token}`)
|
||||
|
||||
export const joinViaInvite = (token: string, payload: JoinGroupViaInvite) =>
|
||||
apiFetch<{ attendee: AttendeeDto; message: string }>(`/api/v1/invitations/${token}/join`, {
|
||||
apiFetch<JoinGroupViaInviteResult>(`/api/v1/invitations/${token}/join`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
export const createAttendee = (groupId: string, payload: CreateAttendee) =>
|
||||
apiFetch<AttendeeDto>(`/api/v1/groups/${groupId}/attendees`, {
|
||||
export const createAttendee = (
|
||||
groupId: string,
|
||||
payload: CreateAttendee,
|
||||
options?: { allowOverflow?: boolean },
|
||||
) =>
|
||||
apiFetch<CreateAttendeeResult>(
|
||||
`/api/v1/groups/${groupId}/attendees${options?.allowOverflow ? '?allowOverflow=true' : ''}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
)
|
||||
|
||||
export const addToGroupWaitlist = (groupId: string, payload: CreateGroupWaitlistEntry) =>
|
||||
apiFetch<GroupWaitlistEntryDto>(`/api/v1/groups/${groupId}/waitlist`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useParams, Link } from '@tanstack/react-router';
|
||||
import {
|
||||
CalendarClock,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
GraduationCap,
|
||||
Loader2,
|
||||
Moon,
|
||||
@@ -32,6 +33,9 @@ export function JoinGroupView() {
|
||||
phone: string | null;
|
||||
} | null>(null);
|
||||
|
||||
// Waitlist state (group is full)
|
||||
const [waitlistInfo, setWaitlistInfo] = useState<{ message: string } | null>(null);
|
||||
|
||||
const inviteQuery = useQuery({
|
||||
queryKey: ['public-invite', token],
|
||||
queryFn: () => getInviteInfo(token),
|
||||
@@ -49,9 +53,15 @@ export function JoinGroupView() {
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
setErrorMessage(null);
|
||||
if (data.status === 'waitlisted') {
|
||||
setRegisteredAttendee(null);
|
||||
setWaitlistInfo({ message: data.message });
|
||||
return;
|
||||
}
|
||||
setWaitlistInfo(null);
|
||||
setRegisteredAttendee({
|
||||
fullName: data.attendee.fullName,
|
||||
phone: data.attendee.phone,
|
||||
fullName: data.attendee!.fullName,
|
||||
phone: data.attendee!.phone,
|
||||
});
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
@@ -163,8 +173,32 @@ export function JoinGroupView() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Waitlist Confirmation (group full) */}
|
||||
{waitlistInfo && group ? (
|
||||
<div className="rounded-2xl border border-accent/30 bg-surface p-6 sm:p-8 text-center shadow-xl space-y-5 animate-step-enter">
|
||||
<div className="size-16 rounded-full bg-accent-soft text-accent flex items-center justify-center mx-auto">
|
||||
<Clock className="size-10" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Badge variant="neutral" className="mb-2">
|
||||
Lista de Espera
|
||||
</Badge>
|
||||
<h1 className="text-2xl font-bold text-primary">Cupo completo</h1>
|
||||
<p className="mt-2 text-sm text-foreground/70 leading-relaxed">
|
||||
{waitlistInfo.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-foreground/60">
|
||||
El profesor se pondrá en contacto contigo cuando haya un lugar disponible en{' '}
|
||||
<strong className="text-primary font-semibold">{group.name}</strong>.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Inscription Form */}
|
||||
{!registeredAttendee && group ? (
|
||||
{!registeredAttendee && !waitlistInfo && group ? (
|
||||
<div className="rounded-2xl border border-border bg-surface p-6 sm:p-8 shadow-xl space-y-6">
|
||||
{/* Group Info Header */}
|
||||
<div className="border-b border-border pb-5">
|
||||
|
||||
Reference in New Issue
Block a user