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:
@@ -14,11 +14,15 @@ import type {
|
||||
GroupInviteInfoDto,
|
||||
GroupList,
|
||||
GroupWaitlistEntryDto,
|
||||
GroupWaitlistList,
|
||||
InviteTokenResult,
|
||||
JoinGroupViaInvite,
|
||||
JoinGroupViaInviteResult,
|
||||
OnboardingStatusDto,
|
||||
ProblemDetails,
|
||||
PromoteGroupWaitlistEntryResult,
|
||||
RemoveAttendeeResult,
|
||||
RemoveGroupWaitlistEntryResult,
|
||||
} from '@gruperly/shared'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4000'
|
||||
@@ -122,4 +126,29 @@ export const bulkCreateAttendees = (groupId: string, payload: BulkCreateAttendee
|
||||
})
|
||||
|
||||
export const getGroupAttendees = (groupId: string, page = 1, pageSize = 20) =>
|
||||
apiFetch<AttendeeList>(`/api/v1/groups/${groupId}/attendees?page=${page}&pageSize=${pageSize}`)
|
||||
apiFetch<AttendeeList>(`/api/v1/groups/${groupId}/attendees?page=${page}&pageSize=${pageSize}`)
|
||||
|
||||
export const getGroupWaitlist = (groupId: string, page = 1, pageSize = 100) =>
|
||||
apiFetch<GroupWaitlistList>(`/api/v1/groups/${groupId}/waitlist?page=${page}&pageSize=${pageSize}`)
|
||||
|
||||
export const promoteGroupWaitlistEntry = (groupId: string, entryId: string) =>
|
||||
apiFetch<PromoteGroupWaitlistEntryResult>(`/api/v1/groups/${groupId}/waitlist/${entryId}/promote`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
export const removeGroupWaitlistEntry = (groupId: string, entryId: string) =>
|
||||
apiFetch<RemoveGroupWaitlistEntryResult>(`/api/v1/groups/${groupId}/waitlist/${entryId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
export const removeGroupAttendee = (
|
||||
groupId: string,
|
||||
attendeeId: string,
|
||||
options?: { promoteFromWaitlist?: boolean },
|
||||
) =>
|
||||
apiFetch<RemoveAttendeeResult>(
|
||||
`/api/v1/groups/${groupId}/attendees/${attendeeId}${options?.promoteFromWaitlist ? '?promoteFromWaitlist=true' : ''}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
},
|
||||
)
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user