From fc30927b8b36d9e47c4e0b197c1980ce7ab8bcdd Mon Sep 17 00:00:00 2001 From: Jose Selesan Date: Tue, 22 Sep 2026 17:04:41 -0300 Subject: [PATCH] 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 --- apps/backend/src/http/problem-builders.ts | 7 + .../attendees/features/remove/route.ts | 24 + .../attendees/features/remove/use-case.ts | 73 ++ .../group-waitlist/features/get-all/route.ts | 21 + .../features/get-all/use-case.ts | 43 ++ .../group-waitlist/features/promote/route.ts | 19 + .../features/promote/use-case.ts | 56 ++ .../group-waitlist/features/remove/route.ts | 19 + .../features/remove/use-case.ts | 37 + .../src/modules/group-waitlist/index.ts | 1 + .../src/modules/group-waitlist/lib/helpers.ts | 65 ++ .../src/modules/group-waitlist/lib/promote.ts | 83 +++ .../src/modules/group-waitlist/routes.ts | 12 + apps/backend/src/modules/groups/routes.ts | 4 + apps/backend/test/waitlist-management.test.ts | 352 ++++++++++ apps/web/src/lib/api.ts | 31 +- apps/web/src/routes/group-detail.tsx | 635 +++++++++++++++--- packages/shared/src/schemas/attendees.ts | 19 +- packages/shared/src/schemas/waitlist.ts | 19 +- 19 files changed, 1417 insertions(+), 103 deletions(-) create mode 100644 apps/backend/src/modules/attendees/features/remove/route.ts create mode 100644 apps/backend/src/modules/attendees/features/remove/use-case.ts create mode 100644 apps/backend/src/modules/group-waitlist/features/get-all/route.ts create mode 100644 apps/backend/src/modules/group-waitlist/features/get-all/use-case.ts create mode 100644 apps/backend/src/modules/group-waitlist/features/promote/route.ts create mode 100644 apps/backend/src/modules/group-waitlist/features/promote/use-case.ts create mode 100644 apps/backend/src/modules/group-waitlist/features/remove/route.ts create mode 100644 apps/backend/src/modules/group-waitlist/features/remove/use-case.ts create mode 100644 apps/backend/src/modules/group-waitlist/index.ts create mode 100644 apps/backend/src/modules/group-waitlist/lib/helpers.ts create mode 100644 apps/backend/src/modules/group-waitlist/lib/promote.ts create mode 100644 apps/backend/src/modules/group-waitlist/routes.ts create mode 100644 apps/backend/test/waitlist-management.test.ts diff --git a/apps/backend/src/http/problem-builders.ts b/apps/backend/src/http/problem-builders.ts index afba39f..ece493e 100644 --- a/apps/backend/src/http/problem-builders.ts +++ b/apps/backend/src/http/problem-builders.ts @@ -116,4 +116,11 @@ export function alreadyWaitlistedProblem(): ProblemDetails { detail: 'Este número de teléfono ya está en la lista de espera del grupo.', code: 'already_waitlisted', }); +} + +export function attendeeHasPaymentsProblem(): ProblemDetails { + return conflictProblem({ + detail: 'Este miembro tiene cobros asociados. Gestiona o cancela sus cobros antes de quitarlo del grupo.', + code: 'attendee_has_payments', + }); } \ No newline at end of file diff --git a/apps/backend/src/modules/attendees/features/remove/route.ts b/apps/backend/src/modules/attendees/features/remove/route.ts new file mode 100644 index 0000000..6596ffc --- /dev/null +++ b/apps/backend/src/modules/attendees/features/remove/route.ts @@ -0,0 +1,24 @@ +import { type RemoveAttendeeQuery, RemoveAttendeeQuerySchema } from '@gruperly/shared'; +import { Hono } from 'hono'; +import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details'; +import { validate } from '@/http/validate'; +import { RemoveAttendee } from './use-case'; + +const route = new Hono(); + +route.delete('/:groupId/attendees/:attendeeId', validate.query(RemoveAttendeeQuerySchema), async (c) => { + const user = c.get('user'); + if (!user) { + return problemJson(c, unauthorizedProblem(c.req.path)); + } + const groupId = c.req.param('groupId'); + const attendeeId = c.req.param('attendeeId'); + const query = c.req.valid('query') as RemoveAttendeeQuery; + const useCase = new RemoveAttendee(); + const result = await useCase.execute(groupId, attendeeId, user.id, { + promoteFromWaitlist: query.promoteFromWaitlist, + }); + return resultJson(c, result); +}); + +export default route; \ No newline at end of file diff --git a/apps/backend/src/modules/attendees/features/remove/use-case.ts b/apps/backend/src/modules/attendees/features/remove/use-case.ts new file mode 100644 index 0000000..584956b --- /dev/null +++ b/apps/backend/src/modules/attendees/features/remove/use-case.ts @@ -0,0 +1,73 @@ +import type { Prisma, PrismaClient } from '@generated/prisma/client'; +import type { ProblemDetails, RemoveAttendeeResult, Result } from '@gruperly/shared'; +import { err, ok } from '@gruperly/shared'; +import { attendeeHasPaymentsProblem, notFoundResourceProblem } from '@/http/problem-builders'; +import { default as prisma, UnitOfWork } from '@/lib/prisma'; +import { + findGroupOwnedByUser, + toGroupWaitlistEntryDto, +} from '@/modules/group-waitlist/lib/helpers'; +import { promoteFirstPendingWaitlistEntry } from '@/modules/group-waitlist/lib/promote'; + +type RemoveAttendeeDeps = { + db?: Pick; + unitOfWork?: UnitOfWork; +}; + +type RemoveAttendeeOptions = { + promoteFromWaitlist?: boolean; +}; + +export class RemoveAttendee { + constructor(private readonly deps: RemoveAttendeeDeps = {}) {} + + async execute( + groupId: string, + attendeeId: string, + userId: string, + options: RemoveAttendeeOptions = {}, + ): Promise> { + const db = this.deps.db ?? prisma; + const unitOfWork = this.deps.unitOfWork ?? new UnitOfWork(prisma); + + const groupResult = await findGroupOwnedByUser(db, groupId, userId); + if (!groupResult.ok) { + return groupResult; + } + const group = groupResult.value; + + const attendee = await db.attendee.findFirst({ where: { id: attendeeId, groupId } }); + if (!attendee) { + return err(notFoundResourceProblem('Attendee', attendeeId)); + } + + const paymentsCount = await db.payment.count({ where: { attendeeId } }); + if (paymentsCount > 0) { + return err(attendeeHasPaymentsProblem()); + } + + return unitOfWork.executeResult( + async (tx: Prisma.TransactionClient): Promise> => { + await tx.attendee.delete({ where: { id: attendeeId } }); + + if (!options.promoteFromWaitlist) { + return ok({ removedAttendeeId: attendeeId, promoted: null }); + } + + const promoteResult = await promoteFirstPendingWaitlistEntry(tx, group); + if (!promoteResult.ok) { + return err(promoteResult.error); + } + if (!promoteResult.value) { + return ok({ removedAttendeeId: attendeeId, promoted: null }); + } + + await tx.groupWaitlistEntry.delete({ where: { id: promoteResult.value.entry.id } }); + return ok({ + removedAttendeeId: attendeeId, + promoted: toGroupWaitlistEntryDto(promoteResult.value.entry), + }); + }, + ); + } +} \ No newline at end of file diff --git a/apps/backend/src/modules/group-waitlist/features/get-all/route.ts b/apps/backend/src/modules/group-waitlist/features/get-all/route.ts new file mode 100644 index 0000000..50e73df --- /dev/null +++ b/apps/backend/src/modules/group-waitlist/features/get-all/route.ts @@ -0,0 +1,21 @@ +import { type GroupWaitlistQuery, GroupWaitlistQuerySchema } from '@gruperly/shared'; +import { Hono } from 'hono'; +import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details'; +import { validate } from '@/http/validate'; +import { ListGroupWaitlistEntries } from './use-case'; + +const route = new Hono(); + +route.get('/:groupId/waitlist', validate.query(GroupWaitlistQuerySchema), async (c) => { + const user = c.get('user'); + if (!user) { + return problemJson(c, unauthorizedProblem(c.req.path)); + } + const groupId = c.req.param('groupId'); + const query = c.req.valid('query') as GroupWaitlistQuery; + const useCase = new ListGroupWaitlistEntries(); + const result = await useCase.execute(groupId, user.id, query); + return resultJson(c, result); +}); + +export default route; \ No newline at end of file diff --git a/apps/backend/src/modules/group-waitlist/features/get-all/use-case.ts b/apps/backend/src/modules/group-waitlist/features/get-all/use-case.ts new file mode 100644 index 0000000..c9522e6 --- /dev/null +++ b/apps/backend/src/modules/group-waitlist/features/get-all/use-case.ts @@ -0,0 +1,43 @@ +import type { PrismaClient } from '@generated/prisma/client'; +import type { GroupWaitlistList, GroupWaitlistQuery, ProblemDetails, Result } from '@gruperly/shared'; +import { ok } from '@gruperly/shared'; +import { getPaginationMetadata, getPaginationOffset } from '@/lib/pagination'; +import prisma from '@/lib/prisma'; +import { findGroupForUser, type GroupWaitlistEntryRecord, toGroupWaitlistEntryDto } from '../../lib/helpers'; + +type ListGroupWaitlistEntriesDeps = { + db?: Pick; +}; + +export class ListGroupWaitlistEntries { + constructor(private readonly deps: ListGroupWaitlistEntriesDeps = {}) {} + + async execute( + groupId: string, + userId: string, + query: GroupWaitlistQuery, + ): Promise> { + const db = this.deps.db ?? prisma; + + const groupResult = await findGroupForUser(db, groupId, userId); + if (!groupResult.ok) { + return groupResult; + } + + const where = { groupId, status: 'PENDING' as const }; + const [records, total] = await Promise.all([ + db.groupWaitlistEntry.findMany({ + where, + skip: getPaginationOffset(query), + take: query.pageSize, + orderBy: { createdAt: 'asc' }, + }), + db.groupWaitlistEntry.count({ where }), + ]); + + return ok({ + data: records.map((record) => toGroupWaitlistEntryDto(record as unknown as GroupWaitlistEntryRecord)), + pagination: getPaginationMetadata(query, total), + }); + } +} \ No newline at end of file diff --git a/apps/backend/src/modules/group-waitlist/features/promote/route.ts b/apps/backend/src/modules/group-waitlist/features/promote/route.ts new file mode 100644 index 0000000..3b88eea --- /dev/null +++ b/apps/backend/src/modules/group-waitlist/features/promote/route.ts @@ -0,0 +1,19 @@ +import { Hono } from 'hono'; +import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details'; +import { PromoteGroupWaitlistEntry } from './use-case'; + +const route = new Hono(); + +route.post('/:groupId/waitlist/:entryId/promote', async (c) => { + const user = c.get('user'); + if (!user) { + return problemJson(c, unauthorizedProblem(c.req.path)); + } + const groupId = c.req.param('groupId'); + const entryId = c.req.param('entryId'); + const useCase = new PromoteGroupWaitlistEntry(); + const result = await useCase.execute(groupId, entryId, user.id); + return resultJson(c, result); +}); + +export default route; \ No newline at end of file diff --git a/apps/backend/src/modules/group-waitlist/features/promote/use-case.ts b/apps/backend/src/modules/group-waitlist/features/promote/use-case.ts new file mode 100644 index 0000000..3e5fc09 --- /dev/null +++ b/apps/backend/src/modules/group-waitlist/features/promote/use-case.ts @@ -0,0 +1,56 @@ +import type { Prisma, PrismaClient } from '@generated/prisma/client'; +import type { ProblemDetails, PromoteGroupWaitlistEntryResult, Result } from '@gruperly/shared'; +import { err, ok } from '@gruperly/shared'; +import { notFoundResourceProblem } from '@/http/problem-builders'; +import { default as prisma, UnitOfWork } from '@/lib/prisma'; +import { + findGroupOwnedByUser, + type GroupWaitlistEntryRecord, +} from '../../lib/helpers'; +import { promoteWaitlistEntryRecord } from '../../lib/promote'; + +type PromoteGroupWaitlistEntryDeps = { + db?: Pick; + unitOfWork?: UnitOfWork; +}; + +export class PromoteGroupWaitlistEntry { + constructor(private readonly deps: PromoteGroupWaitlistEntryDeps = {}) {} + + async execute( + groupId: string, + entryId: string, + userId: string, + ): Promise> { + const db = this.deps.db ?? prisma; + const unitOfWork = this.deps.unitOfWork ?? new UnitOfWork(prisma); + + const groupResult = await findGroupOwnedByUser(db, groupId, userId); + if (!groupResult.ok) { + return groupResult; + } + + const entry = await db.groupWaitlistEntry.findFirst({ + where: { id: entryId, groupId }, + }); + if (!entry) { + return err(notFoundResourceProblem('GroupWaitlistEntry', entryId)); + } + + return unitOfWork.executeResult( + async (tx: Prisma.TransactionClient) => { + const promoteResult = await promoteWaitlistEntryRecord( + tx, + groupResult.value, + entry as unknown as GroupWaitlistEntryRecord, + ); + if (!promoteResult.ok) { + return promoteResult; + } + + await tx.groupWaitlistEntry.delete({ where: { id: entry.id } }); + return ok({ attendee: promoteResult.value }); + }, + ); + } +} \ No newline at end of file diff --git a/apps/backend/src/modules/group-waitlist/features/remove/route.ts b/apps/backend/src/modules/group-waitlist/features/remove/route.ts new file mode 100644 index 0000000..a55c71b --- /dev/null +++ b/apps/backend/src/modules/group-waitlist/features/remove/route.ts @@ -0,0 +1,19 @@ +import { Hono } from 'hono'; +import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details'; +import { RemoveGroupWaitlistEntry } from './use-case'; + +const route = new Hono(); + +route.delete('/:groupId/waitlist/:entryId', async (c) => { + const user = c.get('user'); + if (!user) { + return problemJson(c, unauthorizedProblem(c.req.path)); + } + const groupId = c.req.param('groupId'); + const entryId = c.req.param('entryId'); + const useCase = new RemoveGroupWaitlistEntry(); + const result = await useCase.execute(groupId, entryId, user.id); + return resultJson(c, result); +}); + +export default route; \ No newline at end of file diff --git a/apps/backend/src/modules/group-waitlist/features/remove/use-case.ts b/apps/backend/src/modules/group-waitlist/features/remove/use-case.ts new file mode 100644 index 0000000..11f25cd --- /dev/null +++ b/apps/backend/src/modules/group-waitlist/features/remove/use-case.ts @@ -0,0 +1,37 @@ +import type { PrismaClient } from '@generated/prisma/client'; +import type { ProblemDetails, RemoveGroupWaitlistEntryResult, Result } from '@gruperly/shared'; +import { err, ok } from '@gruperly/shared'; +import { notFoundResourceProblem } from '@/http/problem-builders'; +import prisma from '@/lib/prisma'; +import { findGroupOwnedByUser } from '../../lib/helpers'; + +type RemoveGroupWaitlistEntryDeps = { + db?: Pick; +}; + +export class RemoveGroupWaitlistEntry { + constructor(private readonly deps: RemoveGroupWaitlistEntryDeps = {}) {} + + async execute( + groupId: string, + entryId: string, + userId: string, + ): Promise> { + const db = this.deps.db ?? prisma; + + const groupResult = await findGroupOwnedByUser(db, groupId, userId); + if (!groupResult.ok) { + return groupResult; + } + + const existing = await db.groupWaitlistEntry.findFirst({ + where: { id: entryId, groupId }, + }); + if (!existing) { + return err(notFoundResourceProblem('GroupWaitlistEntry', entryId)); + } + + await db.groupWaitlistEntry.delete({ where: { id: entryId } }); + return ok({ deleted: true }); + } +} \ No newline at end of file diff --git a/apps/backend/src/modules/group-waitlist/index.ts b/apps/backend/src/modules/group-waitlist/index.ts new file mode 100644 index 0000000..545f43d --- /dev/null +++ b/apps/backend/src/modules/group-waitlist/index.ts @@ -0,0 +1 @@ +export { default as groupWaitlistRoutes } from './routes'; \ No newline at end of file diff --git a/apps/backend/src/modules/group-waitlist/lib/helpers.ts b/apps/backend/src/modules/group-waitlist/lib/helpers.ts new file mode 100644 index 0000000..907311d --- /dev/null +++ b/apps/backend/src/modules/group-waitlist/lib/helpers.ts @@ -0,0 +1,65 @@ +import type { Group, PrismaClient } from '@generated/prisma/client'; +import type { GroupWaitlistEntryDto, ProblemDetails, Result } from '@gruperly/shared'; +import { err, ok } from '@gruperly/shared'; +import { noGroupAccessProblem, notFoundResourceProblem } from '@/http/problem-builders'; + +export type GroupWaitlistEntryRecord = { + id: string; + groupId: string; + fullName: string; + phone: string; + email: string | null; + notes: string | null; + status: GroupWaitlistEntryDto['status']; + createdAt: Date; + updatedAt: Date; +}; + +export function toGroupWaitlistEntryDto(record: GroupWaitlistEntryRecord): GroupWaitlistEntryDto { + return { + id: record.id, + groupId: record.groupId, + fullName: record.fullName, + phone: record.phone, + email: record.email, + notes: record.notes, + status: record.status, + createdAt: record.createdAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), + }; +} + +export async function findGroupForUser( + db: Pick, + groupId: string, + userId: string, +): Promise> { + const group = await db.group.findUnique({ + where: { id: groupId }, + include: { members: { where: { userId } } }, + }); + if (!group) { + return err(notFoundResourceProblem('Group', groupId)); + } + const isOwner = group.createdById === userId; + const isMember = group.members.length > 0; + if (!isOwner && !isMember) { + return err(noGroupAccessProblem()); + } + return ok(group); +} + +export async function findGroupOwnedByUser( + db: Pick, + groupId: string, + userId: string, +): Promise> { + const group = await db.group.findUnique({ where: { id: groupId } }); + if (!group) { + return err(notFoundResourceProblem('Group', groupId)); + } + if (group.createdById !== userId) { + return err(noGroupAccessProblem()); + } + return ok(group); +} \ No newline at end of file diff --git a/apps/backend/src/modules/group-waitlist/lib/promote.ts b/apps/backend/src/modules/group-waitlist/lib/promote.ts new file mode 100644 index 0000000..ee5a158 --- /dev/null +++ b/apps/backend/src/modules/group-waitlist/lib/promote.ts @@ -0,0 +1,83 @@ +import type { PrismaClient } from '@generated/prisma/client'; +import type { AttendeeDto, ProblemDetails, Result } from '@gruperly/shared'; +import { err, ok } from '@gruperly/shared'; +import { capacityReachedProblem, conflictProblem } from '@/http/problem-builders'; +import { type AttendeeRecord, toAttendeeDto } from '@/modules/attendees/lib/helpers'; +import { type GroupWaitlistEntryRecord } from './helpers'; + +export type PromoteDb = Pick; + +type PromoteGroup = { + id: string; + capacity: number | null; +}; + +export async function findFirstPendingWaitlistEntry( + db: PromoteDb, + groupId: string, +): Promise { + const record = await db.groupWaitlistEntry.findFirst({ + where: { groupId, status: 'PENDING' }, + orderBy: { createdAt: 'asc' }, + }); + return record as GroupWaitlistEntryRecord | null; +} + +export async function promoteWaitlistEntryRecord( + db: PromoteDb, + group: PromoteGroup, + entry: GroupWaitlistEntryRecord, +): Promise> { + if (group.capacity !== null) { + const currentCount = await db.attendee.count({ where: { groupId: group.id } }); + if (currentCount >= group.capacity) { + return err(capacityReachedProblem(group.capacity)); + } + } + + const existing = await db.attendee.findFirst({ + where: { groupId: group.id, phone: entry.phone }, + }); + if (existing) { + return err( + conflictProblem({ + detail: 'Ya existe un alumno registrado con este número de teléfono en este grupo.', + code: 'attendee_already_registered', + }), + ); + } + + const attendee = await db.attendee.create({ + data: { + groupId: group.id, + fullName: entry.fullName, + phone: entry.phone, + email: entry.email, + notes: entry.notes, + }, + }); + + return ok(toAttendeeDto(attendee as unknown as AttendeeRecord)); +} + +export type PromoteFirstResult = { + attendee: AttendeeDto; + entry: GroupWaitlistEntryRecord; +}; + +export async function promoteFirstPendingWaitlistEntry( + db: PromoteDb, + group: PromoteGroup, +): Promise> { + const entry = await findFirstPendingWaitlistEntry(db, group.id); + if (!entry) { + return ok(null); + } + + const result = await promoteWaitlistEntryRecord(db, group, entry); + if (!result.ok) { + return result; + } + + return ok({ attendee: result.value, entry }); +} \ No newline at end of file diff --git a/apps/backend/src/modules/group-waitlist/routes.ts b/apps/backend/src/modules/group-waitlist/routes.ts new file mode 100644 index 0000000..41da6c1 --- /dev/null +++ b/apps/backend/src/modules/group-waitlist/routes.ts @@ -0,0 +1,12 @@ +import { Hono } from 'hono'; +import getAllRoute from './features/get-all/route'; +import promoteRoute from './features/promote/route'; +import removeRoute from './features/remove/route'; + +const routes = new Hono(); + +routes.route('/', getAllRoute); +routes.route('/', promoteRoute); +routes.route('/', removeRoute); + +export default routes; \ No newline at end of file diff --git a/apps/backend/src/modules/groups/routes.ts b/apps/backend/src/modules/groups/routes.ts index ac1d671..bfa9916 100644 --- a/apps/backend/src/modules/groups/routes.ts +++ b/apps/backend/src/modules/groups/routes.ts @@ -2,6 +2,8 @@ import { Hono } from 'hono'; import addToGroupWaitlistRoute from '../attendees/features/add-to-waitlist/route'; import bulkCreateAttendeesRoute from '../attendees/features/bulk-create/route'; import createAttendeeRoute from '../attendees/features/create/route'; +import removeAttendeeRoute from '../attendees/features/remove/route'; +import groupWaitlistRoutes from '../group-waitlist/routes'; import createFromOrganizationRoute from './features/create-from-organization/route'; import getAllRoute from './features/get-all/route'; import getByIdRoute from './features/get-by-id/route'; @@ -18,5 +20,7 @@ routes.route('/', createAttendeeRoute); routes.route('/', bulkCreateAttendeesRoute); routes.route('/', getByIdRoute); routes.route('/', addToGroupWaitlistRoute); +routes.route('/', groupWaitlistRoutes); +routes.route('/', removeAttendeeRoute); export default routes; \ No newline at end of file diff --git a/apps/backend/test/waitlist-management.test.ts b/apps/backend/test/waitlist-management.test.ts new file mode 100644 index 0000000..76d1a06 --- /dev/null +++ b/apps/backend/test/waitlist-management.test.ts @@ -0,0 +1,352 @@ +import { beforeEach, describe, expect, it, mock, vi } from 'bun:test'; +import { Hono } from 'hono'; + +const db = { + group: { + findUnique: mock(), + }, + attendee: { + findFirst: mock(), + findMany: mock(), + count: mock(), + create: mock(), + delete: mock(), + }, + groupWaitlistEntry: { + findMany: mock(), + findFirst: mock(), + count: mock(), + create: mock(), + delete: mock(), + }, + payment: { + count: mock(), + }, +}; + +mock.module('@/lib/prisma', () => ({ + default: db, + getPrismaClient: mock(), + UnitOfWork: class { + executeResult = mock(async (cb: (tx: unknown) => Promise) => cb(db)); + execute = mock(async (cb: (tx: unknown) => Promise) => cb(db)); + }, +})); + +import prisma from '@/lib/prisma'; +import { groupsRoutes } from '@/modules/groups'; + +const teacherId = 'teacher-1'; +const mockGroup = { + id: 'group-1', + name: 'Taller de Pintura', + description: null, + createdById: teacherId, + inviteToken: null, + createdAt: new Date('2026-09-01T10:00:00.000Z'), + updatedAt: new Date('2026-09-01T10:00:00.000Z'), + days: ['MONDAY'], + time: '18:00', + capacity: 20, + price: 1500, + billingType: 'MONTHLY', + dueDay: 10, + members: [], +}; + +const waitlistEntry = (overrides: Record = {}) => ({ + id: 'wl-1', + groupId: 'group-1', + fullName: 'Martín Gómez', + phone: '+5491133445566', + email: null, + notes: null, + status: 'PENDING', + createdAt: new Date('2026-09-10T10:00:00.000Z'), + updatedAt: new Date('2026-09-10T10:00:00.000Z'), + ...overrides, +}); + +const attendee = (overrides: Record = {}) => ({ + id: 'att-1', + groupId: 'group-1', + fullName: 'Martín Gómez', + phone: '+5491133445566', + email: null, + guardianName: null, + guardianPhone: null, + notes: null, + createdAt: new Date('2026-09-18T10:00:00.000Z'), + updatedAt: new Date('2026-09-18T10:00:00.000Z'), + ...overrides, +}); + +function makeApp(userValue: unknown) { + const app = new Hono(); + app.use('*', async (c, next) => { + c.set('user', userValue as never); + await next(); + }); + app.route('/groups', groupsRoutes); + return app; +} + +describe('group waitlist management', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('GET /groups/:groupId/waitlist', () => { + it('lists pending entries in FIFO order with pagination', async () => { + prisma.group.findUnique.mockResolvedValue(mockGroup as never); + prisma.groupWaitlistEntry.findMany.mockResolvedValue([ + waitlistEntry({ id: 'wl-1', fullName: 'Ana García', createdAt: new Date('2026-09-08T10:00:00.000Z') }), + waitlistEntry({ id: 'wl-2', fullName: 'Pedro López', createdAt: new Date('2026-09-09T10:00:00.000Z') }), + ] as never); + prisma.groupWaitlistEntry.count.mockResolvedValue(2); + + const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist'); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.data).toHaveLength(2); + expect(data.data[0].fullName).toBe('Ana García'); + expect(data.pagination).toEqual({ page: 1, pageSize: 10, total: 2, totalPages: 1 }); + expect(prisma.groupWaitlistEntry.findMany).toHaveBeenCalledWith({ + where: { groupId: 'group-1', status: 'PENDING' }, + skip: 0, + take: 10, + orderBy: { createdAt: 'asc' }, + }); + }); + + it('rejects user without access to the group', async () => { + prisma.group.findUnique.mockResolvedValue(mockGroup as never); + + const res = await makeApp({ id: 'other-user' }).request('/groups/group-1/waitlist'); + + expect(res.status).toBe(403); + expect(prisma.groupWaitlistEntry.findMany).not.toHaveBeenCalled(); + }); + }); + + describe('POST /groups/:groupId/waitlist/:entryId/promote', () => { + it('creates an attendee from the entry and removes it from the waitlist', async () => { + prisma.group.findUnique.mockResolvedValue(mockGroup as never); + prisma.groupWaitlistEntry.findFirst.mockResolvedValue(waitlistEntry() as never); + prisma.attendee.count.mockResolvedValue(5); + prisma.attendee.findFirst.mockResolvedValue(null); + prisma.attendee.create.mockResolvedValue(attendee() as never); + + const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-1/promote', { + method: 'POST', + }); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.attendee.fullName).toBe('Martín Gómez'); + expect(prisma.attendee.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ groupId: 'group-1', fullName: 'Martín Gómez', phone: '+5491133445566' }), + }); + expect(prisma.groupWaitlistEntry.delete).toHaveBeenCalledWith({ where: { id: 'wl-1' } }); + }); + + it('rejects with 409 when the group reached its capacity', async () => { + prisma.group.findUnique.mockResolvedValue({ ...mockGroup, capacity: 5 } as never); + prisma.groupWaitlistEntry.findFirst.mockResolvedValue(waitlistEntry() as never); + prisma.attendee.count.mockResolvedValue(5); + + const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-1/promote', { + method: 'POST', + }); + + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.code).toBe('group_capacity_reached'); + expect(prisma.attendee.create).not.toHaveBeenCalled(); + expect(prisma.groupWaitlistEntry.delete).not.toHaveBeenCalled(); + }); + + it('rejects with 409 when the phone already belongs to an attendee', async () => { + prisma.group.findUnique.mockResolvedValue(mockGroup as never); + prisma.groupWaitlistEntry.findFirst.mockResolvedValue(waitlistEntry() as never); + prisma.attendee.count.mockResolvedValue(5); + prisma.attendee.findFirst.mockResolvedValue({ id: 'att-existing' } as never); + + const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-1/promote', { + method: 'POST', + }); + + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.code).toBe('attendee_already_registered'); + expect(prisma.attendee.create).not.toHaveBeenCalled(); + }); + + it('returns 404 when the entry does not exist', async () => { + prisma.group.findUnique.mockResolvedValue(mockGroup as never); + prisma.groupWaitlistEntry.findFirst.mockResolvedValue(null); + + const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-missing/promote', { + method: 'POST', + }); + + expect(res.status).toBe(404); + }); + + it('rejects a non-owner user', async () => { + prisma.group.findUnique.mockResolvedValue(mockGroup as never); + + const res = await makeApp({ id: 'other-user' }).request('/groups/group-1/waitlist/wl-1/promote', { + method: 'POST', + }); + + expect(res.status).toBe(403); + expect(prisma.attendee.create).not.toHaveBeenCalled(); + }); + }); + + describe('DELETE /groups/:groupId/waitlist/:entryId', () => { + it('removes a waitlist entry', async () => { + prisma.group.findUnique.mockResolvedValue(mockGroup as never); + prisma.groupWaitlistEntry.findFirst.mockResolvedValue(waitlistEntry() as never); + + const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-1', { + method: 'DELETE', + }); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.deleted).toBe(true); + expect(prisma.groupWaitlistEntry.delete).toHaveBeenCalledWith({ where: { id: 'wl-1' } }); + }); + + it('returns 404 when the entry does not exist', async () => { + prisma.group.findUnique.mockResolvedValue(mockGroup as never); + prisma.groupWaitlistEntry.findFirst.mockResolvedValue(null); + + const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-missing', { + method: 'DELETE', + }); + + expect(res.status).toBe(404); + expect(prisma.groupWaitlistEntry.delete).not.toHaveBeenCalled(); + }); + + it('rejects a non-owner user', async () => { + prisma.group.findUnique.mockResolvedValue(mockGroup as never); + + const res = await makeApp({ id: 'other-user' }).request('/groups/group-1/waitlist/wl-1', { + method: 'DELETE', + }); + + expect(res.status).toBe(403); + expect(prisma.groupWaitlistEntry.delete).not.toHaveBeenCalled(); + }); + }); + + describe('DELETE /groups/:groupId/attendees/:attendeeId', () => { + it('removes an attendee without promotion', async () => { + prisma.group.findUnique.mockResolvedValue(mockGroup as never); + prisma.attendee.findFirst.mockResolvedValue(attendee() as never); + prisma.payment.count.mockResolvedValue(0); + + const res = await makeApp({ id: teacherId }).request('/groups/group-1/attendees/att-1', { + method: 'DELETE', + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.removedAttendeeId).toBe('att-1'); + expect(body.promoted).toBeNull(); + expect(prisma.attendee.delete).toHaveBeenCalledWith({ where: { id: 'att-1' } }); + }); + + it('removes an attendee and promotes the first pending waitlist entry atomically', async () => { + prisma.group.findUnique.mockResolvedValue(mockGroup as never); + prisma.attendee.findFirst.mockImplementation(async ({ where }: { where?: { id?: string } }) => { + if (where?.id) return attendee() as never; + return null; + }); + prisma.payment.count.mockResolvedValue(0); + prisma.groupWaitlistEntry.findFirst.mockResolvedValue( + waitlistEntry({ id: 'wl-1', fullName: 'Sofía Ruiz', phone: '+5491133778899' }) as never, + ); + prisma.attendee.count.mockResolvedValue(5); + prisma.attendee.create.mockResolvedValue( + attendee({ id: 'att-2', fullName: 'Sofía Ruiz', phone: '+5491133778899' }) as never, + ); + + const res = await makeApp({ id: teacherId }).request( + '/groups/group-1/attendees/att-1?promoteFromWaitlist=true', + { method: 'DELETE' }, + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.removedAttendeeId).toBe('att-1'); + expect(body.promoted).toMatchObject({ id: 'wl-1', fullName: 'Sofía Ruiz' }); + expect(prisma.attendee.delete).toHaveBeenCalledWith({ where: { id: 'att-1' } }); + expect(prisma.groupWaitlistEntry.delete).toHaveBeenCalledWith({ where: { id: 'wl-1' } }); + expect(prisma.attendee.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ fullName: 'Sofía Ruiz', phone: '+5491133778899' }), + }); + }); + + it('removes the attendee when promote requested but waitlist is empty', async () => { + prisma.group.findUnique.mockResolvedValue(mockGroup as never); + prisma.attendee.findFirst.mockResolvedValue(attendee() as never); + prisma.payment.count.mockResolvedValue(0); + prisma.groupWaitlistEntry.findFirst.mockResolvedValue(null); + + const res = await makeApp({ id: teacherId }).request( + '/groups/group-1/attendees/att-1?promoteFromWaitlist=true', + { method: 'DELETE' }, + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.removedAttendeeId).toBe('att-1'); + expect(body.promoted).toBeNull(); + expect(prisma.attendee.create).not.toHaveBeenCalled(); + }); + + it('blocks removal when the attendee has payments', async () => { + prisma.group.findUnique.mockResolvedValue(mockGroup as never); + prisma.attendee.findFirst.mockResolvedValue(attendee() as never); + prisma.payment.count.mockResolvedValue(2); + + const res = await makeApp({ id: teacherId }).request('/groups/group-1/attendees/att-1', { + method: 'DELETE', + }); + + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.code).toBe('attendee_has_payments'); + expect(prisma.attendee.delete).not.toHaveBeenCalled(); + }); + + it('returns 404 when the attendee does not belong to the group', async () => { + prisma.group.findUnique.mockResolvedValue(mockGroup as never); + prisma.attendee.findFirst.mockResolvedValue(null); + + const res = await makeApp({ id: teacherId }).request('/groups/group-1/attendees/att-missing', { + method: 'DELETE', + }); + + expect(res.status).toBe(404); + expect(prisma.attendee.delete).not.toHaveBeenCalled(); + }); + + it('rejects a non-owner user', async () => { + prisma.group.findUnique.mockResolvedValue(mockGroup as never); + + const res = await makeApp({ id: 'other-user' }).request('/groups/group-1/attendees/att-1', { + method: 'DELETE', + }); + + expect(res.status).toBe(403); + expect(prisma.attendee.delete).not.toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 6da61ab..61d962f 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -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(`/api/v1/groups/${groupId}/attendees?page=${page}&pageSize=${pageSize}`) \ No newline at end of file + apiFetch(`/api/v1/groups/${groupId}/attendees?page=${page}&pageSize=${pageSize}`) + +export const getGroupWaitlist = (groupId: string, page = 1, pageSize = 100) => + apiFetch(`/api/v1/groups/${groupId}/waitlist?page=${page}&pageSize=${pageSize}`) + +export const promoteGroupWaitlistEntry = (groupId: string, entryId: string) => + apiFetch(`/api/v1/groups/${groupId}/waitlist/${entryId}/promote`, { + method: 'POST', + }) + +export const removeGroupWaitlistEntry = (groupId: string, entryId: string) => + apiFetch(`/api/v1/groups/${groupId}/waitlist/${entryId}`, { + method: 'DELETE', + }) + +export const removeGroupAttendee = ( + groupId: string, + attendeeId: string, + options?: { promoteFromWaitlist?: boolean }, +) => + apiFetch( + `/api/v1/groups/${groupId}/attendees/${attendeeId}${options?.promoteFromWaitlist ? '?promoteFromWaitlist=true' : ''}`, + { + method: 'DELETE', + }, + ) \ No newline at end of file diff --git a/apps/web/src/routes/group-detail.tsx b/apps/web/src/routes/group-detail.tsx index 7e09c27..28de332 100644 --- a/apps/web/src/routes/group-detail.tsx +++ b/apps/web/src/routes/group-detail.tsx @@ -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(null); const [activeTab, setActiveTab] = useState<'quick' | 'bulk'>('quick'); + const [listSection, setListSection] = useState<'members' | 'waitlist'>('members'); + const [attendeeToRemove, setAttendeeToRemove] = useState(null); + const [selectedWaitlistEntry, setSelectedWaitlistEntry] = useState(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(['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(['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(['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() {
Volver a grupos @@ -428,9 +542,10 @@ export function GroupDetailView() {
-

Miembros & Cupo

+

Miembros & Cupo

- {attendees.length} inscritos {group.capacity ? `· Cupo de ${group.capacity}` : ''} + {attendees.length} inscritos {waitlistTotal > 0 ? ` · ${waitlistTotal} en espera` : ''} + {group.capacity ? ` · Cupo de ${group.capacity}` : ''}

@@ -450,108 +565,201 @@ export function GroupDetailView() {
- {/* Attendees Management Section */} + {/* Miembros & Lista de espera */}
-
-
-

- Miembros del Grupo ({attendees.length}) -

-

- Listado de todos los miembros incorporados al grupo. -

+
+
+
+ + +
+ + {listSection === 'members' ? ( +
+ + setSearchFilter(e.target.value)} + className="pl-9 h-9 text-xs" + /> +
+ ) : null}
-
- - setSearchFilter(e.target.value)} - className="pl-9 h-9 text-xs" - /> -
+

+ {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.'} +

- {attendeesQuery.isPending ? ( -
- -
- ) : null} + {listSection === 'members' ? ( + <> + {attendeesQuery.isPending ? ( +
+ +
+ ) : null} - {attendeesQuery.isSuccess && attendees.length === 0 ? ( -
- -

Todavía no hay miembros en este grupo

-

- Puedes compartir el enlace de invitación único o agregar miembros de forma manual o masiva. -

-
- - -
-
- ) : null} - - {attendeesQuery.isSuccess && attendees.length > 0 && filteredAttendees.length === 0 ? ( -
- No se encontraron miembros que coincidan con la búsqueda. -
- ) : null} - - {filteredAttendees.length > 0 ? ( -
- - - - - - - - - {filteredAttendees.map((attendee) => ( - setSelectedAttendee(attendee)} + {attendeesQuery.isSuccess && attendees.length === 0 ? ( +
+ +

Todavía no hay miembros en este grupo

+

+ Puedes compartir el enlace de invitación único o agregar miembros de forma manual o masiva. +

+
+
- - - - ))} - -
NombreTeléfono -
-
-
- {attendee.fullName.charAt(0).toUpperCase()} -
- {attendee.fullName} -
-
- {attendee.phone || —} - - -
+ + Compartir Enlace + + +
+
+ ) : null} + + {attendeesQuery.isSuccess && attendees.length > 0 && filteredAttendees.length === 0 ? ( +
+ No se encontraron miembros que coincidan con la búsqueda. +
+ ) : null} + + {filteredAttendees.length > 0 ? ( +
+ + + + + + + + + {filteredAttendees.map((attendee) => ( + setSelectedAttendee(attendee)} + > + + + + + ))} + +
NombreTeléfono +
+
+
+ {attendee.fullName.charAt(0).toUpperCase()} +
+ {attendee.fullName} +
+
+ {attendee.phone || —} + + +
+
+ ) : null} + + ) : ( +
+ {waitlistQuery.isPending ? ( +
+ +
+ ) : null} + + {waitlistQuery.isSuccess && waitlistEntries.length === 0 ? ( +
+ +

No hay nadie en la lista de espera

+

+ Cuando el grupo alcance su cupo, las personas podrán sumarse a la espera y podrás pasarlas al grupo desde aquí. +

+
+ ) : null} + + {waitlistEntries.length > 0 ? ( +
+ + + + + + + + + {waitlistEntries.map((entry) => ( + setSelectedWaitlistEntry(entry)} + > + + + + + ))} + +
NombreTeléfono +
+
+
+ {entry.fullName.charAt(0).toUpperCase()} +
+
+

{entry.fullName}

+ {entry.notes ? ( +

{entry.notes}

+ ) : null} +
+
+
+ {entry.phone} + + +
+
+ ) : null}
- ) : null} + )}
{/* MODAL: COMPARTIR LINK DE INVITACION */} @@ -1151,6 +1359,233 @@ export function GroupDetailView() { )}
+ +
+ +
+ + ) : null} + + {/* MODAL: CONFIRMAR QUITAR MIEMBRO */} + setAttendeeToRemove(null)} + title="Quitar del grupo" + maxWidth="md" + > + {attendeeToRemove ? ( +
+

+ {attendeeToRemove.fullName} dejará de ser miembro del grupo{' '} + {group.name} + {firstWaitlistEntry ? ' y el cupo quedará libre.' : '.'} + {attendeeToRemove.phone ? ( + + Teléfono: {attendeeToRemove.phone} + + ) : null} +

+ + {firstWaitlistEntry ? ( +
+
+ + + Hay {waitlistTotal} persona{waitlistTotal === 1 ? '' : 's'} esperando un cupo + +
+

+ ¿Quieres pasar a{' '} + {firstWaitlistEntry.fullName}, el primero de + la lista de espera, al grupo? +

+
+ + +
+
+ ) : ( +
+ + +
+ )} +
+ ) : null} +
+ {/* MODAL: DETALLE LISTA DE ESPERA */} + setSelectedWaitlistEntry(null)} + title="Detalle de la lista de espera" + maxWidth="sm" + > + {selectedWaitlistEntry ? ( +
+ {/* Header: avatar + name */} +
+
+ {selectedWaitlistEntry.fullName.charAt(0).toUpperCase()} +
+
+

+ {selectedWaitlistEntry.fullName} +

+

+ En espera desde el{' '} + {new Date(selectedWaitlistEntry.createdAt).toLocaleDateString('es-ES', { + day: 'numeric', + month: 'long', + year: 'numeric', + })} +

+
+
+ + {/* Info rows */} +
+ } label="Teléfono"> + {selectedWaitlistEntry.phone ? ( +
+ {selectedWaitlistEntry.phone} + e.stopPropagation()} + > + + WhatsApp + +
+ ) : ( + Sin teléfono + )} +
+ + {selectedWaitlistEntry.email ? ( + } label="Email"> + e.stopPropagation()} + > + {selectedWaitlistEntry.email} + + + ) : null} + + } label="Notas"> + {selectedWaitlistEntry.notes ? ( + + {selectedWaitlistEntry.notes} + + ) : ( + Sin notas + )} + +
+ + {/* Footer: status + actions */} + {!hasFreeCapacity ? ( +

+ El grupo alcanzó su cupo de miembros. Quita un miembro o aumenta el cupo para poder pasar + a esta persona al grupo. +

+ ) : null} +
+ + +
) : null}
diff --git a/packages/shared/src/schemas/attendees.ts b/packages/shared/src/schemas/attendees.ts index cbca23a..9886c9d 100644 --- a/packages/shared/src/schemas/attendees.ts +++ b/packages/shared/src/schemas/attendees.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { createPageSchema, createPageSizeSchema, createPaginationSchema } from './pagination.js'; +import { GroupWaitlistEntryDtoSchema } from './waitlist.js'; const isoDateTimeSchema = z.string().datetime(); const pageSchema = createPageSchema(); @@ -92,4 +93,20 @@ export const BulkCreateAttendeesResultSchema = z.object({ message: z.string(), created: z.array(AttendeeDtoSchema), }); -export type BulkCreateAttendeesResult = z.output; \ No newline at end of file +export type BulkCreateAttendeesResult = z.output; + +export const PromoteGroupWaitlistEntryResultSchema = z.object({ + attendee: AttendeeDtoSchema, +}); +export type PromoteGroupWaitlistEntryResult = z.output; + +export const RemoveAttendeeQuerySchema = z.object({ + promoteFromWaitlist: z.coerce.boolean().optional(), +}); +export type RemoveAttendeeQuery = z.output; + +export const RemoveAttendeeResultSchema = z.object({ + removedAttendeeId: z.string(), + promoted: GroupWaitlistEntryDtoSchema.nullable(), +}); +export type RemoveAttendeeResult = z.output; \ No newline at end of file diff --git a/packages/shared/src/schemas/waitlist.ts b/packages/shared/src/schemas/waitlist.ts index 42e8395..5d32184 100644 --- a/packages/shared/src/schemas/waitlist.ts +++ b/packages/shared/src/schemas/waitlist.ts @@ -50,4 +50,21 @@ export const CreateGroupWaitlistEntrySchema = z.object({ email: z.string().trim().email('Email inválido').optional().or(z.literal('')), notes: z.string().trim().optional(), }); -export type CreateGroupWaitlistEntry = z.output; \ No newline at end of file +export type CreateGroupWaitlistEntry = z.output; + +export const GroupWaitlistQuerySchema = z.object({ + page: pageSchema, + pageSize: pageSizeSchema, +}); +export type GroupWaitlistQuery = z.output; + +export const GroupWaitlistListSchema = z.object({ + data: z.array(GroupWaitlistEntryDtoSchema), + pagination: paginationSchema, +}); +export type GroupWaitlistList = z.output; + +export const RemoveGroupWaitlistEntryResultSchema = z.object({ + deleted: z.boolean(), +}); +export type RemoveGroupWaitlistEntryResult = z.output; \ No newline at end of file