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:
@@ -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',
|
||||
});
|
||||
}
|
||||
24
apps/backend/src/modules/attendees/features/remove/route.ts
Normal file
24
apps/backend/src/modules/attendees/features/remove/route.ts
Normal file
@@ -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;
|
||||
@@ -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<PrismaClient, 'group' | 'attendee' | 'payment'>;
|
||||
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<Result<RemoveAttendeeResult, ProblemDetails>> {
|
||||
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<Result<RemoveAttendeeResult, ProblemDetails>> => {
|
||||
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),
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<PrismaClient, 'group' | 'groupWaitlistEntry'>;
|
||||
};
|
||||
|
||||
export class ListGroupWaitlistEntries {
|
||||
constructor(private readonly deps: ListGroupWaitlistEntriesDeps = {}) {}
|
||||
|
||||
async execute(
|
||||
groupId: string,
|
||||
userId: string,
|
||||
query: GroupWaitlistQuery,
|
||||
): Promise<Result<GroupWaitlistList, ProblemDetails>> {
|
||||
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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<PrismaClient, 'group' | 'attendee' | 'groupWaitlistEntry'>;
|
||||
unitOfWork?: UnitOfWork;
|
||||
};
|
||||
|
||||
export class PromoteGroupWaitlistEntry {
|
||||
constructor(private readonly deps: PromoteGroupWaitlistEntryDeps = {}) {}
|
||||
|
||||
async execute(
|
||||
groupId: string,
|
||||
entryId: string,
|
||||
userId: string,
|
||||
): Promise<Result<PromoteGroupWaitlistEntryResult, ProblemDetails>> {
|
||||
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 });
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<PrismaClient, 'group' | 'groupWaitlistEntry'>;
|
||||
};
|
||||
|
||||
export class RemoveGroupWaitlistEntry {
|
||||
constructor(private readonly deps: RemoveGroupWaitlistEntryDeps = {}) {}
|
||||
|
||||
async execute(
|
||||
groupId: string,
|
||||
entryId: string,
|
||||
userId: string,
|
||||
): Promise<Result<RemoveGroupWaitlistEntryResult, ProblemDetails>> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
1
apps/backend/src/modules/group-waitlist/index.ts
Normal file
1
apps/backend/src/modules/group-waitlist/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as groupWaitlistRoutes } from './routes';
|
||||
65
apps/backend/src/modules/group-waitlist/lib/helpers.ts
Normal file
65
apps/backend/src/modules/group-waitlist/lib/helpers.ts
Normal file
@@ -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<PrismaClient, 'group'>,
|
||||
groupId: string,
|
||||
userId: string,
|
||||
): Promise<Result<Group, ProblemDetails>> {
|
||||
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<PrismaClient, 'group'>,
|
||||
groupId: string,
|
||||
userId: string,
|
||||
): Promise<Result<Group, ProblemDetails>> {
|
||||
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);
|
||||
}
|
||||
83
apps/backend/src/modules/group-waitlist/lib/promote.ts
Normal file
83
apps/backend/src/modules/group-waitlist/lib/promote.ts
Normal file
@@ -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<PrismaClient, 'attendee' | 'groupWaitlistEntry'>;
|
||||
|
||||
type PromoteGroup = {
|
||||
id: string;
|
||||
capacity: number | null;
|
||||
};
|
||||
|
||||
export async function findFirstPendingWaitlistEntry(
|
||||
db: PromoteDb,
|
||||
groupId: string,
|
||||
): Promise<GroupWaitlistEntryRecord | null> {
|
||||
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<Result<AttendeeDto, ProblemDetails>> {
|
||||
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<Result<PromoteFirstResult | null, ProblemDetails>> {
|
||||
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 });
|
||||
}
|
||||
12
apps/backend/src/modules/group-waitlist/routes.ts
Normal file
12
apps/backend/src/modules/group-waitlist/routes.ts
Normal file
@@ -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;
|
||||
@@ -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;
|
||||
352
apps/backend/test/waitlist-management.test.ts
Normal file
352
apps/backend/test/waitlist-management.test.ts
Normal file
@@ -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<unknown>) => cb(db));
|
||||
execute = mock(async (cb: (tx: unknown) => Promise<unknown>) => 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<string, unknown> = {}) => ({
|
||||
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<string, unknown> = {}) => ({
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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<typeof BulkCreateAttendeesResultSchema>;
|
||||
export type BulkCreateAttendeesResult = z.output<typeof BulkCreateAttendeesResultSchema>;
|
||||
|
||||
export const PromoteGroupWaitlistEntryResultSchema = z.object({
|
||||
attendee: AttendeeDtoSchema,
|
||||
});
|
||||
export type PromoteGroupWaitlistEntryResult = z.output<typeof PromoteGroupWaitlistEntryResultSchema>;
|
||||
|
||||
export const RemoveAttendeeQuerySchema = z.object({
|
||||
promoteFromWaitlist: z.coerce.boolean().optional(),
|
||||
});
|
||||
export type RemoveAttendeeQuery = z.output<typeof RemoveAttendeeQuerySchema>;
|
||||
|
||||
export const RemoveAttendeeResultSchema = z.object({
|
||||
removedAttendeeId: z.string(),
|
||||
promoted: GroupWaitlistEntryDtoSchema.nullable(),
|
||||
});
|
||||
export type RemoveAttendeeResult = z.output<typeof RemoveAttendeeResultSchema>;
|
||||
@@ -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<typeof CreateGroupWaitlistEntrySchema>;
|
||||
export type CreateGroupWaitlistEntry = z.output<typeof CreateGroupWaitlistEntrySchema>;
|
||||
|
||||
export const GroupWaitlistQuerySchema = z.object({
|
||||
page: pageSchema,
|
||||
pageSize: pageSizeSchema,
|
||||
});
|
||||
export type GroupWaitlistQuery = z.output<typeof GroupWaitlistQuerySchema>;
|
||||
|
||||
export const GroupWaitlistListSchema = z.object({
|
||||
data: z.array(GroupWaitlistEntryDtoSchema),
|
||||
pagination: paginationSchema,
|
||||
});
|
||||
export type GroupWaitlistList = z.output<typeof GroupWaitlistListSchema>;
|
||||
|
||||
export const RemoveGroupWaitlistEntryResultSchema = z.object({
|
||||
deleted: z.boolean(),
|
||||
});
|
||||
export type RemoveGroupWaitlistEntryResult = z.output<typeof RemoveGroupWaitlistEntryResultSchema>;
|
||||
Reference in New Issue
Block a user