Compare commits

...

5 Commits

Author SHA1 Message Date
17dca1ad89 Merge pull request 'feat/waiting-list' (#3) from feat/waiting-list into development
Reviewed-on: #3
2026-09-23 12:36:46 +00:00
Jose Selesan
c1b99804b3 Removed "Back" link in create group page 2026-09-23 09:28:29 -03:00
Jose Selesan
5572ef0869 feat(groups): implement group creation functionality and refactor related components
- Removed organization dependency from group creation logic.
- Introduced new route and use-case for creating groups.
- Refactored form handling for group creation into a reusable GroupForm component.
- Updated API calls to support new group creation endpoint.
- Adjusted tests to reflect changes in group creation logic and validation.
- Removed obsolete organizations route and related components.
- Updated breadcrumb navigation and routing for group management.
2026-09-23 09:19:52 -03:00
Jose Selesan
fc30927b8b 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
2026-09-22 17:04:41 -03:00
Jose Selesan
785d54df7e feat: add group waitlist functionality
- Introduced GroupWaitlistEntry model in Prisma schema to manage waitlisted users for groups.
- Implemented API route to add users to the group waitlist with validation.
- Enhanced attendee creation logic to handle group capacity and waitlisting scenarios.
- Added new problem builders for handling waitlist-related errors.
- Updated frontend to support waitlist interactions, including modals for capacity warnings.
- Created tests for waitlist functionality, ensuring proper handling of full groups and existing waitlist entries.
2026-09-22 16:28:54 -03:00
45 changed files with 2718 additions and 774 deletions

View File

@@ -31,7 +31,7 @@ bun --filter @gruperly/backend db:* # db:generate / db:migrate / db:push
- `src/lib/` — `prisma.ts` (`getPrismaClient`, proxy `default` y clase `UnitOfWork`), `pagination.ts` (offset/metadata), `email.ts`, `error-message.ts`.
- `src/logger.ts` — pino + pino-pretty.
- Módulos existentes: `health-check`, `auth` (**Better Auth 1.7.2 público**, montado en `/api/v1/auth` vía `basePath` del server; cookie de sesión con `path: "/"`), `groups`, `attendees`, `payments`, `waitlist` (listados paginados `{ data, pagination }`).
- `apps/web` — Frontend React 19 + Vite + Tailwind v4. Entry `src/main.tsx` → `src/router.tsx`. Puerto **6173** (`vite.config.ts`). Auth client con `basePath: '/api/v1/auth'` (`src/lib/auth-client.ts`); el backend llama a `/api/v1/groups/from-organization` (`src/routes/organizations.tsx`).
- `apps/web` — Frontend React 19 + Vite + Tailwind v4. Entry `src/main.tsx` → `src/router.tsx`. Puerto **6173** (`vite.config.ts`). Auth client con `basePath: '/api/v1/auth'` (`src/lib/auth-client.ts`).
- `packages/shared` — Esquemas Zod (v3.24) + tipos + `Result` + Problem Details. **Se consume como TS fuente directo** (`exports` apunta a `src/index.ts`, sin build previo); se resuelve vía el symlink de bun en `node_modules` (`@gruperly/shared` no está en `paths` de los tsconfig). El `paths` de los tsconfig solo mapea `@/*` → `src/*` y `@generated/*` → `generated/*`.
- `packages/config` — `tsconfig.base.json`; tsconfigs lo extienden con `"extends": "@gruperly/config/tsconfig.base.json"` (por eso `@gruperly/config` es devDependency de cada paquete).
@@ -52,6 +52,7 @@ bun --filter @gruperly/backend db:* # db:generate / db:migrate / db:push
- Nav (Inicio/Grupos/Cobros/Ajustes) vive en `apps/web/src/components/layout/nav-items.ts`; es la fuente única para `BottomNav` (móvil) y `Sidebar` (desktop) — no dupliques la lista.
- **UI**: primitivos propios en `apps/web/src/components/ui/` (avatar, button, badge) más helper `cn()` en `src/lib/utils.ts` (clsx + tailwind-merge). Aunque `stack.md` mencione Shadcn, **todavía no está instalado** (sin Radix); úsalos directos.
- Layout mobile-first: `RootLayout` usa columna `max-w-md` en móvil y dos columnas (Sidebar + contenido `max-w-6xl`) en `lg+`.
- **Sin "volver atrás" en mobile**: en ninguna vista se muestra un link/botón de navegación a la página anterior cuando se ve en mobile (se apoya en el gesto de back del dispositivo). Los links de "volver" solo en desktop: usar `hidden ... lg:inline-flex` (p. ej. `create-group`, `group-detail`) o `hidden lg:flex` (`Breadcrumb`). No confundir con botones "Volver" de pasos dentro de un wizard/formulario: esos sí se mantienen.
## Fuentes de contexto

View File

@@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "group_waitlist_entries" (
"id" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"fullName" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"email" TEXT,
"notes" TEXT,
"status" "WaitlistStatus" NOT NULL DEFAULT 'PENDING',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "group_waitlist_entries_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "group_waitlist_entries_groupId_idx" ON "group_waitlist_entries"("groupId");
-- CreateIndex
CREATE UNIQUE INDEX "group_waitlist_entries_groupId_phone_key" ON "group_waitlist_entries"("groupId", "phone");
-- AddForeignKey
ALTER TABLE "group_waitlist_entries" ADD CONSTRAINT "group_waitlist_entries_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "groups"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -19,6 +19,7 @@ model Group {
members GroupMember[]
attendees Attendee[]
payments Payment[]
waitlist GroupWaitlistEntry[]
@@map("groups")
}
@@ -120,6 +121,24 @@ model WaitlistEntry {
@@map("waitlist_entries")
}
model GroupWaitlistEntry {
id String @id @default(cuid())
groupId String
fullName String
phone String
email String?
notes String?
status WaitlistStatus @default(PENDING) // PENDING, INVITED, JOINED, DECLINED
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
@@unique([groupId, phone])
@@index([groupId])
@@map("group_waitlist_entries")
}
enum WaitlistStatus {
PENDING
INVITED

View File

@@ -47,23 +47,6 @@ export function forbiddenProblem(params: {
};
}
export function organizationNotFoundProblem(id: string): ProblemDetails {
return {
type: `${PROBLEM_DOMAIN}/problems/organization-not-found`,
title: 'Not Found',
status: 404,
detail: `Organization ${id} was not found.`,
code: 'organization_not_found',
};
}
export function groupOwnerRequiredProblem(): ProblemDetails {
return forbiddenProblem({
detail: 'Only the organization owner can create the group.',
code: 'group_owner_required',
});
}
export function databaseUnavailableProblem(params?: {
instance?: string;
}): ProblemDetails {
@@ -100,4 +83,27 @@ export function onboardingAlreadyCompletedProblem(): ProblemDetails {
detail: 'Ya completaste el onboarding de Gruperly.',
code: 'onboarding_already_completed',
});
}
export function capacityReachedProblem(capacity: number | null): ProblemDetails {
return conflictProblem({
detail: capacity
? `El grupo alcanzó su cupo máximo de ${capacity} miembros.`
: 'El grupo alcanzó su cupo máximo de miembros.',
code: 'group_capacity_reached',
});
}
export function alreadyWaitlistedProblem(): ProblemDetails {
return conflictProblem({
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',
});
}

View File

@@ -0,0 +1,21 @@
import { type CreateGroupWaitlistEntry, CreateGroupWaitlistEntrySchema } from '@gruperly/shared';
import { Hono } from 'hono';
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
import { validate } from '@/http/validate';
import { AddToGroupWaitlist } from './use-case';
const route = new Hono();
route.post('/:groupId/waitlist', validate.json(CreateGroupWaitlistEntrySchema), async (c) => {
const user = c.get('user');
if (!user) {
return problemJson(c, unauthorizedProblem(c.req.path));
}
const groupId = c.req.param('groupId');
const body = c.req.valid('json') as CreateGroupWaitlistEntry;
const useCase = new AddToGroupWaitlist();
const result = await useCase.execute(groupId, user.id, body);
return resultJson(c, result, { status: 201 });
});
export default route;

View File

@@ -0,0 +1,112 @@
import type { PrismaClient } from '@generated/prisma/client';
import type { CreateGroupWaitlistEntry, GroupWaitlistEntryDto, ProblemDetails, Result } from '@gruperly/shared';
import { err, ok } from '@gruperly/shared';
import {
alreadyWaitlistedProblem,
conflictProblem,
noGroupAccessProblem,
notFoundResourceProblem,
} from '@/http/problem-builders';
import prisma from '@/lib/prisma';
import { normalizePhone } from '../../lib/helpers';
type AddToGroupWaitlistDeps = {
db?: Pick<PrismaClient, 'group' | 'attendee' | 'groupWaitlistEntry'>;
};
type GroupWaitlistRecord = {
id: string;
groupId: string;
fullName: string;
phone: string;
email: string | null;
notes: string | null;
status: GroupWaitlistEntryDto['status'];
createdAt: Date;
updatedAt: Date;
};
function toGroupWaitlistEntryDto(record: GroupWaitlistRecord): 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 class AddToGroupWaitlist {
constructor(private readonly deps: AddToGroupWaitlistDeps = {}) {}
async execute(
groupId: string,
userId: string,
payload: CreateGroupWaitlistEntry,
): Promise<Result<GroupWaitlistEntryDto, ProblemDetails>> {
const db = this.deps.db ?? prisma;
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());
}
const phone = normalizePhone(payload.phone);
const existingAttendee = await db.attendee.findFirst({
where: {
groupId,
OR: [
{ phone },
{ phone: payload.phone.trim() },
],
},
});
if (existingAttendee) {
return err(
conflictProblem({
detail: 'Ya existe un alumno registrado con este número de teléfono en este grupo.',
code: 'attendee_already_registered',
}),
);
}
const existingWaitlistEntry = await db.groupWaitlistEntry.findFirst({
where: { groupId, phone },
});
if (existingWaitlistEntry) {
return err(alreadyWaitlistedProblem());
}
const fullName = `${payload.firstName.trim()} ${payload.lastName.trim()}`.trim();
const entry = await db.groupWaitlistEntry.create({
data: {
groupId,
fullName,
phone,
email: payload.email?.trim() || null,
notes: payload.notes?.trim() || null,
},
});
return ok(toGroupWaitlistEntryDto(entry as unknown as GroupWaitlistRecord));
}
}

View File

@@ -13,8 +13,9 @@ route.post('/:groupId/attendees', validate.json(CreateAttendeeSchema), async (c)
}
const groupId = c.req.param('groupId');
const body = c.req.valid('json') as CreateAttendee;
const allowOverflow = c.req.query('allowOverflow') === 'true';
const useCase = new CreateAttendeeUseCase();
const result = await useCase.execute(groupId, user.id, body);
const result = await useCase.execute(groupId, user.id, body, { allowOverflow });
return resultJson(c, result, { status: 201 });
});

View File

@@ -1,12 +1,22 @@
import type { PrismaClient } from '@generated/prisma/client';
import type { AttendeeDto, CreateAttendee, ProblemDetails, Result } from '@gruperly/shared';
import type { CreateAttendee, CreateAttendeeResult, ProblemDetails, Result } from '@gruperly/shared';
import { err, ok } from '@gruperly/shared';
import { conflictProblem, noGroupAccessProblem, notFoundResourceProblem } from '@/http/problem-builders';
import {
alreadyWaitlistedProblem,
capacityReachedProblem,
conflictProblem,
noGroupAccessProblem,
notFoundResourceProblem,
} from '@/http/problem-builders';
import prisma from '@/lib/prisma';
import { type AttendeeRecord, normalizePhone, toAttendeeDto } from '../../lib/helpers';
type CreateAttendeeDeps = {
db?: Pick<PrismaClient, 'group' | 'attendee'>;
db?: Pick<PrismaClient, 'group' | 'attendee' | 'groupWaitlistEntry'>;
};
type CreateAttendeeOptions = {
allowOverflow?: boolean;
};
export class CreateAttendeeUseCase {
@@ -16,7 +26,8 @@ export class CreateAttendeeUseCase {
groupId: string,
userId: string,
payload: CreateAttendee,
): Promise<Result<AttendeeDto, ProblemDetails>> {
options: CreateAttendeeOptions = {},
): Promise<Result<CreateAttendeeResult, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const group = await db.group.findUnique({
@@ -57,6 +68,42 @@ export class CreateAttendeeUseCase {
}
const fullName = `${payload.firstName.trim()} ${payload.lastName.trim()}`.trim();
if (group.capacity !== null) {
const currentCount = await db.attendee.count({ where: { groupId } });
if (currentCount >= group.capacity) {
if (isOwner && !options.allowOverflow) {
return err(capacityReachedProblem(group.capacity));
}
if (!isOwner) {
const existingWaitlistEntry = await db.groupWaitlistEntry.findFirst({
where: { groupId, phone },
});
if (existingWaitlistEntry) {
return err(alreadyWaitlistedProblem());
}
await db.groupWaitlistEntry.create({
data: {
groupId,
fullName,
phone,
email: payload.email?.trim() || null,
notes: payload.notes?.trim() || null,
},
});
return ok({
outcome: 'waitlisted',
message: `${fullName} fue agregado a la lista de espera del grupo.`,
});
}
}
}
const attendee = await db.attendee.create({
data: {
groupId,
@@ -67,6 +114,9 @@ export class CreateAttendeeUseCase {
},
});
return ok(toAttendeeDto(attendee as unknown as AttendeeRecord));
return ok({
outcome: 'created',
attendee: toAttendeeDto(attendee as unknown as AttendeeRecord),
});
}
}
}

View 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;

View File

@@ -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),
});
},
);
}
}

View File

@@ -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;

View File

@@ -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),
});
}
}

View File

@@ -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;

View File

@@ -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 });
},
);
}
}

View File

@@ -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;

View File

@@ -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 });
}
}

View File

@@ -0,0 +1 @@
export { default as groupWaitlistRoutes } from './routes';

View 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);
}

View 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 });
}

View 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;

View File

@@ -1,27 +0,0 @@
import type { CreateGroupFromOrganization } from '@gruperly/shared';
import { CreateGroupFromOrganizationSchema } from '@gruperly/shared';
import { Hono } from 'hono';
import { problemJson, unauthorizedProblem } from '@/http/problem-details';
import { validate } from '@/http/validate';
import { CreateGroupFromOrganization as UseCase } from './use-case';
const route = new Hono();
route.post('/', validate.json(CreateGroupFromOrganizationSchema), async (c) => {
const user = c.get('user');
if (!user) {
return problemJson(c, unauthorizedProblem(c.req.path));
}
const data = c.req.valid('json') as CreateGroupFromOrganization;
const useCase = new UseCase();
const result = await useCase.execute(data, user.id);
if (!result.ok) {
return problemJson(c, result.error);
}
return c.json(result.value, result.value.alreadyExists ? 200 : 201);
});
export default route;

View File

@@ -1,83 +0,0 @@
import type { Prisma } from '@generated/prisma/client';
import { Role } from '@generated/prisma/client';
import type {
CreateGroupFromOrganization as CreateGroupFromOrganizationInput,
CreateGroupFromOrganizationResult,
ProblemDetails,
Result,
} from '@gruperly/shared';
import { err, ok } from '@gruperly/shared';
import {
groupOwnerRequiredProblem,
organizationNotFoundProblem,
} from '@/http/problem-builders';
import { default as prisma, UnitOfWork } from '@/lib/prisma';
import { type GroupDb, type GroupRecord, toGroupDto } from '../../lib';
type CreateGroupFromOrganizationDeps = {
db?: Pick<GroupDb, 'group' | 'groupMember' | 'organization'>;
unitOfWork?: UnitOfWork;
};
export class CreateGroupFromOrganization {
constructor(private readonly deps: CreateGroupFromOrganizationDeps = {}) {}
async execute(
data: CreateGroupFromOrganizationInput,
userId: string,
): Promise<Result<CreateGroupFromOrganizationResult, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const unitOfWork = this.deps.unitOfWork ?? new UnitOfWork(prisma);
const organization = await db.organization.findUnique({
where: { id: data.organizationId },
include: { members: true },
});
if (!organization) {
return err(organizationNotFoundProblem(data.organizationId));
}
const membership = organization.members.find((member: { userId: string; role: string }) => member.userId === userId);
if (membership?.role !== 'owner') {
return err(groupOwnerRequiredProblem());
}
const existing = await db.group.findFirst({
where: { createdById: userId, name: organization.name },
});
if (existing) {
return ok({ group: toGroupDto(existing), alreadyExists: true });
}
const transaction = unitOfWork.executeResult(
async (tx: Prisma.TransactionClient) => {
const created = await tx.group.create({
data: {
name: organization.name,
createdById: userId,
},
});
await tx.groupMember.create({
data: {
groupId: created.id,
userId,
role: Role.OWNER,
},
});
return ok(created);
},
);
const result = await transaction;
if (!result.ok) {
return result;
}
return ok({
group: toGroupDto(result.value as GroupRecord),
alreadyExists: false,
});
}
}

View File

@@ -0,0 +1,22 @@
import type { CreateFirstGroup } from '@gruperly/shared';
import { CreateFirstGroupSchema } from '@gruperly/shared';
import { Hono } from 'hono';
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
import { validate } from '@/http/validate';
import { CreateGroup as CreateGroupUseCase } from './use-case';
const route = new Hono();
route.post('/', validate.json(CreateFirstGroupSchema), async (c) => {
const user = c.get('user');
if (!user) {
return problemJson(c, unauthorizedProblem(c.req.path));
}
const data = c.req.valid('json') as CreateFirstGroup;
const useCase = new CreateGroupUseCase();
const result = await useCase.execute(data, user.id);
return resultJson(c, result, { status: 201 });
});
export default route;

View File

@@ -0,0 +1,68 @@
import type { Prisma, PrismaClient } from '@generated/prisma/client';
import { Role } from '@generated/prisma/client';
import type {
CreateFirstGroup as CreateGroupInput,
CreateGroupResult,
ProblemDetails,
Result,
} from '@gruperly/shared';
import { err, ok } from '@gruperly/shared';
import { paymentNotSetupProblem } from '@/http/problem-builders';
import { default as prisma, UnitOfWork } from '@/lib/prisma';
import { type GroupRecord, toGroupDto } from '../../lib';
type CreateGroupDeps = {
db?: Pick<PrismaClient, 'group' | 'groupMember' | 'merchantAccount'>;
unitOfWork?: UnitOfWork;
};
export class CreateGroup {
constructor(private readonly deps: CreateGroupDeps = {}) {}
async execute(
data: CreateGroupInput,
userId: string,
): Promise<Result<CreateGroupResult, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const unitOfWork = this.deps.unitOfWork ?? new UnitOfWork(prisma);
const merchantAccount = await db.merchantAccount.findUnique({ where: { userId } });
if (!merchantAccount) {
return err(paymentNotSetupProblem());
}
const transaction = unitOfWork.executeResult(
async (tx: Prisma.TransactionClient) => {
const created = await tx.group.create({
data: {
name: data.name,
createdById: userId,
days: data.days,
time: data.time,
capacity: data.capacity,
price: data.price,
billingType: data.billingType,
dueDay: data.dueDay,
},
});
await tx.groupMember.create({
data: {
groupId: created.id,
userId,
role: Role.OWNER,
},
});
return ok(created);
},
);
const result = await transaction;
if (!result.ok) {
return result;
}
return ok({ group: toGroupDto(result.value as GroupRecord) });
}
}

View File

@@ -1,7 +1,7 @@
import type { PrismaClient } from '@generated/prisma/client';
import type { BillingType, GroupDto, WeekDay } from '@gruperly/shared';
export type GroupDb = Pick<PrismaClient, 'group' | 'groupMember' | 'organization'>;
export type GroupDb = Pick<PrismaClient, 'group' | 'groupMember'>;
type PriceLike = { toString(): string };

View File

@@ -1,7 +1,10 @@
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 createFromOrganizationRoute from './features/create-from-organization/route';
import removeAttendeeRoute from '../attendees/features/remove/route';
import groupWaitlistRoutes from '../group-waitlist/routes';
import createGroupRoute from './features/create/route';
import getAllRoute from './features/get-all/route';
import getByIdRoute from './features/get-by-id/route';
import inviteTokenRoute from './features/invite-token/route';
@@ -10,11 +13,14 @@ import listAttendeesRoute from './features/list-attendees/route';
const routes = new Hono();
routes.route('/', getAllRoute);
routes.route('/from-organization', createFromOrganizationRoute);
routes.route('/', createGroupRoute);
routes.route('/', inviteTokenRoute);
routes.route('/', listAttendeesRoute);
routes.route('/', createAttendeeRoute);
routes.route('/', bulkCreateAttendeesRoute);
routes.route('/', getByIdRoute);
routes.route('/', addToGroupWaitlistRoute);
routes.route('/', groupWaitlistRoutes);
routes.route('/', removeAttendeeRoute);
export default routes;

View File

@@ -6,12 +6,13 @@ import prisma from '@/lib/prisma';
import { type AttendeeRecord, normalizePhone, toAttendeeDto } from '@/modules/attendees/lib/helpers';
type JoinViaInviteDeps = {
db?: Pick<PrismaClient, 'group' | 'attendee'>;
db?: Pick<PrismaClient, 'group' | 'attendee' | 'groupWaitlistEntry'>;
};
export type JoinViaInviteResult = {
attendee: AttendeeDto;
status: 'registered' | 'waitlisted';
message: string;
attendee: AttendeeDto | null;
};
export class JoinViaInvite {
@@ -52,6 +53,35 @@ export class JoinViaInvite {
}
const fullName = `${payload.firstName.trim()} ${payload.lastName.trim()}`.trim();
if (group.capacity !== null) {
const currentCount = await db.attendee.count({ where: { groupId: group.id } });
if (currentCount >= group.capacity) {
const existingWaitlistEntry = await db.groupWaitlistEntry.findFirst({
where: { groupId: group.id, phone },
});
if (!existingWaitlistEntry) {
await db.groupWaitlistEntry.create({
data: {
groupId: group.id,
fullName,
phone,
email: payload.email?.trim() || null,
},
});
}
return ok({
status: 'waitlisted',
message:
'El grupo está completo. Fuiste agregado a la lista de espera. Te contactaremos cuando haya un lugar disponible.',
attendee: null,
});
}
}
const created = await db.attendee.create({
data: {
groupId: group.id,
@@ -62,8 +92,9 @@ export class JoinViaInvite {
});
return ok({
status: 'registered',
attendee: toAttendeeDto(created as unknown as AttendeeRecord),
message: 'Inscripción realizada con éxito',
});
}
}
}

View File

@@ -14,6 +14,12 @@ const db = {
create: mock(),
count: mock(),
},
groupWaitlistEntry: {
findMany: mock(),
findFirst: mock(),
create: mock(),
count: mock(),
},
};
mock.module('@/lib/prisma', () => ({
@@ -130,8 +136,9 @@ describe('attendee incorporation & invite-token in groups', () => {
expect(res.status).toBe(201);
const data = await res.json();
expect(data.fullName).toBe('Martín Gómez');
expect(data.phone).toBe('+5491133445566');
expect(data.outcome).toBe('created');
expect(data.attendee.fullName).toBe('Martín Gómez');
expect(data.attendee.phone).toBe('+5491133445566');
expect(prisma.attendee.create).toHaveBeenCalledWith({
data: {
groupId: 'group-1',
@@ -161,6 +168,244 @@ describe('attendee incorporation & invite-token in groups', () => {
expect(res.status).toBe(409);
expect(prisma.attendee.create).not.toHaveBeenCalled();
});
it('rejects owner with 409 group_capacity_reached when the group is full', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
prisma.attendee.findFirst.mockResolvedValue(null);
prisma.attendee.count.mockResolvedValue(20); // capacity full
const app = makeApp({ id: teacherId });
const res = await app.request('/groups/group-1/attendees', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: 'Martín',
lastName: 'Gómez',
phone: '1133445566',
}),
});
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.create).not.toHaveBeenCalled();
});
it('allows the owner to add anyway when allowOverflow is set', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
prisma.attendee.findFirst.mockResolvedValue(null);
prisma.attendee.count.mockResolvedValue(20);
prisma.attendee.create.mockResolvedValue({
id: 'att-1',
groupId: 'group-1',
fullName: 'Martín Gómez',
email: null,
phone: '+5491133445566',
guardianName: null,
guardianPhone: null,
notes: null,
createdAt: new Date('2026-09-18T10:00:00.000Z'),
updatedAt: new Date('2026-09-18T10:00:00.000Z'),
} as never);
const app = makeApp({ id: teacherId });
const res = await app.request('/groups/group-1/attendees?allowOverflow=true', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: 'Martín',
lastName: 'Gómez',
phone: '1133445566',
}),
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.outcome).toBe('created');
expect(body.attendee.fullName).toBe('Martín Gómez');
expect(prisma.attendee.create).toHaveBeenCalled();
});
it('adds to the waitlist when a non-owner member tries to add to a full group', async () => {
const memberGroup = {
...mockGroup,
members: [{ id: 'gm-1', role: 'MEMBER' }],
};
prisma.group.findUnique.mockResolvedValue(memberGroup as never);
prisma.attendee.findFirst.mockResolvedValue(null);
prisma.attendee.count.mockResolvedValue(20);
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(null);
prisma.groupWaitlistEntry.create.mockResolvedValue({
id: 'wl-1',
groupId: 'group-1',
fullName: 'Martín Gómez',
phone: '+5491133445566',
email: null,
notes: null,
status: 'PENDING',
createdAt: new Date('2026-09-18T10:00:00.000Z'),
updatedAt: new Date('2026-09-18T10:00:00.000Z'),
} as never);
const app = makeApp({ id: 'member-1' });
const res = await app.request('/groups/group-1/attendees', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: 'Martín',
lastName: 'Gómez',
phone: '1133445566',
}),
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.outcome).toBe('waitlisted');
expect(prisma.attendee.create).not.toHaveBeenCalled();
expect(prisma.groupWaitlistEntry.create).toHaveBeenCalledWith({
data: {
groupId: 'group-1',
fullName: 'Martín Gómez',
phone: '1133445566',
email: null,
notes: null,
},
});
});
it('still waitlists a non-owner member even when allowOverflow is set', async () => {
const memberGroup = {
...mockGroup,
members: [{ id: 'gm-1', role: 'MEMBER' }],
};
prisma.group.findUnique.mockResolvedValue(memberGroup as never);
prisma.attendee.findFirst.mockResolvedValue(null);
prisma.attendee.count.mockResolvedValue(20);
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(null);
const app = makeApp({ id: 'member-1' });
const res = await app.request('/groups/group-1/attendees?allowOverflow=true', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: 'Martín',
lastName: 'Gómez',
phone: '1133445566',
}),
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.outcome).toBe('waitlisted');
expect(prisma.attendee.create).not.toHaveBeenCalled();
expect(prisma.groupWaitlistEntry.create).toHaveBeenCalled();
});
});
describe('POST /groups/:groupId/waitlist (add to group waitlist)', () => {
it('creates a waitlist entry for the group', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
prisma.attendee.findFirst.mockResolvedValue(null);
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(null);
prisma.groupWaitlistEntry.create.mockResolvedValue({
id: 'wl-1',
groupId: 'group-1',
fullName: 'Martín Gómez',
phone: '+5491133445566',
email: 'martin@example.com',
notes: 'Viene con su hermano',
status: 'PENDING',
createdAt: new Date('2026-09-18T10:00:00.000Z'),
updatedAt: new Date('2026-09-18T10:00:00.000Z'),
} as never);
const app = makeApp({ id: teacherId });
const res = await app.request('/groups/group-1/waitlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: 'Martín',
lastName: 'Gómez',
phone: '+54 9 11 3344-5566',
email: 'martin@example.com',
notes: 'Viene con su hermano',
}),
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.fullName).toBe('Martín Gómez');
expect(body.phone).toBe('+5491133445566');
expect(prisma.groupWaitlistEntry.create).toHaveBeenCalledWith({
data: {
groupId: 'group-1',
fullName: 'Martín Gómez',
phone: '+5491133445566',
email: 'martin@example.com',
notes: 'Viene con su hermano',
},
});
});
it('rejects when the phone number already belongs to an attendee', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
prisma.attendee.findFirst.mockResolvedValue({ id: 'att-existing' } as never);
const app = makeApp({ id: teacherId });
const res = await app.request('/groups/group-1/waitlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: 'Martín',
lastName: 'Gómez',
phone: '1133445566',
}),
});
expect(res.status).toBe(409);
const body = await res.json();
expect(body.code).toBe('attendee_already_registered');
expect(prisma.groupWaitlistEntry.create).not.toHaveBeenCalled();
});
it('rejects when the phone number is already on the waitlist', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
prisma.attendee.findFirst.mockResolvedValue(null);
prisma.groupWaitlistEntry.findFirst.mockResolvedValue({ id: 'wl-existing' } as never);
const app = makeApp({ id: teacherId });
const res = await app.request('/groups/group-1/waitlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: 'Martín',
lastName: 'Gómez',
phone: '1133445566',
}),
});
expect(res.status).toBe(409);
const body = await res.json();
expect(body.code).toBe('already_waitlisted');
});
it('rejects user without access to the group', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
const app = makeApp({ id: 'other-user' });
const res = await app.request('/groups/group-1/waitlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: 'Martín',
lastName: 'Gómez',
phone: '1133445566',
}),
});
expect(res.status).toBe(403);
});
});
describe('POST /groups/:groupId/attendees/bulk (bulk import)', () => {

View File

@@ -4,14 +4,13 @@ import { Hono } from 'hono';
const db = {
group: {
findMany: mock(),
findFirst: mock(),
count: mock(),
create: mock(),
},
groupMember: {
create: mock(),
},
organization: {
merchantAccount: {
findUnique: mock(),
},
};
@@ -102,19 +101,29 @@ describe('groups routes', () => {
expect(res.status).toBe(400);
});
it('creates a group from an owned organization', async () => {
const organization = {
id: 'org-1',
name: 'Escuela Alfa',
members: [{ userId, role: 'owner' }],
};
prisma.organization.findUnique.mockResolvedValue(organization as never);
prisma.group.findFirst.mockResolvedValue(null);
prisma.group.create.mockResolvedValue(group);
it('creates a group with billing configuration', async () => {
prisma.merchantAccount.findUnique.mockResolvedValue({ id: 'merchant-1', userId });
prisma.group.create.mockResolvedValue({
...group,
days: ['MONDAY'],
time: '09:00',
capacity: 20,
price: 500,
billingType: 'MONTHLY',
dueDay: 5,
});
const res = await makeApp({ id: userId }).request('/groups/from-organization', {
const res = await makeApp({ id: userId }).request('/groups', {
method: 'POST',
body: JSON.stringify({ organizationId: 'org-1' }),
body: JSON.stringify({
name: 'Cuadrilla Alfa',
days: ['MONDAY'],
time: '09:00',
capacity: 20,
price: 500,
billingType: 'MONTHLY',
dueDay: 5,
}),
headers: { 'Content-Type': 'application/json' },
});
@@ -122,77 +131,63 @@ describe('groups routes', () => {
expect(await res.json()).toEqual({
group: {
...group,
days: ['MONDAY'],
time: '09:00',
capacity: 20,
price: 500,
billingType: 'MONTHLY',
dueDay: 5,
createdAt: group.createdAt.toISOString(),
updatedAt: group.updatedAt.toISOString(),
},
alreadyExists: false,
});
expect(prisma.group.create).toHaveBeenCalledWith({
data: { name: 'Escuela Alfa', createdById: userId },
data: {
name: 'Cuadrilla Alfa',
createdById: userId,
days: ['MONDAY'],
time: '09:00',
capacity: 20,
price: 500,
billingType: 'MONTHLY',
dueDay: 5,
},
});
expect(prisma.groupMember.create).toHaveBeenCalledWith({
data: { groupId: 'group-1', userId, role: 'OWNER' },
});
});
it('reuses an existing group with the same name', async () => {
const organization = {
id: 'org-1',
name: 'Escuela Alfa',
members: [{ userId, role: 'owner' }],
};
prisma.organization.findUnique.mockResolvedValue(organization as never);
prisma.group.findFirst.mockResolvedValue(group);
it('rejects creating a group without a linked merchant account', async () => {
prisma.merchantAccount.findUnique.mockResolvedValue(null);
const res = await makeApp({ id: userId }).request('/groups/from-organization', {
const res = await makeApp({ id: userId }).request('/groups', {
method: 'POST',
body: JSON.stringify({ organizationId: 'org-1' }),
body: JSON.stringify({
name: 'Cuadrilla Alfa',
days: ['MONDAY'],
time: '09:00',
capacity: 20,
price: 500,
billingType: 'MONTHLY',
dueDay: 5,
}),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.alreadyExists).toBe(true);
expect(res.status).toBe(409);
expect(await res.json()).toMatchObject({ code: 'payment_not_setup' });
expect(prisma.group.create).not.toHaveBeenCalled();
});
it('rejects creating a group for an unknown organization', async () => {
prisma.organization.findUnique.mockResolvedValue(null);
const res = await makeApp({ id: userId }).request('/groups/from-organization', {
it('rejects creating a group with an invalid payload', async () => {
const res = await makeApp({ id: userId }).request('/groups', {
method: 'POST',
body: JSON.stringify({ organizationId: 'missing' }),
body: JSON.stringify({ name: 'x' }),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(404);
expect(await res.json()).toMatchObject({ code: 'organization_not_found' });
expect(res.status).toBe(400);
expect(prisma.group.create).not.toHaveBeenCalled();
});
it('rejects creating a group when the user is not the owner', async () => {
const organization = {
id: 'org-1',
name: 'Escuela Alfa',
members: [{ userId: 'other-user', role: 'owner' }],
};
prisma.organization.findUnique.mockResolvedValue(organization as never);
const res = await makeApp({ id: userId }).request('/groups/from-organization', {
method: 'POST',
body: JSON.stringify({ organizationId: 'org-1' }),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(403);
expect(await res.json()).toMatchObject({ code: 'group_owner_required' });
expect(prisma.group.create).not.toHaveBeenCalled();
});
it('rejects creating a group without a session', async () => {
const res = await makeApp(null).request('/groups/from-organization', {
method: 'POST',
body: JSON.stringify({ organizationId: 'org-1' }),
headers: { 'Content-Type': 'application/json' },
});
expect(res.status).toBe(401);
});
});

View File

@@ -8,6 +8,11 @@ const db = {
attendee: {
findFirst: mock(),
create: mock(),
count: mock(),
},
groupWaitlistEntry: {
findFirst: mock(),
create: mock(),
},
};
@@ -151,5 +156,48 @@ describe('public invitations routes', () => {
expect(res.status).toBe(400);
});
it('adds to the waitlist when the group is full', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
prisma.attendee.findFirst.mockResolvedValue(null);
prisma.attendee.count.mockResolvedValue(15); // capacity full
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(null);
prisma.groupWaitlistEntry.create.mockResolvedValue({
id: 'wl-1',
groupId: 'group-1',
fullName: 'Lucía Méndez',
phone: '+5491122334455',
email: 'lucia@test.com',
notes: null,
status: 'PENDING',
createdAt: new Date('2026-09-18T10:00:00.000Z'),
updatedAt: new Date('2026-09-18T10:00:00.000Z'),
} as never);
const res = await app.request('/invitations/valid-token-123/join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: 'Lucía',
lastName: 'Méndez',
phone: '+54 9 11 2233-4455',
email: 'lucia@test.com',
}),
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.status).toBe('waitlisted');
expect(body.attendee).toBeNull();
expect(prisma.attendee.create).not.toHaveBeenCalled();
expect(prisma.groupWaitlistEntry.create).toHaveBeenCalledWith({
data: {
groupId: 'group-1',
fullName: 'Lucía Méndez',
phone: '+5491122334455',
email: 'lucia@test.com',
},
});
});
});
});

View 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();
});
});
});

View File

@@ -0,0 +1,231 @@
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import type { BillingType, CreateFirstGroup, WeekDay } from '@gruperly/shared'
import { CreateFirstGroupSchema } from '@gruperly/shared'
import { ArrowLeft, Check, Loader2 } from 'lucide-react'
import { cn } from '../../lib/utils'
import { Button, Input, Label } from '../ui'
import { BILLING_TYPES, WEEK_DAY_CHIPS } from '../onboarding/constants'
const defaultValues: CreateFirstGroup = {
name: '',
days: [],
time: '09:00',
capacity: 1,
price: 0,
billingType: 'MONTHLY',
dueDay: 1,
}
type GroupFormProps = {
heading: string
description: string
submitLabel: string
isSubmitting: boolean
errorMessage?: string | null
onSubmit: (values: CreateFirstGroup) => void
onBack?: () => void
}
export function GroupForm({
heading,
description,
submitLabel,
isSubmitting,
errorMessage,
onSubmit,
onBack,
}: GroupFormProps) {
const {
register,
handleSubmit,
watch,
setValue,
formState: { errors },
} = useForm<CreateFirstGroup>({
resolver: zodResolver(CreateFirstGroupSchema),
defaultValues,
mode: 'onTouched',
})
const days = watch('days')
const billingType = watch('billingType')
const dueDay = watch('dueDay')
const toggleDay = (day: WeekDay) => {
const next = days.includes(day) ? days.filter((d) => d !== day) : [...days, day]
setValue('days', next, { shouldValidate: true })
}
const selectBillingType = (type: BillingType) => {
setValue('billingType', type, { shouldValidate: true })
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-5">
<header className="space-y-1">
<h2 className="text-2xl font-bold text-primary">{heading}</h2>
<p className="text-sm text-foreground/60">{description}</p>
</header>
<div>
<Label htmlFor="name">Nombre del grupo</Label>
<Input
id="name"
placeholder="Ej: Yoga Vinyasa · Nivel 1"
invalid={!!errors.name}
{...register('name')}
/>
{errors.name ? <p className="mt-1 text-sm text-danger">{errors.name.message}</p> : null}
</div>
<div>
<Label>Días de clase</Label>
<div className="flex flex-wrap gap-2">
{WEEK_DAY_CHIPS.map(({ value, label }) => {
const isActive = days.includes(value)
return (
<button
key={value}
type="button"
aria-pressed={isActive}
onClick={() => toggleDay(value)}
className={cn(
'h-9 rounded-full border px-3.5 text-sm font-medium transition-colors',
isActive
? 'border-accent bg-accent text-on-accent'
: 'border-border bg-surface text-primary hover:bg-primary-soft',
)}
>
{label}
</button>
)
})}
</div>
{errors.days ? <p className="mt-1 text-sm text-danger">{errors.days.message}</p> : null}
</div>
<div>
<Label htmlFor="time">Horario</Label>
<Input
id="time"
type="time"
invalid={!!errors.time}
{...register('time')}
/>
{errors.time ? <p className="mt-1 text-sm text-danger">{errors.time.message}</p> : null}
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="capacity">Cupo máximo</Label>
<Input
id="capacity"
type="number"
inputMode="numeric"
min={1}
step={1}
invalid={!!errors.capacity}
{...register('capacity', { valueAsNumber: true })}
/>
{errors.capacity ? (
<p className="mt-1 text-sm text-danger">{errors.capacity.message}</p>
) : null}
</div>
<div>
<Label htmlFor="price">Precio</Label>
<div className="relative">
<span className="pointer-events-none absolute inset-y-0 left-3 flex items-center text-sm text-foreground/50">
$
</span>
<Input
id="price"
type="number"
inputMode="decimal"
min={0}
step="0.01"
placeholder="0.00"
className="pl-7"
invalid={!!errors.price}
{...register('price', { valueAsNumber: true })}
/>
</div>
{errors.price ? (
<p className="mt-1 text-sm text-danger">{errors.price.message}</p>
) : null}
</div>
</div>
<div>
<Label>Tipo de cobro</Label>
<div className="grid grid-cols-2 gap-2">
{BILLING_TYPES.map(({ value, label, hint }) => {
const isActive = billingType === value
return (
<button
key={value}
type="button"
aria-pressed={isActive}
onClick={() => selectBillingType(value)}
className={cn(
'rounded-xl border px-3 py-2.5 text-left transition-colors',
isActive
? 'border-accent bg-accent-soft'
: 'border-border bg-surface hover:bg-primary-soft',
)}
>
<span
className={cn(
'block text-sm font-semibold',
isActive ? 'text-accent' : 'text-primary',
)}
>
{label}
</span>
<span className="block text-xs text-foreground/50">{hint}</span>
</button>
)
})}
</div>
</div>
<div>
<Label htmlFor="dueDay">Día de vencimiento</Label>
<Input
id="dueDay"
type="number"
inputMode="numeric"
min={1}
max={28}
step={1}
invalid={!!errors.dueDay}
{...register('dueDay', { valueAsNumber: true })}
/>
<p className="mt-1 text-xs text-foreground/50">
Los cobros vencerán el día {isFinite(dueDay) && dueDay ? dueDay : '1'} de cada mes.
</p>
{errors.dueDay ? (
<p className="mt-1 text-sm text-danger">{errors.dueDay.message}</p>
) : null}
</div>
{errorMessage ? (
<p className="rounded-xl bg-danger-soft px-4 py-3 text-sm text-danger">{errorMessage}</p>
) : null}
<div className="flex items-center gap-3">
{onBack ? (
<Button variant="outline" onClick={onBack} disabled={isSubmitting}>
<ArrowLeft className="size-4" />
Volver
</Button>
) : null}
<Button type="submit" variant="primary" className="flex-1" disabled={isSubmitting}>
{isSubmitting ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />}
{isSubmitting ? 'Creando…' : submitLabel}
</Button>
</div>
</form>
)
}

View File

@@ -22,11 +22,6 @@ const BREADCRUMBS: Record<string, Crumb[]> = {
{ label: 'Ajustes', to: '/settings' },
{ label: 'Seguridad' },
],
'/settings/organizations': [
{ label: 'Inicio', to: '/' },
{ label: 'Ajustes', to: '/settings' },
{ label: 'Organizaciones' },
],
'/profile': [
{ label: 'Inicio', to: '/' },
{ label: 'Mi perfil' },

View File

@@ -1,23 +1,7 @@
import { useMutation } from '@tanstack/react-query'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import type { BillingType, CreateFirstGroup, OnboardingGroupDto, WeekDay } from '@gruperly/shared'
import { CreateFirstGroupSchema } from '@gruperly/shared'
import { ArrowLeft, Check, Loader2 } from 'lucide-react'
import type { CreateFirstGroup, OnboardingGroupDto } from '@gruperly/shared'
import { createFirstGroup } from '../../lib/api'
import { cn } from '../../lib/utils'
import { Button, Input, Label } from '../ui'
import { BILLING_TYPES, WEEK_DAY_CHIPS } from './constants'
const defaultValues: CreateFirstGroup = {
name: '',
days: [],
time: '09:00',
capacity: 1,
price: 0,
billingType: 'MONTHLY',
dueDay: 1,
}
import { GroupForm } from '../groups/GroupForm'
type FirstGroupStepProps = {
onBack: () => void
@@ -25,212 +9,22 @@ type FirstGroupStepProps = {
}
export function FirstGroupStep({ onBack, onCompleted }: FirstGroupStepProps) {
const {
register,
handleSubmit,
watch,
setValue,
formState: { errors },
} = useForm<CreateFirstGroup>({
resolver: zodResolver(CreateFirstGroupSchema),
defaultValues,
mode: 'onTouched',
})
const days = watch('days')
const billingType = watch('billingType')
const dueDay = watch('dueDay')
const create = useMutation({
mutationFn: (values: CreateFirstGroup) => createFirstGroup(values),
onSuccess: (result) => onCompleted(result.group),
})
const toggleDay = (day: WeekDay) => {
const next = days.includes(day) ? days.filter((d) => d !== day) : [...days, day]
setValue('days', next, { shouldValidate: true })
}
const selectBillingType = (type: BillingType) => {
setValue('billingType', type, { shouldValidate: true })
}
return (
<form onSubmit={handleSubmit((values) => create.mutate(values))} noValidate className="space-y-5">
<header className="space-y-1">
<h2 className="text-2xl font-bold text-primary">Tu primer grupo</h2>
<p className="text-sm text-foreground/60">
Definí los datos de la clase que vas a cobrar. Después siempre podés editarlos.
</p>
</header>
<div>
<Label htmlFor="name">Nombre del grupo</Label>
<Input
id="name"
placeholder="Ej: Yoga Vinyasa · Nivel 1"
invalid={!!errors.name}
{...register('name')}
/>
{errors.name ? <p className="mt-1 text-sm text-danger">{errors.name.message}</p> : null}
</div>
<div>
<Label>Días de clase</Label>
<div className="flex flex-wrap gap-2">
{WEEK_DAY_CHIPS.map(({ value, label }) => {
const isActive = days.includes(value)
return (
<button
key={value}
type="button"
aria-pressed={isActive}
onClick={() => toggleDay(value)}
className={cn(
'h-9 rounded-full border px-3.5 text-sm font-medium transition-colors',
isActive
? 'border-accent bg-accent text-on-accent'
: 'border-border bg-surface text-primary hover:bg-primary-soft',
)}
>
{label}
</button>
)
})}
</div>
{errors.days ? <p className="mt-1 text-sm text-danger">{errors.days.message}</p> : null}
</div>
<div>
<Label htmlFor="time">Horario</Label>
<Input
id="time"
type="time"
invalid={!!errors.time}
{...register('time')}
/>
{errors.time ? <p className="mt-1 text-sm text-danger">{errors.time.message}</p> : null}
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="capacity">Cupo máximo</Label>
<Input
id="capacity"
type="number"
inputMode="numeric"
min={1}
step={1}
invalid={!!errors.capacity}
{...register('capacity', { valueAsNumber: true })}
/>
{errors.capacity ? (
<p className="mt-1 text-sm text-danger">{errors.capacity.message}</p>
) : null}
</div>
<div>
<Label htmlFor="price">Precio</Label>
<div className="relative">
<span className="pointer-events-none absolute inset-y-0 left-3 flex items-center text-sm text-foreground/50">
$
</span>
<Input
id="price"
type="number"
inputMode="decimal"
min={0}
step="0.01"
placeholder="0.00"
className="pl-7"
invalid={!!errors.price}
{...register('price', { valueAsNumber: true })}
/>
</div>
{errors.price ? (
<p className="mt-1 text-sm text-danger">{errors.price.message}</p>
) : null}
</div>
</div>
<div>
<Label>Tipo de cobro</Label>
<div className="grid grid-cols-2 gap-2">
{BILLING_TYPES.map(({ value, label, hint }) => {
const isActive = billingType === value
return (
<button
key={value}
type="button"
aria-pressed={isActive}
onClick={() => selectBillingType(value)}
className={cn(
'rounded-xl border px-3 py-2.5 text-left transition-colors',
isActive
? 'border-accent bg-accent-soft'
: 'border-border bg-surface hover:bg-primary-soft',
)}
>
<span
className={cn(
'block text-sm font-semibold',
isActive ? 'text-accent' : 'text-primary',
)}
>
{label}
</span>
<span className="block text-xs text-foreground/50">{hint}</span>
</button>
)
})}
</div>
</div>
<div>
<Label htmlFor="dueDay">Día de vencimiento</Label>
<Input
id="dueDay"
type="number"
inputMode="numeric"
min={1}
max={28}
step={1}
invalid={!!errors.dueDay}
{...register('dueDay', { valueAsNumber: true })}
/>
<p className="mt-1 text-xs text-foreground/50">
Los cobros vencerán el día {isFinite(dueDay) && dueDay ? dueDay : '1'} de cada mes.
</p>
{errors.dueDay ? (
<p className="mt-1 text-sm text-danger">{errors.dueDay.message}</p>
) : null}
</div>
{create.isError ? (
<p className="rounded-xl bg-danger-soft px-4 py-3 text-sm text-danger">
No pudimos crear el grupo. Revisá los datos e intentá de nuevo.
</p>
) : null}
<div className="flex items-center gap-3">
<Button variant="outline" onClick={onBack} disabled={create.isPending}>
<ArrowLeft className="size-4" />
Volver
</Button>
<Button
type="submit"
variant="primary"
className="flex-1"
disabled={create.isPending}
>
{create.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Check className="size-4" />
)}
{create.isPending ? 'Creando…' : 'Crear grupo'}
</Button>
</div>
</form>
<GroupForm
heading="Tu primer grupo"
description="Definí los datos de la clase que vas a cobrar. Después siempre podés editarlos."
submitLabel="Crear grupo"
isSubmitting={create.isPending}
errorMessage={
create.isError ? 'No pudimos crear el grupo. Revisá los datos e intentá de nuevo.' : null
}
onSubmit={(values) => create.mutate(values)}
onBack={onBack}
/>
)
}

View File

@@ -6,15 +6,24 @@ import type {
ConnectPayment,
ConnectPaymentResult,
CreateAttendee,
CreateAttendeeResult,
CreateFirstGroup,
CreateFirstGroupResult,
CreateGroupResult,
CreateGroupWaitlistEntry,
GroupDto,
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'
@@ -71,6 +80,12 @@ export const createFirstGroup = (payload: CreateFirstGroup) =>
body: JSON.stringify(payload),
})
export const createGroup = (payload: CreateFirstGroup) =>
apiFetch<CreateGroupResult>('/api/v1/groups', {
method: 'POST',
body: JSON.stringify(payload),
})
export const getGroups = () => apiFetch<GroupList>('/api/v1/groups')
export const getGroup = (groupId: string) =>
@@ -87,13 +102,26 @@ export const getInviteInfo = (token: string) =>
apiFetch<GroupInviteInfoDto>(`/api/v1/invitations/${token}`)
export const joinViaInvite = (token: string, payload: JoinGroupViaInvite) =>
apiFetch<{ attendee: AttendeeDto; message: string }>(`/api/v1/invitations/${token}/join`, {
apiFetch<JoinGroupViaInviteResult>(`/api/v1/invitations/${token}/join`, {
method: 'POST',
body: JSON.stringify(payload),
})
export const createAttendee = (groupId: string, payload: CreateAttendee) =>
apiFetch<AttendeeDto>(`/api/v1/groups/${groupId}/attendees`, {
export const createAttendee = (
groupId: string,
payload: CreateAttendee,
options?: { allowOverflow?: boolean },
) =>
apiFetch<CreateAttendeeResult>(
`/api/v1/groups/${groupId}/attendees${options?.allowOverflow ? '?allowOverflow=true' : ''}`,
{
method: 'POST',
body: JSON.stringify(payload),
},
)
export const addToGroupWaitlist = (groupId: string, payload: CreateGroupWaitlistEntry) =>
apiFetch<GroupWaitlistEntryDto>(`/api/v1/groups/${groupId}/waitlist`, {
method: 'POST',
body: JSON.stringify(payload),
})
@@ -105,4 +133,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',
},
)

View File

@@ -6,12 +6,12 @@ import { PaymentsView } from './routes/payments'
import { SettingsView } from './routes/settings'
import { ProfileView } from './routes/profile'
import { SecurityPage } from './routes/security'
import { OrganizationsPage } from './routes/organizations'
import { LoginPage } from './routes/auth/login'
import { SignupPage } from './routes/auth/signup'
import { VerifyEmailPage } from './routes/auth/verify-email'
import { OnboardingView } from './routes/onboarding'
import { GroupDetailView } from './routes/group-detail'
import { CreateGroupView } from './routes/create-group'
import { JoinGroupView } from './routes/join-group'
const rootRoute = createRootRoute({
@@ -68,6 +68,12 @@ const groupsRoute = createRoute({
component: GroupsView,
})
const createGroupRoute = createRoute({
getParentRoute: () => appLayoutRoute,
path: '/groups/new',
component: CreateGroupView,
})
const groupDetailRoute = createRoute({
getParentRoute: () => appLayoutRoute,
path: '/groups/$groupId',
@@ -98,12 +104,6 @@ const profileRoute = createRoute({
component: ProfileView,
})
const organizationsRoute = createRoute({
getParentRoute: () => appLayoutRoute,
path: '/settings/organizations',
component: OrganizationsPage,
})
const routeTree = rootRoute.addChildren([
loginRoute,
signupRoute,
@@ -113,11 +113,11 @@ const routeTree = rootRoute.addChildren([
appLayoutRoute.addChildren([
indexRoute,
groupsRoute,
createGroupRoute,
groupDetailRoute,
paymentsRoute,
settingsRoute,
securityRoute,
organizationsRoute,
profileRoute,
]),
])

View File

@@ -0,0 +1,39 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useNavigate, Link } from '@tanstack/react-router'
import type { CreateFirstGroup } from '@gruperly/shared'
import { ChevronLeft } from 'lucide-react'
import { GroupForm } from '../components/groups/GroupForm'
import { createGroup } from '../lib/api'
export function CreateGroupView() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const create = useMutation({
mutationFn: (values: CreateFirstGroup) => createGroup(values),
onSuccess: (result) => {
void queryClient.invalidateQueries({ queryKey: ['groups'] })
void navigate({ to: '/groups/$groupId', params: { groupId: result.group.id } })
},
})
return (
<section className="mx-auto w-full max-w-md">
<Link to="/groups" className="hidden mb-6 items-center gap-1 text-sm font-medium text-foreground/70 transition-colors hover:text-primary lg:inline-flex">
<ChevronLeft className="size-4" />
Grupos
</Link>
<GroupForm
heading="Nuevo grupo"
description="Definí los datos de la clase que vas a cobrar. Después siempre podés editarlos."
submitLabel="Crear grupo"
isSubmitting={create.isPending}
errorMessage={
create.isError ? 'No pudimos crear el grupo. Revisá los datos e intentá de nuevo.' : null
}
onSubmit={(values) => create.mutate(values)}
/>
</section>
)
}

View File

@@ -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 } from '@gruperly/shared';
import type {
AttendeeDto,
CreateAttendee,
CreateAttendeeResult,
CreateGroupWaitlistEntry,
GroupWaitlistEntryDto,
GroupWaitlistList,
} from '@gruperly/shared';
import {
ArrowLeft,
CalendarClock,
@@ -23,17 +30,24 @@ import {
StickyNote,
Trash2,
UploadCloud,
UserCheck,
UserMinus,
UserPlus,
Users,
} from 'lucide-react';
import type { CreateAttendee } from '@gruperly/shared';
import { Badge, Button, Input, Label, Modal, useToast } from '../components/ui';
import {
addToGroupWaitlist,
ApiError,
bulkCreateAttendees,
createAttendee,
getGroup,
getGroupAttendees,
getGroupWaitlist,
getInviteToken,
promoteGroupWaitlistEntry,
removeGroupAttendee,
removeGroupWaitlistEntry,
} from '../lib/api';
import {
downloadAttendeeTemplateCsv,
@@ -55,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('');
@@ -70,6 +87,11 @@ export function GroupDetailView() {
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Capacity & waitlist state
const [isCapacityModalOpen, setIsCapacityModalOpen] = useState(false);
const [pendingCapacityPayload, setPendingCapacityPayload] = useState<CreateAttendee | null>(null);
const [isBulkCapacityModalOpen, setIsBulkCapacityModalOpen] = useState(false);
// Attendees search filter
const [searchFilter, setSearchFilter] = useState('');
@@ -92,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),
@@ -105,23 +133,103 @@ export function GroupDetailView() {
});
// Quick add attendee mutation
const resetQuickForm = () => {
setFirstName('');
setLastName('');
setPhone('');
setEmail('');
setNotes('');
};
const closeAddAttendeeFlow = () => {
resetQuickForm();
setPendingCapacityPayload(null);
setIsCapacityModalOpen(false);
setIsAddAttendeeModalOpen(false);
};
const handleCreateSuccess = (result: CreateAttendeeResult) => {
if (result.outcome === 'created') {
toast.success(`Miembro ${result.attendee.fullName} agregado con éxito.`);
} else {
toast.info(result.message);
}
queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] });
closeAddAttendeeFlow();
};
const createAttendeeMutation = useMutation({
mutationFn: (payload: CreateAttendee) => createAttendee(groupId, payload),
onSuccess: (created) => {
toast.success(`Miembro ${created.fullName} agregado con éxito.`);
queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] });
setFirstName('');
setLastName('');
setPhone('');
setEmail('');
setNotes('');
setIsAddAttendeeModalOpen(false);
onMutate: (payload) => setPendingCapacityPayload(payload),
onSuccess: handleCreateSuccess,
onError: (err: Error) => {
if (err instanceof ApiError && err.problem?.code === 'group_capacity_reached') {
setIsCapacityModalOpen(true);
return;
}
toast.error(err.message || 'Error al agregar miembro.');
},
});
// Force add (owner overrides the full capacity)
const createAttendeeForceMutation = useMutation({
mutationFn: (payload: CreateAttendee) => createAttendee(groupId, payload, { allowOverflow: true }),
onSuccess: handleCreateSuccess,
onError: (err: Error) => {
toast.error(err.message || 'Error al agregar miembro.');
},
});
// Add to group waitlist
const addToWaitlistMutation = useMutation({
mutationFn: (payload: CreateGroupWaitlistEntry) => addToGroupWaitlist(groupId, payload),
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-waitlist', groupId] });
queryClient.invalidateQueries({ queryKey: ['group', groupId] });
closeAddAttendeeFlow();
},
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 {
toast.error(err.message || 'Error al agregar a la lista de espera.');
}
setPendingCapacityPayload(null);
setIsCapacityModalOpen(false);
},
});
// Bulk import mutation
const bulkImportMutation = useMutation({
mutationFn: (attendees: Array<{ firstName: string; lastName: string; fullName: string; phone: string; email?: string; notes?: string }>) =>
@@ -138,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;
@@ -240,7 +405,7 @@ export function GroupDetailView() {
setParsedContacts((prev) => prev.filter((c) => c.id !== id));
};
const handleConfirmBulkImport = () => {
const doBulkImport = () => {
const validRows = parsedContacts.filter((c) => c.isValid);
if (validRows.length === 0) {
toast.error('No hay miembros válidos para importar.');
@@ -258,8 +423,25 @@ export function GroupDetailView() {
);
};
const handleConfirmBulkImport = () => {
if (validRowsCount === 0) {
toast.error('No hay miembros válidos para importar.');
return;
}
if (group?.capacity != null && attendeesTotal + validRowsCount > group.capacity) {
setIsBulkCapacityModalOpen(true);
return;
}
doBulkImport();
};
const group = groupQuery.data;
const attendees = attendeesQuery.data?.data ?? [];
const attendeesTotal = attendeesQuery.data?.pagination?.total ?? attendees.length;
const 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 (
@@ -307,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>
@@ -360,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>
@@ -382,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 */}
@@ -880,6 +1156,95 @@ export function GroupDetailView() {
</div>
</Modal>
{/* MODAL: CUPO ALCANZADO (ALTA INDIVIDUAL) */}
<Modal
isOpen={isCapacityModalOpen}
onClose={() => {
setPendingCapacityPayload(null);
setIsCapacityModalOpen(false);
}}
title="Cupo alcanzado"
description="El grupo llegó a su cupo máximo de miembros."
maxWidth="md"
>
{pendingCapacityPayload ? (
<div className="space-y-4">
<p className="text-sm text-foreground/70">
El grupo <strong className="text-primary">{group.name}</strong> ya alcanzó su cupo de{' '}
<strong className="text-primary">{group.capacity}</strong> miembros. ¿Qué deseas hacer con{' '}
<strong className="text-primary">
{`${pendingCapacityPayload.firstName.trim()} ${pendingCapacityPayload.lastName.trim()}`.trim()}
</strong>
?
</p>
<div className="flex flex-col gap-2.5">
<Button
variant="primary"
onClick={() => createAttendeeForceMutation.mutate({ ...pendingCapacityPayload })}
disabled={createAttendeeForceMutation.isPending || addToWaitlistMutation.isPending}
className="gap-2"
>
{createAttendeeForceMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<UserPlus className="size-4" />
)}
<span>Agregar de todos modos</span>
</Button>
<Button
variant="outline"
onClick={() => addToWaitlistMutation.mutate({ ...pendingCapacityPayload })}
disabled={createAttendeeForceMutation.isPending || addToWaitlistMutation.isPending}
className="gap-2"
>
{addToWaitlistMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Clock className="size-4" />
)}
<span>Sumar a la lista de espera</span>
</Button>
</div>
<p className="text-[11px] text-foreground/50 text-center">
Si eliges agregarlo de todos modos, el grupo quedará por encima de su cupo.
</p>
</div>
) : null}
</Modal>
{/* MODAL: AVISO DE CUPO EN CARGA MASIVA */}
<Modal
isOpen={isBulkCapacityModalOpen}
onClose={() => setIsBulkCapacityModalOpen(false)}
title="Aviso de cupo"
description="La carga supera el cupo del grupo."
maxWidth="md"
>
<div className="space-y-4">
<p className="text-sm text-foreground/70">
Estás por importar <strong className="text-primary">{validRowsCount}</strong> miembro(s), pero el grupo ya
tiene <strong className="text-primary">{attendeesTotal}</strong> inscrito(s) y su cupo es de{' '}
<strong className="text-primary">{group.capacity}</strong>. ¿Deseas importarlos de todos modos?
</p>
<div className="flex items-center justify-end gap-2.5 pt-2">
<Button variant="ghost" onClick={() => setIsBulkCapacityModalOpen(false)}>
Cancelar
</Button>
<Button
variant="primary"
onClick={() => {
setIsBulkCapacityModalOpen(false);
doBulkImport();
}}
className="gap-2"
>
<Check className="size-4" />
Importar de todos modos
</Button>
</div>
</div>
</Modal>
{/* MODAL: DETALLE DEL PARTICIPANTE */}
<Modal
isOpen={selectedAttendee !== null}
@@ -994,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>

View File

@@ -70,7 +70,7 @@ export function GroupsView() {
<h1 className="text-2xl font-bold text-primary">Grupos</h1>
<p className="mt-2 text-sm text-foreground/60">Tus grupos de cobranza.</p>
</div>
<Button variant="primary" onClick={() => void navigate({ to: '/onboarding' })}>
<Button variant="primary" onClick={() => void navigate({ to: '/groups/new' })}>
<Plus className="size-4" />
Crear
</Button>
@@ -100,7 +100,7 @@ export function GroupsView() {
<Button
variant="primary"
className="mt-5"
onClick={() => void navigate({ to: '/onboarding' })}
onClick={() => void navigate({ to: '/groups/new' })}
>
<Plus className="size-4" />
Crear tu primer grupo

View File

@@ -4,6 +4,7 @@ import { useParams, Link } from '@tanstack/react-router';
import {
CalendarClock,
CheckCircle2,
Clock,
GraduationCap,
Loader2,
Moon,
@@ -32,6 +33,9 @@ export function JoinGroupView() {
phone: string | null;
} | null>(null);
// Waitlist state (group is full)
const [waitlistInfo, setWaitlistInfo] = useState<{ message: string } | null>(null);
const inviteQuery = useQuery({
queryKey: ['public-invite', token],
queryFn: () => getInviteInfo(token),
@@ -49,9 +53,15 @@ export function JoinGroupView() {
}),
onSuccess: (data) => {
setErrorMessage(null);
if (data.status === 'waitlisted') {
setRegisteredAttendee(null);
setWaitlistInfo({ message: data.message });
return;
}
setWaitlistInfo(null);
setRegisteredAttendee({
fullName: data.attendee.fullName,
phone: data.attendee.phone,
fullName: data.attendee!.fullName,
phone: data.attendee!.phone,
});
},
onError: (error: unknown) => {
@@ -163,8 +173,32 @@ export function JoinGroupView() {
</div>
) : null}
{/* Waitlist Confirmation (group full) */}
{waitlistInfo && group ? (
<div className="rounded-2xl border border-accent/30 bg-surface p-6 sm:p-8 text-center shadow-xl space-y-5 animate-step-enter">
<div className="size-16 rounded-full bg-accent-soft text-accent flex items-center justify-center mx-auto">
<Clock className="size-10" />
</div>
<div>
<Badge variant="neutral" className="mb-2">
Lista de Espera
</Badge>
<h1 className="text-2xl font-bold text-primary">Cupo completo</h1>
<p className="mt-2 text-sm text-foreground/70 leading-relaxed">
{waitlistInfo.message}
</p>
</div>
<p className="text-xs text-foreground/60">
El profesor se pondrá en contacto contigo cuando haya un lugar disponible en{' '}
<strong className="text-primary font-semibold">{group.name}</strong>.
</p>
</div>
) : null}
{/* Inscription Form */}
{!registeredAttendee && group ? (
{!registeredAttendee && !waitlistInfo && group ? (
<div className="rounded-2xl border border-border bg-surface p-6 sm:p-8 shadow-xl space-y-6">
{/* Group Info Header */}
<div className="border-b border-border pb-5">

View File

@@ -1,191 +0,0 @@
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { Building2, Check, Loader2, Plus } from 'lucide-react'
import { authClient } from '../lib/auth-client'
import { Badge, Button, Input, Label } from '../components/ui'
const createOrgSchema = z.object({
name: z.string().min(2, 'Ingresá el nombre del grupo'),
slug: z
.string()
.min(2, 'Mínimo 2 caracteres')
.regex(/^[a-z0-9-]+$/, 'Solo minúsculas, números y guiones'),
})
type CreateOrgValues = z.infer<typeof createOrgSchema>
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4000'
type OrganizationRow = {
id: string
name: string
slug: string
logo: string | null
}
function OrganizationList({ onRefresh }: { onRefresh: () => void }) {
const { data, isPending } = authClient.useListOrganizations()
const [syncing, setSyncing] = useState<string | null>(null)
const [activeId, setActiveId] = useState<string | null>(null)
const organizations = (data ?? []) as OrganizationRow[]
const handleSyncGroup = async (org: OrganizationRow) => {
setSyncing(org.id)
try {
const res = await fetch(`${API_URL}/api/v1/groups/from-organization`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ organizationId: org.id }),
})
const body = (await res.json()) as { message?: string }
if (!res.ok) {
onRefresh()
setSyncing(null)
return
}
void body
} finally {
setSyncing(null)
}
onRefresh()
}
const handleSetActive = async (organizationId: string) => {
setActiveId(organizationId)
await authClient.organization.setActive({ organizationId })
setActiveId(null)
}
if (isPending) {
return <Loader2 className="size-5 animate-spin text-foreground/40" />
}
if (organizations.length === 0) {
return <p className="text-sm text-foreground/60">Todavía no pertenecés a ningún grupo.</p>
}
return (
<ul className="divide-y divide-border rounded-xl border border-border bg-surface">
{organizations.map((org) => (
<li key={org.id} className="flex flex-wrap items-center gap-3 px-4 py-3">
<span className="rounded-lg bg-accent-soft p-2">
<Building2 className="size-4 text-accent" />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-primary">{org.name}</p>
<p className="text-xs text-foreground/50">/{org.slug}</p>
</div>
<Button
variant="outline"
size="sm"
disabled={syncing === org.id}
onClick={() => handleSyncGroup(org)}
>
{syncing === org.id ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
Sincronizar grupo
</Button>
<Button
variant="ghost"
size="sm"
disabled={activeId === org.id}
onClick={() => handleSetActive(org.id)}
>
{activeId === org.id ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Check className="size-4" />
)}
<span className="hidden sm:inline">Usar</span>
</Button>
<Badge>Owner</Badge>
</li>
))}
</ul>
)
}
export function OrganizationsPage() {
const [refreshKey, setRefreshKey] = useState(0)
const [feedback, setFeedback] = useState<string | null>(null)
const {
register,
handleSubmit,
reset,
setError,
formState: { errors, isSubmitting },
} = useForm<CreateOrgValues>({ resolver: zodResolver(createOrgSchema) })
const refreshOrganizations = () => setRefreshKey((k) => k + 1)
const onCreateOrg = handleSubmit(async ({ name, slug }) => {
setFeedback(null)
const { error, data } = await authClient.organization.create({ name, slug })
if (error) {
setError('root', { message: error.message ?? 'No se pudo crear el grupo' })
return
}
reset({ name: '', slug: '' })
setFeedback('Grupo creado. Sincronizalo con el cobro grupal.')
refreshOrganizations()
void data
})
return (
<section className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-primary">Grupos</h1>
<p className="mt-1 text-sm text-foreground/60">
Creá un grupo para organizar tus cobros. Cada organización se vincula a un grupo.
</p>
</div>
{feedback ? (
<p className="rounded-xl bg-success-soft px-4 py-3 text-sm text-success">{feedback}</p>
) : null}
<div className="rounded-xl border border-border bg-surface p-5">
<div className="mb-4 flex items-center gap-2">
<Plus className="size-4 text-accent" />
<h2 className="text-base font-semibold text-primary">Crear grupo</h2>
</div>
<form onSubmit={onCreateOrg} className="space-y-4">
<div>
<Label htmlFor="name">Nombre</Label>
<Input
id="name"
placeholder="Gimnasio, club, kermés…"
invalid={!!errors.name}
{...register('name')}
/>
{errors.name ? <p className="mt-1 text-sm text-danger">{errors.name.message}</p> : null}
</div>
<div>
<Label htmlFor="slug">Slug</Label>
<Input
id="slug"
placeholder="gimnasio-don-bosco"
invalid={!!errors.slug}
{...register('slug')}
/>
{errors.slug ? <p className="mt-1 text-sm text-danger">{errors.slug.message}</p> : null}
</div>
{errors.root ? <p className="text-sm text-danger">{errors.root.message}</p> : null}
<Button type="submit" variant="primary" disabled={isSubmitting}>
{isSubmitting ? <Loader2 className="size-4 animate-spin" /> : null}
Crear grupo
</Button>
</form>
</div>
<div className="rounded-xl border border-border bg-surface p-5">
<h2 className="mb-4 text-base font-semibold text-primary">Tus grupos</h2>
<OrganizationList key={refreshKey} onRefresh={refreshOrganizations} />
</div>
</section>
)
}

View File

@@ -1,5 +1,5 @@
import { Link } from '@tanstack/react-router'
import { Building2, Check, Fingerprint, Monitor, Moon, Sun } from 'lucide-react'
import { Check, Fingerprint, Monitor, Moon, Sun } from 'lucide-react'
import { signOut } from '../lib/auth-client'
import { Button } from '../components/ui'
import { useTheme, type Theme } from '../context/ThemeProvider'
@@ -76,19 +76,6 @@ export function SettingsView() {
</div>
<div className="divide-y divide-border rounded-xl border border-border bg-surface">
<Link
to="/settings/organizations"
className="flex items-center gap-3 px-4 py-3 hover:bg-primary-soft"
>
<span className="rounded-lg bg-accent-soft p-2">
<Building2 className="size-4 text-accent-fg" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-primary">Grupos</p>
<p className="text-xs text-foreground/50">Crear y gestionar tus organizaciones</p>
</div>
</Link>
<Link to="/seguridad" className="flex items-center gap-3 px-4 py-3 hover:bg-primary-soft">
<span className="rounded-lg bg-accent-soft p-2">
<Fingerprint className="size-4 text-accent-fg" />

View File

@@ -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();
@@ -42,6 +43,18 @@ export const CreateAttendeeSchema = z.object({
});
export type CreateAttendee = z.output<typeof CreateAttendeeSchema>;
export const CreateAttendeeResultSchema = z.discriminatedUnion('outcome', [
z.object({
outcome: z.literal('created'),
attendee: AttendeeDtoSchema,
}),
z.object({
outcome: z.literal('waitlisted'),
message: z.string(),
}),
]);
export type CreateAttendeeResult = z.output<typeof CreateAttendeeResultSchema>;
export const JoinGroupViaInviteSchema = z.object({
firstName: z.string().trim().min(1, 'El nombre es obligatorio'),
lastName: z.string().trim().min(1, 'El apellido es obligatorio'),
@@ -50,6 +63,13 @@ export const JoinGroupViaInviteSchema = z.object({
});
export type JoinGroupViaInvite = z.output<typeof JoinGroupViaInviteSchema>;
export const JoinGroupViaInviteResultSchema = z.object({
status: z.enum(['registered', 'waitlisted']),
message: z.string(),
attendee: AttendeeDtoSchema.nullable(),
});
export type JoinGroupViaInviteResult = z.output<typeof JoinGroupViaInviteResultSchema>;
export const BulkAttendeeItemSchema = z.object({
firstName: z.string().trim().min(1, 'El nombre es obligatorio'),
lastName: z.string().trim().optional().default(''),
@@ -73,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>;

View File

@@ -56,15 +56,7 @@ export const GroupListSchema = z.object({
});
export type GroupList = z.output<typeof GroupListSchema>;
export const CreateGroupFromOrganizationSchema = z.strictObject({
organizationId: z.string().min(1),
});
export type CreateGroupFromOrganization = z.output<typeof CreateGroupFromOrganizationSchema>;
export const CreateGroupFromOrganizationResultSchema = z.object({
export const CreateGroupResultSchema = z.object({
group: GroupDtoSchema,
alreadyExists: z.boolean(),
});
export type CreateGroupFromOrganizationResult = z.output<
typeof CreateGroupFromOrganizationResultSchema
>;
export type CreateGroupResult = z.output<typeof CreateGroupResultSchema>;

View File

@@ -28,4 +28,43 @@ export const WaitlistListSchema = z.object({
data: z.array(WaitlistEntryDtoSchema),
pagination: paginationSchema,
});
export type WaitlistList = z.output<typeof WaitlistListSchema>;
export type WaitlistList = z.output<typeof WaitlistListSchema>;
export const GroupWaitlistEntryDtoSchema = z.object({
id: z.string(),
groupId: z.string(),
fullName: z.string(),
phone: z.string(),
email: z.string().nullable(),
notes: z.string().nullable(),
status: waitlistStatusSchema,
createdAt: isoDateTimeSchema,
updatedAt: isoDateTimeSchema,
});
export type GroupWaitlistEntryDto = z.output<typeof GroupWaitlistEntryDtoSchema>;
export const CreateGroupWaitlistEntrySchema = z.object({
firstName: z.string().trim().min(1, 'El nombre es obligatorio'),
lastName: z.string().trim().optional().default(''),
phone: z.string().trim().min(6, 'Ingresa un número de teléfono válido'),
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 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>;