Merge pull request 'feat/waiting-list' (#3) from feat/waiting-list into development
Reviewed-on: #3
This commit is contained in:
@@ -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/lib/` — `prisma.ts` (`getPrismaClient`, proxy `default` y clase `UnitOfWork`), `pagination.ts` (offset/metadata), `email.ts`, `error-message.ts`.
|
||||||
- `src/logger.ts` — pino + pino-pretty.
|
- `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 }`).
|
- 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/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).
|
- `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.
|
- 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.
|
- **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+`.
|
- 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
|
## Fuentes de contexto
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -19,6 +19,7 @@ model Group {
|
|||||||
members GroupMember[]
|
members GroupMember[]
|
||||||
attendees Attendee[]
|
attendees Attendee[]
|
||||||
payments Payment[]
|
payments Payment[]
|
||||||
|
waitlist GroupWaitlistEntry[]
|
||||||
|
|
||||||
@@map("groups")
|
@@map("groups")
|
||||||
}
|
}
|
||||||
@@ -120,6 +121,24 @@ model WaitlistEntry {
|
|||||||
@@map("waitlist_entries")
|
@@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 {
|
enum WaitlistStatus {
|
||||||
PENDING
|
PENDING
|
||||||
INVITED
|
INVITED
|
||||||
|
|||||||
@@ -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?: {
|
export function databaseUnavailableProblem(params?: {
|
||||||
instance?: string;
|
instance?: string;
|
||||||
}): ProblemDetails {
|
}): ProblemDetails {
|
||||||
@@ -100,4 +83,27 @@ export function onboardingAlreadyCompletedProblem(): ProblemDetails {
|
|||||||
detail: 'Ya completaste el onboarding de Gruperly.',
|
detail: 'Ya completaste el onboarding de Gruperly.',
|
||||||
code: 'onboarding_already_completed',
|
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',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,8 +13,9 @@ route.post('/:groupId/attendees', validate.json(CreateAttendeeSchema), async (c)
|
|||||||
}
|
}
|
||||||
const groupId = c.req.param('groupId');
|
const groupId = c.req.param('groupId');
|
||||||
const body = c.req.valid('json') as CreateAttendee;
|
const body = c.req.valid('json') as CreateAttendee;
|
||||||
|
const allowOverflow = c.req.query('allowOverflow') === 'true';
|
||||||
const useCase = new CreateAttendeeUseCase();
|
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 });
|
return resultJson(c, result, { status: 201 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
import type { PrismaClient } from '@generated/prisma/client';
|
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 { 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 prisma from '@/lib/prisma';
|
||||||
import { type AttendeeRecord, normalizePhone, toAttendeeDto } from '../../lib/helpers';
|
import { type AttendeeRecord, normalizePhone, toAttendeeDto } from '../../lib/helpers';
|
||||||
|
|
||||||
type CreateAttendeeDeps = {
|
type CreateAttendeeDeps = {
|
||||||
db?: Pick<PrismaClient, 'group' | 'attendee'>;
|
db?: Pick<PrismaClient, 'group' | 'attendee' | 'groupWaitlistEntry'>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateAttendeeOptions = {
|
||||||
|
allowOverflow?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class CreateAttendeeUseCase {
|
export class CreateAttendeeUseCase {
|
||||||
@@ -16,7 +26,8 @@ export class CreateAttendeeUseCase {
|
|||||||
groupId: string,
|
groupId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
payload: CreateAttendee,
|
payload: CreateAttendee,
|
||||||
): Promise<Result<AttendeeDto, ProblemDetails>> {
|
options: CreateAttendeeOptions = {},
|
||||||
|
): Promise<Result<CreateAttendeeResult, ProblemDetails>> {
|
||||||
const db = this.deps.db ?? prisma;
|
const db = this.deps.db ?? prisma;
|
||||||
|
|
||||||
const group = await db.group.findUnique({
|
const group = await db.group.findUnique({
|
||||||
@@ -57,6 +68,42 @@ export class CreateAttendeeUseCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fullName = `${payload.firstName.trim()} ${payload.lastName.trim()}`.trim();
|
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({
|
const attendee = await db.attendee.create({
|
||||||
data: {
|
data: {
|
||||||
groupId,
|
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),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
24
apps/backend/src/modules/attendees/features/remove/route.ts
Normal file
24
apps/backend/src/modules/attendees/features/remove/route.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { type RemoveAttendeeQuery, RemoveAttendeeQuerySchema } from '@gruperly/shared';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
|
||||||
|
import { validate } from '@/http/validate';
|
||||||
|
import { RemoveAttendee } from './use-case';
|
||||||
|
|
||||||
|
const route = new Hono();
|
||||||
|
|
||||||
|
route.delete('/:groupId/attendees/:attendeeId', validate.query(RemoveAttendeeQuerySchema), async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
if (!user) {
|
||||||
|
return problemJson(c, unauthorizedProblem(c.req.path));
|
||||||
|
}
|
||||||
|
const groupId = c.req.param('groupId');
|
||||||
|
const attendeeId = c.req.param('attendeeId');
|
||||||
|
const query = c.req.valid('query') as RemoveAttendeeQuery;
|
||||||
|
const useCase = new RemoveAttendee();
|
||||||
|
const result = await useCase.execute(groupId, attendeeId, user.id, {
|
||||||
|
promoteFromWaitlist: query.promoteFromWaitlist,
|
||||||
|
});
|
||||||
|
return resultJson(c, result);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default route;
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { Prisma, PrismaClient } from '@generated/prisma/client';
|
||||||
|
import type { ProblemDetails, RemoveAttendeeResult, Result } from '@gruperly/shared';
|
||||||
|
import { err, ok } from '@gruperly/shared';
|
||||||
|
import { attendeeHasPaymentsProblem, notFoundResourceProblem } from '@/http/problem-builders';
|
||||||
|
import { default as prisma, UnitOfWork } from '@/lib/prisma';
|
||||||
|
import {
|
||||||
|
findGroupOwnedByUser,
|
||||||
|
toGroupWaitlistEntryDto,
|
||||||
|
} from '@/modules/group-waitlist/lib/helpers';
|
||||||
|
import { promoteFirstPendingWaitlistEntry } from '@/modules/group-waitlist/lib/promote';
|
||||||
|
|
||||||
|
type RemoveAttendeeDeps = {
|
||||||
|
db?: Pick<PrismaClient, 'group' | 'attendee' | 'payment'>;
|
||||||
|
unitOfWork?: UnitOfWork;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RemoveAttendeeOptions = {
|
||||||
|
promoteFromWaitlist?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class RemoveAttendee {
|
||||||
|
constructor(private readonly deps: RemoveAttendeeDeps = {}) {}
|
||||||
|
|
||||||
|
async execute(
|
||||||
|
groupId: string,
|
||||||
|
attendeeId: string,
|
||||||
|
userId: string,
|
||||||
|
options: RemoveAttendeeOptions = {},
|
||||||
|
): Promise<Result<RemoveAttendeeResult, ProblemDetails>> {
|
||||||
|
const db = this.deps.db ?? prisma;
|
||||||
|
const unitOfWork = this.deps.unitOfWork ?? new UnitOfWork(prisma);
|
||||||
|
|
||||||
|
const groupResult = await findGroupOwnedByUser(db, groupId, userId);
|
||||||
|
if (!groupResult.ok) {
|
||||||
|
return groupResult;
|
||||||
|
}
|
||||||
|
const group = groupResult.value;
|
||||||
|
|
||||||
|
const attendee = await db.attendee.findFirst({ where: { id: attendeeId, groupId } });
|
||||||
|
if (!attendee) {
|
||||||
|
return err(notFoundResourceProblem('Attendee', attendeeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentsCount = await db.payment.count({ where: { attendeeId } });
|
||||||
|
if (paymentsCount > 0) {
|
||||||
|
return err(attendeeHasPaymentsProblem());
|
||||||
|
}
|
||||||
|
|
||||||
|
return unitOfWork.executeResult(
|
||||||
|
async (tx: Prisma.TransactionClient): Promise<Result<RemoveAttendeeResult, ProblemDetails>> => {
|
||||||
|
await tx.attendee.delete({ where: { id: attendeeId } });
|
||||||
|
|
||||||
|
if (!options.promoteFromWaitlist) {
|
||||||
|
return ok({ removedAttendeeId: attendeeId, promoted: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
const promoteResult = await promoteFirstPendingWaitlistEntry(tx, group);
|
||||||
|
if (!promoteResult.ok) {
|
||||||
|
return err(promoteResult.error);
|
||||||
|
}
|
||||||
|
if (!promoteResult.value) {
|
||||||
|
return ok({ removedAttendeeId: attendeeId, promoted: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.groupWaitlistEntry.delete({ where: { id: promoteResult.value.entry.id } });
|
||||||
|
return ok({
|
||||||
|
removedAttendeeId: attendeeId,
|
||||||
|
promoted: toGroupWaitlistEntryDto(promoteResult.value.entry),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { type GroupWaitlistQuery, GroupWaitlistQuerySchema } from '@gruperly/shared';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
|
||||||
|
import { validate } from '@/http/validate';
|
||||||
|
import { ListGroupWaitlistEntries } from './use-case';
|
||||||
|
|
||||||
|
const route = new Hono();
|
||||||
|
|
||||||
|
route.get('/:groupId/waitlist', validate.query(GroupWaitlistQuerySchema), async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
if (!user) {
|
||||||
|
return problemJson(c, unauthorizedProblem(c.req.path));
|
||||||
|
}
|
||||||
|
const groupId = c.req.param('groupId');
|
||||||
|
const query = c.req.valid('query') as GroupWaitlistQuery;
|
||||||
|
const useCase = new ListGroupWaitlistEntries();
|
||||||
|
const result = await useCase.execute(groupId, user.id, query);
|
||||||
|
return resultJson(c, result);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default route;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { PrismaClient } from '@generated/prisma/client';
|
||||||
|
import type { GroupWaitlistList, GroupWaitlistQuery, ProblemDetails, Result } from '@gruperly/shared';
|
||||||
|
import { ok } from '@gruperly/shared';
|
||||||
|
import { getPaginationMetadata, getPaginationOffset } from '@/lib/pagination';
|
||||||
|
import prisma from '@/lib/prisma';
|
||||||
|
import { findGroupForUser, type GroupWaitlistEntryRecord, toGroupWaitlistEntryDto } from '../../lib/helpers';
|
||||||
|
|
||||||
|
type ListGroupWaitlistEntriesDeps = {
|
||||||
|
db?: Pick<PrismaClient, 'group' | 'groupWaitlistEntry'>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ListGroupWaitlistEntries {
|
||||||
|
constructor(private readonly deps: ListGroupWaitlistEntriesDeps = {}) {}
|
||||||
|
|
||||||
|
async execute(
|
||||||
|
groupId: string,
|
||||||
|
userId: string,
|
||||||
|
query: GroupWaitlistQuery,
|
||||||
|
): Promise<Result<GroupWaitlistList, ProblemDetails>> {
|
||||||
|
const db = this.deps.db ?? prisma;
|
||||||
|
|
||||||
|
const groupResult = await findGroupForUser(db, groupId, userId);
|
||||||
|
if (!groupResult.ok) {
|
||||||
|
return groupResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = { groupId, status: 'PENDING' as const };
|
||||||
|
const [records, total] = await Promise.all([
|
||||||
|
db.groupWaitlistEntry.findMany({
|
||||||
|
where,
|
||||||
|
skip: getPaginationOffset(query),
|
||||||
|
take: query.pageSize,
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
}),
|
||||||
|
db.groupWaitlistEntry.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return ok({
|
||||||
|
data: records.map((record) => toGroupWaitlistEntryDto(record as unknown as GroupWaitlistEntryRecord)),
|
||||||
|
pagination: getPaginationMetadata(query, total),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
|
||||||
|
import { PromoteGroupWaitlistEntry } from './use-case';
|
||||||
|
|
||||||
|
const route = new Hono();
|
||||||
|
|
||||||
|
route.post('/:groupId/waitlist/:entryId/promote', async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
if (!user) {
|
||||||
|
return problemJson(c, unauthorizedProblem(c.req.path));
|
||||||
|
}
|
||||||
|
const groupId = c.req.param('groupId');
|
||||||
|
const entryId = c.req.param('entryId');
|
||||||
|
const useCase = new PromoteGroupWaitlistEntry();
|
||||||
|
const result = await useCase.execute(groupId, entryId, user.id);
|
||||||
|
return resultJson(c, result);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default route;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { Prisma, PrismaClient } from '@generated/prisma/client';
|
||||||
|
import type { ProblemDetails, PromoteGroupWaitlistEntryResult, Result } from '@gruperly/shared';
|
||||||
|
import { err, ok } from '@gruperly/shared';
|
||||||
|
import { notFoundResourceProblem } from '@/http/problem-builders';
|
||||||
|
import { default as prisma, UnitOfWork } from '@/lib/prisma';
|
||||||
|
import {
|
||||||
|
findGroupOwnedByUser,
|
||||||
|
type GroupWaitlistEntryRecord,
|
||||||
|
} from '../../lib/helpers';
|
||||||
|
import { promoteWaitlistEntryRecord } from '../../lib/promote';
|
||||||
|
|
||||||
|
type PromoteGroupWaitlistEntryDeps = {
|
||||||
|
db?: Pick<PrismaClient, 'group' | 'attendee' | 'groupWaitlistEntry'>;
|
||||||
|
unitOfWork?: UnitOfWork;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class PromoteGroupWaitlistEntry {
|
||||||
|
constructor(private readonly deps: PromoteGroupWaitlistEntryDeps = {}) {}
|
||||||
|
|
||||||
|
async execute(
|
||||||
|
groupId: string,
|
||||||
|
entryId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<Result<PromoteGroupWaitlistEntryResult, ProblemDetails>> {
|
||||||
|
const db = this.deps.db ?? prisma;
|
||||||
|
const unitOfWork = this.deps.unitOfWork ?? new UnitOfWork(prisma);
|
||||||
|
|
||||||
|
const groupResult = await findGroupOwnedByUser(db, groupId, userId);
|
||||||
|
if (!groupResult.ok) {
|
||||||
|
return groupResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = await db.groupWaitlistEntry.findFirst({
|
||||||
|
where: { id: entryId, groupId },
|
||||||
|
});
|
||||||
|
if (!entry) {
|
||||||
|
return err(notFoundResourceProblem('GroupWaitlistEntry', entryId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return unitOfWork.executeResult(
|
||||||
|
async (tx: Prisma.TransactionClient) => {
|
||||||
|
const promoteResult = await promoteWaitlistEntryRecord(
|
||||||
|
tx,
|
||||||
|
groupResult.value,
|
||||||
|
entry as unknown as GroupWaitlistEntryRecord,
|
||||||
|
);
|
||||||
|
if (!promoteResult.ok) {
|
||||||
|
return promoteResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.groupWaitlistEntry.delete({ where: { id: entry.id } });
|
||||||
|
return ok({ attendee: promoteResult.value });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
|
||||||
|
import { RemoveGroupWaitlistEntry } from './use-case';
|
||||||
|
|
||||||
|
const route = new Hono();
|
||||||
|
|
||||||
|
route.delete('/:groupId/waitlist/:entryId', async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
if (!user) {
|
||||||
|
return problemJson(c, unauthorizedProblem(c.req.path));
|
||||||
|
}
|
||||||
|
const groupId = c.req.param('groupId');
|
||||||
|
const entryId = c.req.param('entryId');
|
||||||
|
const useCase = new RemoveGroupWaitlistEntry();
|
||||||
|
const result = await useCase.execute(groupId, entryId, user.id);
|
||||||
|
return resultJson(c, result);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default route;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { PrismaClient } from '@generated/prisma/client';
|
||||||
|
import type { ProblemDetails, RemoveGroupWaitlistEntryResult, Result } from '@gruperly/shared';
|
||||||
|
import { err, ok } from '@gruperly/shared';
|
||||||
|
import { notFoundResourceProblem } from '@/http/problem-builders';
|
||||||
|
import prisma from '@/lib/prisma';
|
||||||
|
import { findGroupOwnedByUser } from '../../lib/helpers';
|
||||||
|
|
||||||
|
type RemoveGroupWaitlistEntryDeps = {
|
||||||
|
db?: Pick<PrismaClient, 'group' | 'groupWaitlistEntry'>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class RemoveGroupWaitlistEntry {
|
||||||
|
constructor(private readonly deps: RemoveGroupWaitlistEntryDeps = {}) {}
|
||||||
|
|
||||||
|
async execute(
|
||||||
|
groupId: string,
|
||||||
|
entryId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<Result<RemoveGroupWaitlistEntryResult, ProblemDetails>> {
|
||||||
|
const db = this.deps.db ?? prisma;
|
||||||
|
|
||||||
|
const groupResult = await findGroupOwnedByUser(db, groupId, userId);
|
||||||
|
if (!groupResult.ok) {
|
||||||
|
return groupResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db.groupWaitlistEntry.findFirst({
|
||||||
|
where: { id: entryId, groupId },
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
return err(notFoundResourceProblem('GroupWaitlistEntry', entryId));
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.groupWaitlistEntry.delete({ where: { id: entryId } });
|
||||||
|
return ok({ deleted: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/backend/src/modules/group-waitlist/index.ts
Normal file
1
apps/backend/src/modules/group-waitlist/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as groupWaitlistRoutes } from './routes';
|
||||||
65
apps/backend/src/modules/group-waitlist/lib/helpers.ts
Normal file
65
apps/backend/src/modules/group-waitlist/lib/helpers.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import type { Group, PrismaClient } from '@generated/prisma/client';
|
||||||
|
import type { GroupWaitlistEntryDto, ProblemDetails, Result } from '@gruperly/shared';
|
||||||
|
import { err, ok } from '@gruperly/shared';
|
||||||
|
import { noGroupAccessProblem, notFoundResourceProblem } from '@/http/problem-builders';
|
||||||
|
|
||||||
|
export type GroupWaitlistEntryRecord = {
|
||||||
|
id: string;
|
||||||
|
groupId: string;
|
||||||
|
fullName: string;
|
||||||
|
phone: string;
|
||||||
|
email: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
status: GroupWaitlistEntryDto['status'];
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function toGroupWaitlistEntryDto(record: GroupWaitlistEntryRecord): GroupWaitlistEntryDto {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
groupId: record.groupId,
|
||||||
|
fullName: record.fullName,
|
||||||
|
phone: record.phone,
|
||||||
|
email: record.email,
|
||||||
|
notes: record.notes,
|
||||||
|
status: record.status,
|
||||||
|
createdAt: record.createdAt.toISOString(),
|
||||||
|
updatedAt: record.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findGroupForUser(
|
||||||
|
db: Pick<PrismaClient, 'group'>,
|
||||||
|
groupId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<Result<Group, ProblemDetails>> {
|
||||||
|
const group = await db.group.findUnique({
|
||||||
|
where: { id: groupId },
|
||||||
|
include: { members: { where: { userId } } },
|
||||||
|
});
|
||||||
|
if (!group) {
|
||||||
|
return err(notFoundResourceProblem('Group', groupId));
|
||||||
|
}
|
||||||
|
const isOwner = group.createdById === userId;
|
||||||
|
const isMember = group.members.length > 0;
|
||||||
|
if (!isOwner && !isMember) {
|
||||||
|
return err(noGroupAccessProblem());
|
||||||
|
}
|
||||||
|
return ok(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findGroupOwnedByUser(
|
||||||
|
db: Pick<PrismaClient, 'group'>,
|
||||||
|
groupId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<Result<Group, ProblemDetails>> {
|
||||||
|
const group = await db.group.findUnique({ where: { id: groupId } });
|
||||||
|
if (!group) {
|
||||||
|
return err(notFoundResourceProblem('Group', groupId));
|
||||||
|
}
|
||||||
|
if (group.createdById !== userId) {
|
||||||
|
return err(noGroupAccessProblem());
|
||||||
|
}
|
||||||
|
return ok(group);
|
||||||
|
}
|
||||||
83
apps/backend/src/modules/group-waitlist/lib/promote.ts
Normal file
83
apps/backend/src/modules/group-waitlist/lib/promote.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import type { PrismaClient } from '@generated/prisma/client';
|
||||||
|
import type { AttendeeDto, ProblemDetails, Result } from '@gruperly/shared';
|
||||||
|
import { err, ok } from '@gruperly/shared';
|
||||||
|
import { capacityReachedProblem, conflictProblem } from '@/http/problem-builders';
|
||||||
|
import { type AttendeeRecord, toAttendeeDto } from '@/modules/attendees/lib/helpers';
|
||||||
|
import { type GroupWaitlistEntryRecord } from './helpers';
|
||||||
|
|
||||||
|
export type PromoteDb = Pick<PrismaClient, 'attendee' | 'groupWaitlistEntry'>;
|
||||||
|
|
||||||
|
type PromoteGroup = {
|
||||||
|
id: string;
|
||||||
|
capacity: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function findFirstPendingWaitlistEntry(
|
||||||
|
db: PromoteDb,
|
||||||
|
groupId: string,
|
||||||
|
): Promise<GroupWaitlistEntryRecord | null> {
|
||||||
|
const record = await db.groupWaitlistEntry.findFirst({
|
||||||
|
where: { groupId, status: 'PENDING' },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
return record as GroupWaitlistEntryRecord | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function promoteWaitlistEntryRecord(
|
||||||
|
db: PromoteDb,
|
||||||
|
group: PromoteGroup,
|
||||||
|
entry: GroupWaitlistEntryRecord,
|
||||||
|
): Promise<Result<AttendeeDto, ProblemDetails>> {
|
||||||
|
if (group.capacity !== null) {
|
||||||
|
const currentCount = await db.attendee.count({ where: { groupId: group.id } });
|
||||||
|
if (currentCount >= group.capacity) {
|
||||||
|
return err(capacityReachedProblem(group.capacity));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db.attendee.findFirst({
|
||||||
|
where: { groupId: group.id, phone: entry.phone },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
return err(
|
||||||
|
conflictProblem({
|
||||||
|
detail: 'Ya existe un alumno registrado con este número de teléfono en este grupo.',
|
||||||
|
code: 'attendee_already_registered',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const attendee = await db.attendee.create({
|
||||||
|
data: {
|
||||||
|
groupId: group.id,
|
||||||
|
fullName: entry.fullName,
|
||||||
|
phone: entry.phone,
|
||||||
|
email: entry.email,
|
||||||
|
notes: entry.notes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return ok(toAttendeeDto(attendee as unknown as AttendeeRecord));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PromoteFirstResult = {
|
||||||
|
attendee: AttendeeDto;
|
||||||
|
entry: GroupWaitlistEntryRecord;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function promoteFirstPendingWaitlistEntry(
|
||||||
|
db: PromoteDb,
|
||||||
|
group: PromoteGroup,
|
||||||
|
): Promise<Result<PromoteFirstResult | null, ProblemDetails>> {
|
||||||
|
const entry = await findFirstPendingWaitlistEntry(db, group.id);
|
||||||
|
if (!entry) {
|
||||||
|
return ok(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await promoteWaitlistEntryRecord(db, group, entry);
|
||||||
|
if (!result.ok) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok({ attendee: result.value, entry });
|
||||||
|
}
|
||||||
12
apps/backend/src/modules/group-waitlist/routes.ts
Normal file
12
apps/backend/src/modules/group-waitlist/routes.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import getAllRoute from './features/get-all/route';
|
||||||
|
import promoteRoute from './features/promote/route';
|
||||||
|
import removeRoute from './features/remove/route';
|
||||||
|
|
||||||
|
const routes = new Hono();
|
||||||
|
|
||||||
|
routes.route('/', getAllRoute);
|
||||||
|
routes.route('/', promoteRoute);
|
||||||
|
routes.route('/', removeRoute);
|
||||||
|
|
||||||
|
export default routes;
|
||||||
@@ -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;
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
22
apps/backend/src/modules/groups/features/create/route.ts
Normal file
22
apps/backend/src/modules/groups/features/create/route.ts
Normal 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;
|
||||||
68
apps/backend/src/modules/groups/features/create/use-case.ts
Normal file
68
apps/backend/src/modules/groups/features/create/use-case.ts
Normal 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) });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { PrismaClient } from '@generated/prisma/client';
|
import type { PrismaClient } from '@generated/prisma/client';
|
||||||
import type { BillingType, GroupDto, WeekDay } from '@gruperly/shared';
|
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 };
|
type PriceLike = { toString(): string };
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
|
import addToGroupWaitlistRoute from '../attendees/features/add-to-waitlist/route';
|
||||||
import bulkCreateAttendeesRoute from '../attendees/features/bulk-create/route';
|
import bulkCreateAttendeesRoute from '../attendees/features/bulk-create/route';
|
||||||
import createAttendeeRoute from '../attendees/features/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 getAllRoute from './features/get-all/route';
|
||||||
import getByIdRoute from './features/get-by-id/route';
|
import getByIdRoute from './features/get-by-id/route';
|
||||||
import inviteTokenRoute from './features/invite-token/route';
|
import inviteTokenRoute from './features/invite-token/route';
|
||||||
@@ -10,11 +13,14 @@ import listAttendeesRoute from './features/list-attendees/route';
|
|||||||
const routes = new Hono();
|
const routes = new Hono();
|
||||||
|
|
||||||
routes.route('/', getAllRoute);
|
routes.route('/', getAllRoute);
|
||||||
routes.route('/from-organization', createFromOrganizationRoute);
|
routes.route('/', createGroupRoute);
|
||||||
routes.route('/', inviteTokenRoute);
|
routes.route('/', inviteTokenRoute);
|
||||||
routes.route('/', listAttendeesRoute);
|
routes.route('/', listAttendeesRoute);
|
||||||
routes.route('/', createAttendeeRoute);
|
routes.route('/', createAttendeeRoute);
|
||||||
routes.route('/', bulkCreateAttendeesRoute);
|
routes.route('/', bulkCreateAttendeesRoute);
|
||||||
routes.route('/', getByIdRoute);
|
routes.route('/', getByIdRoute);
|
||||||
|
routes.route('/', addToGroupWaitlistRoute);
|
||||||
|
routes.route('/', groupWaitlistRoutes);
|
||||||
|
routes.route('/', removeAttendeeRoute);
|
||||||
|
|
||||||
export default routes;
|
export default routes;
|
||||||
@@ -6,12 +6,13 @@ import prisma from '@/lib/prisma';
|
|||||||
import { type AttendeeRecord, normalizePhone, toAttendeeDto } from '@/modules/attendees/lib/helpers';
|
import { type AttendeeRecord, normalizePhone, toAttendeeDto } from '@/modules/attendees/lib/helpers';
|
||||||
|
|
||||||
type JoinViaInviteDeps = {
|
type JoinViaInviteDeps = {
|
||||||
db?: Pick<PrismaClient, 'group' | 'attendee'>;
|
db?: Pick<PrismaClient, 'group' | 'attendee' | 'groupWaitlistEntry'>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type JoinViaInviteResult = {
|
export type JoinViaInviteResult = {
|
||||||
attendee: AttendeeDto;
|
status: 'registered' | 'waitlisted';
|
||||||
message: string;
|
message: string;
|
||||||
|
attendee: AttendeeDto | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class JoinViaInvite {
|
export class JoinViaInvite {
|
||||||
@@ -52,6 +53,35 @@ export class JoinViaInvite {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fullName = `${payload.firstName.trim()} ${payload.lastName.trim()}`.trim();
|
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({
|
const created = await db.attendee.create({
|
||||||
data: {
|
data: {
|
||||||
groupId: group.id,
|
groupId: group.id,
|
||||||
@@ -62,8 +92,9 @@ export class JoinViaInvite {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return ok({
|
return ok({
|
||||||
|
status: 'registered',
|
||||||
attendee: toAttendeeDto(created as unknown as AttendeeRecord),
|
attendee: toAttendeeDto(created as unknown as AttendeeRecord),
|
||||||
message: 'Inscripción realizada con éxito',
|
message: 'Inscripción realizada con éxito',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,12 @@ const db = {
|
|||||||
create: mock(),
|
create: mock(),
|
||||||
count: mock(),
|
count: mock(),
|
||||||
},
|
},
|
||||||
|
groupWaitlistEntry: {
|
||||||
|
findMany: mock(),
|
||||||
|
findFirst: mock(),
|
||||||
|
create: mock(),
|
||||||
|
count: mock(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
mock.module('@/lib/prisma', () => ({
|
mock.module('@/lib/prisma', () => ({
|
||||||
@@ -130,8 +136,9 @@ describe('attendee incorporation & invite-token in groups', () => {
|
|||||||
|
|
||||||
expect(res.status).toBe(201);
|
expect(res.status).toBe(201);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
expect(data.fullName).toBe('Martín Gómez');
|
expect(data.outcome).toBe('created');
|
||||||
expect(data.phone).toBe('+5491133445566');
|
expect(data.attendee.fullName).toBe('Martín Gómez');
|
||||||
|
expect(data.attendee.phone).toBe('+5491133445566');
|
||||||
expect(prisma.attendee.create).toHaveBeenCalledWith({
|
expect(prisma.attendee.create).toHaveBeenCalledWith({
|
||||||
data: {
|
data: {
|
||||||
groupId: 'group-1',
|
groupId: 'group-1',
|
||||||
@@ -161,6 +168,244 @@ describe('attendee incorporation & invite-token in groups', () => {
|
|||||||
expect(res.status).toBe(409);
|
expect(res.status).toBe(409);
|
||||||
expect(prisma.attendee.create).not.toHaveBeenCalled();
|
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)', () => {
|
describe('POST /groups/:groupId/attendees/bulk (bulk import)', () => {
|
||||||
|
|||||||
@@ -4,14 +4,13 @@ import { Hono } from 'hono';
|
|||||||
const db = {
|
const db = {
|
||||||
group: {
|
group: {
|
||||||
findMany: mock(),
|
findMany: mock(),
|
||||||
findFirst: mock(),
|
|
||||||
count: mock(),
|
count: mock(),
|
||||||
create: mock(),
|
create: mock(),
|
||||||
},
|
},
|
||||||
groupMember: {
|
groupMember: {
|
||||||
create: mock(),
|
create: mock(),
|
||||||
},
|
},
|
||||||
organization: {
|
merchantAccount: {
|
||||||
findUnique: mock(),
|
findUnique: mock(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -102,19 +101,29 @@ describe('groups routes', () => {
|
|||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates a group from an owned organization', async () => {
|
it('creates a group with billing configuration', async () => {
|
||||||
const organization = {
|
prisma.merchantAccount.findUnique.mockResolvedValue({ id: 'merchant-1', userId });
|
||||||
id: 'org-1',
|
prisma.group.create.mockResolvedValue({
|
||||||
name: 'Escuela Alfa',
|
...group,
|
||||||
members: [{ userId, role: 'owner' }],
|
days: ['MONDAY'],
|
||||||
};
|
time: '09:00',
|
||||||
prisma.organization.findUnique.mockResolvedValue(organization as never);
|
capacity: 20,
|
||||||
prisma.group.findFirst.mockResolvedValue(null);
|
price: 500,
|
||||||
prisma.group.create.mockResolvedValue(group);
|
billingType: 'MONTHLY',
|
||||||
|
dueDay: 5,
|
||||||
|
});
|
||||||
|
|
||||||
const res = await makeApp({ id: userId }).request('/groups/from-organization', {
|
const res = await makeApp({ id: userId }).request('/groups', {
|
||||||
method: 'POST',
|
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' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -122,77 +131,63 @@ describe('groups routes', () => {
|
|||||||
expect(await res.json()).toEqual({
|
expect(await res.json()).toEqual({
|
||||||
group: {
|
group: {
|
||||||
...group,
|
...group,
|
||||||
|
days: ['MONDAY'],
|
||||||
|
time: '09:00',
|
||||||
|
capacity: 20,
|
||||||
|
price: 500,
|
||||||
|
billingType: 'MONTHLY',
|
||||||
|
dueDay: 5,
|
||||||
createdAt: group.createdAt.toISOString(),
|
createdAt: group.createdAt.toISOString(),
|
||||||
updatedAt: group.updatedAt.toISOString(),
|
updatedAt: group.updatedAt.toISOString(),
|
||||||
},
|
},
|
||||||
alreadyExists: false,
|
|
||||||
});
|
});
|
||||||
expect(prisma.group.create).toHaveBeenCalledWith({
|
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 () => {
|
it('rejects creating a group without a linked merchant account', async () => {
|
||||||
const organization = {
|
prisma.merchantAccount.findUnique.mockResolvedValue(null);
|
||||||
id: 'org-1',
|
|
||||||
name: 'Escuela Alfa',
|
|
||||||
members: [{ userId, role: 'owner' }],
|
|
||||||
};
|
|
||||||
prisma.organization.findUnique.mockResolvedValue(organization as never);
|
|
||||||
prisma.group.findFirst.mockResolvedValue(group);
|
|
||||||
|
|
||||||
const res = await makeApp({ id: userId }).request('/groups/from-organization', {
|
const res = await makeApp({ id: userId }).request('/groups', {
|
||||||
method: 'POST',
|
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' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(409);
|
||||||
const body = await res.json();
|
expect(await res.json()).toMatchObject({ code: 'payment_not_setup' });
|
||||||
expect(body.alreadyExists).toBe(true);
|
|
||||||
expect(prisma.group.create).not.toHaveBeenCalled();
|
expect(prisma.group.create).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects creating a group for an unknown organization', async () => {
|
it('rejects creating a group with an invalid payload', async () => {
|
||||||
prisma.organization.findUnique.mockResolvedValue(null);
|
const res = await makeApp({ id: userId }).request('/groups', {
|
||||||
|
|
||||||
const res = await makeApp({ id: userId }).request('/groups/from-organization', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ organizationId: 'missing' }),
|
body: JSON.stringify({ name: 'x' }),
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(400);
|
||||||
expect(await res.json()).toMatchObject({ code: 'organization_not_found' });
|
|
||||||
expect(prisma.group.create).not.toHaveBeenCalled();
|
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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -8,6 +8,11 @@ const db = {
|
|||||||
attendee: {
|
attendee: {
|
||||||
findFirst: mock(),
|
findFirst: mock(),
|
||||||
create: mock(),
|
create: mock(),
|
||||||
|
count: mock(),
|
||||||
|
},
|
||||||
|
groupWaitlistEntry: {
|
||||||
|
findFirst: mock(),
|
||||||
|
create: mock(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -151,5 +156,48 @@ describe('public invitations routes', () => {
|
|||||||
|
|
||||||
expect(res.status).toBe(400);
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
352
apps/backend/test/waitlist-management.test.ts
Normal file
352
apps/backend/test/waitlist-management.test.ts
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
import { beforeEach, describe, expect, it, mock, vi } from 'bun:test';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
|
||||||
|
const db = {
|
||||||
|
group: {
|
||||||
|
findUnique: mock(),
|
||||||
|
},
|
||||||
|
attendee: {
|
||||||
|
findFirst: mock(),
|
||||||
|
findMany: mock(),
|
||||||
|
count: mock(),
|
||||||
|
create: mock(),
|
||||||
|
delete: mock(),
|
||||||
|
},
|
||||||
|
groupWaitlistEntry: {
|
||||||
|
findMany: mock(),
|
||||||
|
findFirst: mock(),
|
||||||
|
count: mock(),
|
||||||
|
create: mock(),
|
||||||
|
delete: mock(),
|
||||||
|
},
|
||||||
|
payment: {
|
||||||
|
count: mock(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mock.module('@/lib/prisma', () => ({
|
||||||
|
default: db,
|
||||||
|
getPrismaClient: mock(),
|
||||||
|
UnitOfWork: class {
|
||||||
|
executeResult = mock(async (cb: (tx: unknown) => Promise<unknown>) => cb(db));
|
||||||
|
execute = mock(async (cb: (tx: unknown) => Promise<unknown>) => cb(db));
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import prisma from '@/lib/prisma';
|
||||||
|
import { groupsRoutes } from '@/modules/groups';
|
||||||
|
|
||||||
|
const teacherId = 'teacher-1';
|
||||||
|
const mockGroup = {
|
||||||
|
id: 'group-1',
|
||||||
|
name: 'Taller de Pintura',
|
||||||
|
description: null,
|
||||||
|
createdById: teacherId,
|
||||||
|
inviteToken: null,
|
||||||
|
createdAt: new Date('2026-09-01T10:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-09-01T10:00:00.000Z'),
|
||||||
|
days: ['MONDAY'],
|
||||||
|
time: '18:00',
|
||||||
|
capacity: 20,
|
||||||
|
price: 1500,
|
||||||
|
billingType: 'MONTHLY',
|
||||||
|
dueDay: 10,
|
||||||
|
members: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitlistEntry = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
id: 'wl-1',
|
||||||
|
groupId: 'group-1',
|
||||||
|
fullName: 'Martín Gómez',
|
||||||
|
phone: '+5491133445566',
|
||||||
|
email: null,
|
||||||
|
notes: null,
|
||||||
|
status: 'PENDING',
|
||||||
|
createdAt: new Date('2026-09-10T10:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-09-10T10:00:00.000Z'),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const attendee = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
id: 'att-1',
|
||||||
|
groupId: 'group-1',
|
||||||
|
fullName: 'Martín Gómez',
|
||||||
|
phone: '+5491133445566',
|
||||||
|
email: null,
|
||||||
|
guardianName: null,
|
||||||
|
guardianPhone: null,
|
||||||
|
notes: null,
|
||||||
|
createdAt: new Date('2026-09-18T10:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-09-18T10:00:00.000Z'),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeApp(userValue: unknown) {
|
||||||
|
const app = new Hono();
|
||||||
|
app.use('*', async (c, next) => {
|
||||||
|
c.set('user', userValue as never);
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
app.route('/groups', groupsRoutes);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('group waitlist management', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /groups/:groupId/waitlist', () => {
|
||||||
|
it('lists pending entries in FIFO order with pagination', async () => {
|
||||||
|
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||||
|
prisma.groupWaitlistEntry.findMany.mockResolvedValue([
|
||||||
|
waitlistEntry({ id: 'wl-1', fullName: 'Ana García', createdAt: new Date('2026-09-08T10:00:00.000Z') }),
|
||||||
|
waitlistEntry({ id: 'wl-2', fullName: 'Pedro López', createdAt: new Date('2026-09-09T10:00:00.000Z') }),
|
||||||
|
] as never);
|
||||||
|
prisma.groupWaitlistEntry.count.mockResolvedValue(2);
|
||||||
|
|
||||||
|
const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist');
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data.data).toHaveLength(2);
|
||||||
|
expect(data.data[0].fullName).toBe('Ana García');
|
||||||
|
expect(data.pagination).toEqual({ page: 1, pageSize: 10, total: 2, totalPages: 1 });
|
||||||
|
expect(prisma.groupWaitlistEntry.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { groupId: 'group-1', status: 'PENDING' },
|
||||||
|
skip: 0,
|
||||||
|
take: 10,
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects user without access to the group', async () => {
|
||||||
|
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||||
|
|
||||||
|
const res = await makeApp({ id: 'other-user' }).request('/groups/group-1/waitlist');
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(prisma.groupWaitlistEntry.findMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /groups/:groupId/waitlist/:entryId/promote', () => {
|
||||||
|
it('creates an attendee from the entry and removes it from the waitlist', async () => {
|
||||||
|
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||||
|
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(waitlistEntry() as never);
|
||||||
|
prisma.attendee.count.mockResolvedValue(5);
|
||||||
|
prisma.attendee.findFirst.mockResolvedValue(null);
|
||||||
|
prisma.attendee.create.mockResolvedValue(attendee() as never);
|
||||||
|
|
||||||
|
const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-1/promote', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data.attendee.fullName).toBe('Martín Gómez');
|
||||||
|
expect(prisma.attendee.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({ groupId: 'group-1', fullName: 'Martín Gómez', phone: '+5491133445566' }),
|
||||||
|
});
|
||||||
|
expect(prisma.groupWaitlistEntry.delete).toHaveBeenCalledWith({ where: { id: 'wl-1' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects with 409 when the group reached its capacity', async () => {
|
||||||
|
prisma.group.findUnique.mockResolvedValue({ ...mockGroup, capacity: 5 } as never);
|
||||||
|
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(waitlistEntry() as never);
|
||||||
|
prisma.attendee.count.mockResolvedValue(5);
|
||||||
|
|
||||||
|
const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-1/promote', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.code).toBe('group_capacity_reached');
|
||||||
|
expect(prisma.attendee.create).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.groupWaitlistEntry.delete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects with 409 when the phone already belongs to an attendee', async () => {
|
||||||
|
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||||
|
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(waitlistEntry() as never);
|
||||||
|
prisma.attendee.count.mockResolvedValue(5);
|
||||||
|
prisma.attendee.findFirst.mockResolvedValue({ id: 'att-existing' } as never);
|
||||||
|
|
||||||
|
const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-1/promote', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.code).toBe('attendee_already_registered');
|
||||||
|
expect(prisma.attendee.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when the entry does not exist', async () => {
|
||||||
|
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||||
|
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-missing/promote', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-owner user', async () => {
|
||||||
|
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||||
|
|
||||||
|
const res = await makeApp({ id: 'other-user' }).request('/groups/group-1/waitlist/wl-1/promote', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(prisma.attendee.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /groups/:groupId/waitlist/:entryId', () => {
|
||||||
|
it('removes a waitlist entry', async () => {
|
||||||
|
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||||
|
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(waitlistEntry() as never);
|
||||||
|
|
||||||
|
const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-1', {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data.deleted).toBe(true);
|
||||||
|
expect(prisma.groupWaitlistEntry.delete).toHaveBeenCalledWith({ where: { id: 'wl-1' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when the entry does not exist', async () => {
|
||||||
|
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||||
|
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-missing', {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(prisma.groupWaitlistEntry.delete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-owner user', async () => {
|
||||||
|
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||||
|
|
||||||
|
const res = await makeApp({ id: 'other-user' }).request('/groups/group-1/waitlist/wl-1', {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(prisma.groupWaitlistEntry.delete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /groups/:groupId/attendees/:attendeeId', () => {
|
||||||
|
it('removes an attendee without promotion', async () => {
|
||||||
|
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||||
|
prisma.attendee.findFirst.mockResolvedValue(attendee() as never);
|
||||||
|
prisma.payment.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
const res = await makeApp({ id: teacherId }).request('/groups/group-1/attendees/att-1', {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.removedAttendeeId).toBe('att-1');
|
||||||
|
expect(body.promoted).toBeNull();
|
||||||
|
expect(prisma.attendee.delete).toHaveBeenCalledWith({ where: { id: 'att-1' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes an attendee and promotes the first pending waitlist entry atomically', async () => {
|
||||||
|
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||||
|
prisma.attendee.findFirst.mockImplementation(async ({ where }: { where?: { id?: string } }) => {
|
||||||
|
if (where?.id) return attendee() as never;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
prisma.payment.count.mockResolvedValue(0);
|
||||||
|
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(
|
||||||
|
waitlistEntry({ id: 'wl-1', fullName: 'Sofía Ruiz', phone: '+5491133778899' }) as never,
|
||||||
|
);
|
||||||
|
prisma.attendee.count.mockResolvedValue(5);
|
||||||
|
prisma.attendee.create.mockResolvedValue(
|
||||||
|
attendee({ id: 'att-2', fullName: 'Sofía Ruiz', phone: '+5491133778899' }) as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await makeApp({ id: teacherId }).request(
|
||||||
|
'/groups/group-1/attendees/att-1?promoteFromWaitlist=true',
|
||||||
|
{ method: 'DELETE' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.removedAttendeeId).toBe('att-1');
|
||||||
|
expect(body.promoted).toMatchObject({ id: 'wl-1', fullName: 'Sofía Ruiz' });
|
||||||
|
expect(prisma.attendee.delete).toHaveBeenCalledWith({ where: { id: 'att-1' } });
|
||||||
|
expect(prisma.groupWaitlistEntry.delete).toHaveBeenCalledWith({ where: { id: 'wl-1' } });
|
||||||
|
expect(prisma.attendee.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({ fullName: 'Sofía Ruiz', phone: '+5491133778899' }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes the attendee when promote requested but waitlist is empty', async () => {
|
||||||
|
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||||
|
prisma.attendee.findFirst.mockResolvedValue(attendee() as never);
|
||||||
|
prisma.payment.count.mockResolvedValue(0);
|
||||||
|
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const res = await makeApp({ id: teacherId }).request(
|
||||||
|
'/groups/group-1/attendees/att-1?promoteFromWaitlist=true',
|
||||||
|
{ method: 'DELETE' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.removedAttendeeId).toBe('att-1');
|
||||||
|
expect(body.promoted).toBeNull();
|
||||||
|
expect(prisma.attendee.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks removal when the attendee has payments', async () => {
|
||||||
|
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||||
|
prisma.attendee.findFirst.mockResolvedValue(attendee() as never);
|
||||||
|
prisma.payment.count.mockResolvedValue(2);
|
||||||
|
|
||||||
|
const res = await makeApp({ id: teacherId }).request('/groups/group-1/attendees/att-1', {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.code).toBe('attendee_has_payments');
|
||||||
|
expect(prisma.attendee.delete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when the attendee does not belong to the group', async () => {
|
||||||
|
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||||
|
prisma.attendee.findFirst.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const res = await makeApp({ id: teacherId }).request('/groups/group-1/attendees/att-missing', {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(prisma.attendee.delete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-owner user', async () => {
|
||||||
|
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||||
|
|
||||||
|
const res = await makeApp({ id: 'other-user' }).request('/groups/group-1/attendees/att-1', {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(prisma.attendee.delete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
231
apps/web/src/components/groups/GroupForm.tsx
Normal file
231
apps/web/src/components/groups/GroupForm.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -22,11 +22,6 @@ const BREADCRUMBS: Record<string, Crumb[]> = {
|
|||||||
{ label: 'Ajustes', to: '/settings' },
|
{ label: 'Ajustes', to: '/settings' },
|
||||||
{ label: 'Seguridad' },
|
{ label: 'Seguridad' },
|
||||||
],
|
],
|
||||||
'/settings/organizations': [
|
|
||||||
{ label: 'Inicio', to: '/' },
|
|
||||||
{ label: 'Ajustes', to: '/settings' },
|
|
||||||
{ label: 'Organizaciones' },
|
|
||||||
],
|
|
||||||
'/profile': [
|
'/profile': [
|
||||||
{ label: 'Inicio', to: '/' },
|
{ label: 'Inicio', to: '/' },
|
||||||
{ label: 'Mi perfil' },
|
{ label: 'Mi perfil' },
|
||||||
|
|||||||
@@ -1,23 +1,7 @@
|
|||||||
import { useMutation } from '@tanstack/react-query'
|
import { useMutation } from '@tanstack/react-query'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import type { CreateFirstGroup, OnboardingGroupDto } from '@gruperly/shared'
|
||||||
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 { createFirstGroup } from '../../lib/api'
|
import { createFirstGroup } from '../../lib/api'
|
||||||
import { cn } from '../../lib/utils'
|
import { GroupForm } from '../groups/GroupForm'
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
type FirstGroupStepProps = {
|
type FirstGroupStepProps = {
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
@@ -25,212 +9,22 @@ type FirstGroupStepProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FirstGroupStep({ onBack, onCompleted }: 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({
|
const create = useMutation({
|
||||||
mutationFn: (values: CreateFirstGroup) => createFirstGroup(values),
|
mutationFn: (values: CreateFirstGroup) => createFirstGroup(values),
|
||||||
onSuccess: (result) => onCompleted(result.group),
|
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 (
|
return (
|
||||||
<form onSubmit={handleSubmit((values) => create.mutate(values))} noValidate className="space-y-5">
|
<GroupForm
|
||||||
<header className="space-y-1">
|
heading="Tu primer grupo"
|
||||||
<h2 className="text-2xl font-bold text-primary">Tu primer grupo</h2>
|
description="Definí los datos de la clase que vas a cobrar. Después siempre podés editarlos."
|
||||||
<p className="text-sm text-foreground/60">
|
submitLabel="Crear grupo"
|
||||||
Definí los datos de la clase que vas a cobrar. Después siempre podés editarlos.
|
isSubmitting={create.isPending}
|
||||||
</p>
|
errorMessage={
|
||||||
</header>
|
create.isError ? 'No pudimos crear el grupo. Revisá los datos e intentá de nuevo.' : null
|
||||||
|
}
|
||||||
<div>
|
onSubmit={(values) => create.mutate(values)}
|
||||||
<Label htmlFor="name">Nombre del grupo</Label>
|
onBack={onBack}
|
||||||
<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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -6,15 +6,24 @@ import type {
|
|||||||
ConnectPayment,
|
ConnectPayment,
|
||||||
ConnectPaymentResult,
|
ConnectPaymentResult,
|
||||||
CreateAttendee,
|
CreateAttendee,
|
||||||
|
CreateAttendeeResult,
|
||||||
CreateFirstGroup,
|
CreateFirstGroup,
|
||||||
CreateFirstGroupResult,
|
CreateFirstGroupResult,
|
||||||
|
CreateGroupResult,
|
||||||
|
CreateGroupWaitlistEntry,
|
||||||
GroupDto,
|
GroupDto,
|
||||||
GroupInviteInfoDto,
|
GroupInviteInfoDto,
|
||||||
GroupList,
|
GroupList,
|
||||||
|
GroupWaitlistEntryDto,
|
||||||
|
GroupWaitlistList,
|
||||||
InviteTokenResult,
|
InviteTokenResult,
|
||||||
JoinGroupViaInvite,
|
JoinGroupViaInvite,
|
||||||
|
JoinGroupViaInviteResult,
|
||||||
OnboardingStatusDto,
|
OnboardingStatusDto,
|
||||||
ProblemDetails,
|
ProblemDetails,
|
||||||
|
PromoteGroupWaitlistEntryResult,
|
||||||
|
RemoveAttendeeResult,
|
||||||
|
RemoveGroupWaitlistEntryResult,
|
||||||
} from '@gruperly/shared'
|
} from '@gruperly/shared'
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4000'
|
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),
|
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 getGroups = () => apiFetch<GroupList>('/api/v1/groups')
|
||||||
|
|
||||||
export const getGroup = (groupId: string) =>
|
export const getGroup = (groupId: string) =>
|
||||||
@@ -87,13 +102,26 @@ export const getInviteInfo = (token: string) =>
|
|||||||
apiFetch<GroupInviteInfoDto>(`/api/v1/invitations/${token}`)
|
apiFetch<GroupInviteInfoDto>(`/api/v1/invitations/${token}`)
|
||||||
|
|
||||||
export const joinViaInvite = (token: string, payload: JoinGroupViaInvite) =>
|
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',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const createAttendee = (groupId: string, payload: CreateAttendee) =>
|
export const createAttendee = (
|
||||||
apiFetch<AttendeeDto>(`/api/v1/groups/${groupId}/attendees`, {
|
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',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
@@ -105,4 +133,29 @@ export const bulkCreateAttendees = (groupId: string, payload: BulkCreateAttendee
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const getGroupAttendees = (groupId: string, page = 1, pageSize = 20) =>
|
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',
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -6,12 +6,12 @@ import { PaymentsView } from './routes/payments'
|
|||||||
import { SettingsView } from './routes/settings'
|
import { SettingsView } from './routes/settings'
|
||||||
import { ProfileView } from './routes/profile'
|
import { ProfileView } from './routes/profile'
|
||||||
import { SecurityPage } from './routes/security'
|
import { SecurityPage } from './routes/security'
|
||||||
import { OrganizationsPage } from './routes/organizations'
|
|
||||||
import { LoginPage } from './routes/auth/login'
|
import { LoginPage } from './routes/auth/login'
|
||||||
import { SignupPage } from './routes/auth/signup'
|
import { SignupPage } from './routes/auth/signup'
|
||||||
import { VerifyEmailPage } from './routes/auth/verify-email'
|
import { VerifyEmailPage } from './routes/auth/verify-email'
|
||||||
import { OnboardingView } from './routes/onboarding'
|
import { OnboardingView } from './routes/onboarding'
|
||||||
import { GroupDetailView } from './routes/group-detail'
|
import { GroupDetailView } from './routes/group-detail'
|
||||||
|
import { CreateGroupView } from './routes/create-group'
|
||||||
import { JoinGroupView } from './routes/join-group'
|
import { JoinGroupView } from './routes/join-group'
|
||||||
|
|
||||||
const rootRoute = createRootRoute({
|
const rootRoute = createRootRoute({
|
||||||
@@ -68,6 +68,12 @@ const groupsRoute = createRoute({
|
|||||||
component: GroupsView,
|
component: GroupsView,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const createGroupRoute = createRoute({
|
||||||
|
getParentRoute: () => appLayoutRoute,
|
||||||
|
path: '/groups/new',
|
||||||
|
component: CreateGroupView,
|
||||||
|
})
|
||||||
|
|
||||||
const groupDetailRoute = createRoute({
|
const groupDetailRoute = createRoute({
|
||||||
getParentRoute: () => appLayoutRoute,
|
getParentRoute: () => appLayoutRoute,
|
||||||
path: '/groups/$groupId',
|
path: '/groups/$groupId',
|
||||||
@@ -98,12 +104,6 @@ const profileRoute = createRoute({
|
|||||||
component: ProfileView,
|
component: ProfileView,
|
||||||
})
|
})
|
||||||
|
|
||||||
const organizationsRoute = createRoute({
|
|
||||||
getParentRoute: () => appLayoutRoute,
|
|
||||||
path: '/settings/organizations',
|
|
||||||
component: OrganizationsPage,
|
|
||||||
})
|
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
loginRoute,
|
loginRoute,
|
||||||
signupRoute,
|
signupRoute,
|
||||||
@@ -113,11 +113,11 @@ const routeTree = rootRoute.addChildren([
|
|||||||
appLayoutRoute.addChildren([
|
appLayoutRoute.addChildren([
|
||||||
indexRoute,
|
indexRoute,
|
||||||
groupsRoute,
|
groupsRoute,
|
||||||
|
createGroupRoute,
|
||||||
groupDetailRoute,
|
groupDetailRoute,
|
||||||
paymentsRoute,
|
paymentsRoute,
|
||||||
settingsRoute,
|
settingsRoute,
|
||||||
securityRoute,
|
securityRoute,
|
||||||
organizationsRoute,
|
|
||||||
profileRoute,
|
profileRoute,
|
||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
|
|||||||
39
apps/web/src/routes/create-group.tsx
Normal file
39
apps/web/src/routes/create-group.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
import { useState, useRef, type ReactNode } from 'react';
|
import { useState, useRef, type ReactNode } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useParams, useNavigate, Link } from '@tanstack/react-router';
|
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 {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
@@ -23,17 +30,24 @@ import {
|
|||||||
StickyNote,
|
StickyNote,
|
||||||
Trash2,
|
Trash2,
|
||||||
UploadCloud,
|
UploadCloud,
|
||||||
|
UserCheck,
|
||||||
|
UserMinus,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
Users,
|
Users,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { CreateAttendee } from '@gruperly/shared';
|
|
||||||
import { Badge, Button, Input, Label, Modal, useToast } from '../components/ui';
|
import { Badge, Button, Input, Label, Modal, useToast } from '../components/ui';
|
||||||
import {
|
import {
|
||||||
|
addToGroupWaitlist,
|
||||||
|
ApiError,
|
||||||
bulkCreateAttendees,
|
bulkCreateAttendees,
|
||||||
createAttendee,
|
createAttendee,
|
||||||
getGroup,
|
getGroup,
|
||||||
getGroupAttendees,
|
getGroupAttendees,
|
||||||
|
getGroupWaitlist,
|
||||||
getInviteToken,
|
getInviteToken,
|
||||||
|
promoteGroupWaitlistEntry,
|
||||||
|
removeGroupAttendee,
|
||||||
|
removeGroupWaitlistEntry,
|
||||||
} from '../lib/api';
|
} from '../lib/api';
|
||||||
import {
|
import {
|
||||||
downloadAttendeeTemplateCsv,
|
downloadAttendeeTemplateCsv,
|
||||||
@@ -55,6 +69,9 @@ export function GroupDetailView() {
|
|||||||
const [isAddAttendeeModalOpen, setIsAddAttendeeModalOpen] = useState(false);
|
const [isAddAttendeeModalOpen, setIsAddAttendeeModalOpen] = useState(false);
|
||||||
const [selectedAttendee, setSelectedAttendee] = useState<AttendeeDto | null>(null);
|
const [selectedAttendee, setSelectedAttendee] = useState<AttendeeDto | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<'quick' | 'bulk'>('quick');
|
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
|
// Quick form state
|
||||||
const [firstName, setFirstName] = useState('');
|
const [firstName, setFirstName] = useState('');
|
||||||
@@ -70,6 +87,11 @@ export function GroupDetailView() {
|
|||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
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
|
// Attendees search filter
|
||||||
const [searchFilter, setSearchFilter] = useState('');
|
const [searchFilter, setSearchFilter] = useState('');
|
||||||
|
|
||||||
@@ -92,6 +114,12 @@ export function GroupDetailView() {
|
|||||||
enabled: Boolean(groupId),
|
enabled: Boolean(groupId),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const waitlistQuery = useQuery({
|
||||||
|
queryKey: ['group-waitlist', groupId],
|
||||||
|
queryFn: () => getGroupWaitlist(groupId, 1, 100),
|
||||||
|
enabled: Boolean(groupId),
|
||||||
|
});
|
||||||
|
|
||||||
// Regenerate invite token mutation
|
// Regenerate invite token mutation
|
||||||
const regenerateTokenMutation = useMutation({
|
const regenerateTokenMutation = useMutation({
|
||||||
mutationFn: () => getInviteToken(groupId, true),
|
mutationFn: () => getInviteToken(groupId, true),
|
||||||
@@ -105,23 +133,103 @@ export function GroupDetailView() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Quick add attendee mutation
|
// 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({
|
const createAttendeeMutation = useMutation({
|
||||||
mutationFn: (payload: CreateAttendee) => createAttendee(groupId, payload),
|
mutationFn: (payload: CreateAttendee) => createAttendee(groupId, payload),
|
||||||
onSuccess: (created) => {
|
onMutate: (payload) => setPendingCapacityPayload(payload),
|
||||||
toast.success(`Miembro ${created.fullName} agregado con éxito.`);
|
onSuccess: handleCreateSuccess,
|
||||||
queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] });
|
onError: (err: Error) => {
|
||||||
setFirstName('');
|
if (err instanceof ApiError && err.problem?.code === 'group_capacity_reached') {
|
||||||
setLastName('');
|
setIsCapacityModalOpen(true);
|
||||||
setPhone('');
|
return;
|
||||||
setEmail('');
|
}
|
||||||
setNotes('');
|
toast.error(err.message || 'Error al agregar miembro.');
|
||||||
setIsAddAttendeeModalOpen(false);
|
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force add (owner overrides the full capacity)
|
||||||
|
const createAttendeeForceMutation = useMutation({
|
||||||
|
mutationFn: (payload: CreateAttendee) => createAttendee(groupId, payload, { allowOverflow: true }),
|
||||||
|
onSuccess: handleCreateSuccess,
|
||||||
onError: (err: Error) => {
|
onError: (err: Error) => {
|
||||||
toast.error(err.message || 'Error al agregar miembro.');
|
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
|
// Bulk import mutation
|
||||||
const bulkImportMutation = useMutation({
|
const bulkImportMutation = useMutation({
|
||||||
mutationFn: (attendees: Array<{ firstName: string; lastName: string; fullName: string; phone: string; email?: string; notes?: string }>) =>
|
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 handleCopyLink = async () => {
|
||||||
const url = inviteTokenQuery.data?.inviteUrl;
|
const url = inviteTokenQuery.data?.inviteUrl;
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
@@ -240,7 +405,7 @@ export function GroupDetailView() {
|
|||||||
setParsedContacts((prev) => prev.filter((c) => c.id !== id));
|
setParsedContacts((prev) => prev.filter((c) => c.id !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirmBulkImport = () => {
|
const doBulkImport = () => {
|
||||||
const validRows = parsedContacts.filter((c) => c.isValid);
|
const validRows = parsedContacts.filter((c) => c.isValid);
|
||||||
if (validRows.length === 0) {
|
if (validRows.length === 0) {
|
||||||
toast.error('No hay miembros válidos para importar.');
|
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 group = groupQuery.data;
|
||||||
const attendees = attendeesQuery.data?.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 filteredAttendees = attendees.filter((a) => {
|
||||||
const q = searchFilter.toLowerCase();
|
const q = searchFilter.toLowerCase();
|
||||||
return (
|
return (
|
||||||
@@ -307,7 +489,7 @@ export function GroupDetailView() {
|
|||||||
<div>
|
<div>
|
||||||
<Link
|
<Link
|
||||||
to="/groups"
|
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" />
|
<ArrowLeft className="size-4" />
|
||||||
<span>Volver a grupos</span>
|
<span>Volver a grupos</span>
|
||||||
@@ -360,9 +542,10 @@ export function GroupDetailView() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Users className="size-5 text-accent shrink-0" />
|
<Users className="size-5 text-accent shrink-0" />
|
||||||
<div>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -382,108 +565,201 @@ export function GroupDetailView() {
|
|||||||
</div>
|
</div>
|
||||||
</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="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 className="flex flex-col gap-3">
|
||||||
<div>
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
<h2 className="text-lg font-semibold text-primary">
|
<div className="flex items-center rounded-xl bg-primary-soft p-1 w-full sm:w-[26rem]">
|
||||||
Miembros del Grupo ({attendees.length})
|
<button
|
||||||
</h2>
|
type="button"
|
||||||
<p className="text-xs text-foreground/60">
|
onClick={() => setListSection('members')}
|
||||||
Listado de todos los miembros incorporados al grupo.
|
className={`flex flex-1 items-center justify-center gap-1.5 py-2 text-xs font-semibold rounded-lg transition-all ${
|
||||||
</p>
|
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>
|
||||||
|
|
||||||
<div className="relative w-full sm:w-64">
|
<p className="text-xs text-foreground/60">
|
||||||
<Search className="absolute left-3 top-2.5 size-4 text-foreground/40" />
|
{listSection === 'members'
|
||||||
<Input
|
? 'Listado de todos los miembros incorporados al grupo.'
|
||||||
placeholder="Buscar por nombre o teléfono..."
|
: 'Personas que esperan un cupo libre en el grupo. Al pasar a alguien al grupo, se crea automáticamente su registro como miembro.'}
|
||||||
value={searchFilter}
|
</p>
|
||||||
onChange={(e) => setSearchFilter(e.target.value)}
|
|
||||||
className="pl-9 h-9 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{attendeesQuery.isPending ? (
|
{listSection === 'members' ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<>
|
||||||
<Loader2 className="size-6 animate-spin text-accent" />
|
{attendeesQuery.isPending ? (
|
||||||
</div>
|
<div className="flex items-center justify-center py-12">
|
||||||
) : null}
|
<Loader2 className="size-6 animate-spin text-accent" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{attendeesQuery.isSuccess && attendees.length === 0 ? (
|
{attendeesQuery.isSuccess && attendees.length === 0 ? (
|
||||||
<div className="rounded-xl border border-dashed border-border py-12 px-4 text-center">
|
<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" />
|
<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="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">
|
<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.
|
Puedes compartir el enlace de invitación único o agregar miembros de forma manual o masiva.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4 flex items-center justify-center gap-3">
|
<div className="mt-4 flex items-center justify-center gap-3">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsInviteModalOpen(true)}
|
onClick={() => setIsInviteModalOpen(true)}
|
||||||
className="gap-2"
|
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)}
|
|
||||||
>
|
>
|
||||||
<td className="py-3 px-3">
|
<Share2 className="size-4" />
|
||||||
<div className="flex items-center gap-2.5">
|
Compartir Enlace
|
||||||
<div className="size-8 rounded-full bg-accent/10 text-accent font-semibold flex items-center justify-center text-xs">
|
</Button>
|
||||||
{attendee.fullName.charAt(0).toUpperCase()}
|
<Button
|
||||||
</div>
|
variant="primary"
|
||||||
<span className="font-medium text-primary">{attendee.fullName}</span>
|
size="sm"
|
||||||
</div>
|
onClick={() => setIsAddAttendeeModalOpen(true)}
|
||||||
</td>
|
className="gap-2"
|
||||||
<td className="py-3 px-3 text-foreground/70">
|
>
|
||||||
{attendee.phone || <span className="text-foreground/40">—</span>}
|
<Plus className="size-4" />
|
||||||
</td>
|
Agregar Miembros
|
||||||
<td className="py-3 px-1 text-foreground/30">
|
</Button>
|
||||||
<ChevronRight className="size-4" />
|
</div>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
) : null}
|
||||||
))}
|
|
||||||
</tbody>
|
{attendeesQuery.isSuccess && attendees.length > 0 && filteredAttendees.length === 0 ? (
|
||||||
</table>
|
<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>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* MODAL: COMPARTIR LINK DE INVITACION */}
|
{/* MODAL: COMPARTIR LINK DE INVITACION */}
|
||||||
@@ -880,6 +1156,95 @@ export function GroupDetailView() {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</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: DETALLE DEL PARTICIPANTE */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={selectedAttendee !== null}
|
isOpen={selectedAttendee !== null}
|
||||||
@@ -994,6 +1359,233 @@ export function GroupDetailView() {
|
|||||||
)}
|
)}
|
||||||
</DetailRow>
|
</DetailRow>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function GroupsView() {
|
|||||||
<h1 className="text-2xl font-bold text-primary">Grupos</h1>
|
<h1 className="text-2xl font-bold text-primary">Grupos</h1>
|
||||||
<p className="mt-2 text-sm text-foreground/60">Tus grupos de cobranza.</p>
|
<p className="mt-2 text-sm text-foreground/60">Tus grupos de cobranza.</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="primary" onClick={() => void navigate({ to: '/onboarding' })}>
|
<Button variant="primary" onClick={() => void navigate({ to: '/groups/new' })}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
Crear
|
Crear
|
||||||
</Button>
|
</Button>
|
||||||
@@ -100,7 +100,7 @@ export function GroupsView() {
|
|||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
className="mt-5"
|
className="mt-5"
|
||||||
onClick={() => void navigate({ to: '/onboarding' })}
|
onClick={() => void navigate({ to: '/groups/new' })}
|
||||||
>
|
>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
Crear tu primer grupo
|
Crear tu primer grupo
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useParams, Link } from '@tanstack/react-router';
|
|||||||
import {
|
import {
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
GraduationCap,
|
GraduationCap,
|
||||||
Loader2,
|
Loader2,
|
||||||
Moon,
|
Moon,
|
||||||
@@ -32,6 +33,9 @@ export function JoinGroupView() {
|
|||||||
phone: string | null;
|
phone: string | null;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
// Waitlist state (group is full)
|
||||||
|
const [waitlistInfo, setWaitlistInfo] = useState<{ message: string } | null>(null);
|
||||||
|
|
||||||
const inviteQuery = useQuery({
|
const inviteQuery = useQuery({
|
||||||
queryKey: ['public-invite', token],
|
queryKey: ['public-invite', token],
|
||||||
queryFn: () => getInviteInfo(token),
|
queryFn: () => getInviteInfo(token),
|
||||||
@@ -49,9 +53,15 @@ export function JoinGroupView() {
|
|||||||
}),
|
}),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
|
if (data.status === 'waitlisted') {
|
||||||
|
setRegisteredAttendee(null);
|
||||||
|
setWaitlistInfo({ message: data.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWaitlistInfo(null);
|
||||||
setRegisteredAttendee({
|
setRegisteredAttendee({
|
||||||
fullName: data.attendee.fullName,
|
fullName: data.attendee!.fullName,
|
||||||
phone: data.attendee.phone,
|
phone: data.attendee!.phone,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
@@ -163,8 +173,32 @@ export function JoinGroupView() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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 */}
|
{/* 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">
|
<div className="rounded-2xl border border-border bg-surface p-6 sm:p-8 shadow-xl space-y-6">
|
||||||
{/* Group Info Header */}
|
{/* Group Info Header */}
|
||||||
<div className="border-b border-border pb-5">
|
<div className="border-b border-border pb-5">
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Link } from '@tanstack/react-router'
|
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 { signOut } from '../lib/auth-client'
|
||||||
import { Button } from '../components/ui'
|
import { Button } from '../components/ui'
|
||||||
import { useTheme, type Theme } from '../context/ThemeProvider'
|
import { useTheme, type Theme } from '../context/ThemeProvider'
|
||||||
@@ -76,19 +76,6 @@ export function SettingsView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="divide-y divide-border rounded-xl border border-border bg-surface">
|
<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">
|
<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">
|
<span className="rounded-lg bg-accent-soft p-2">
|
||||||
<Fingerprint className="size-4 text-accent-fg" />
|
<Fingerprint className="size-4 text-accent-fg" />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { createPageSchema, createPageSizeSchema, createPaginationSchema } from './pagination.js';
|
import { createPageSchema, createPageSizeSchema, createPaginationSchema } from './pagination.js';
|
||||||
|
import { GroupWaitlistEntryDtoSchema } from './waitlist.js';
|
||||||
|
|
||||||
const isoDateTimeSchema = z.string().datetime();
|
const isoDateTimeSchema = z.string().datetime();
|
||||||
const pageSchema = createPageSchema();
|
const pageSchema = createPageSchema();
|
||||||
@@ -42,6 +43,18 @@ export const CreateAttendeeSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type CreateAttendee = z.output<typeof CreateAttendeeSchema>;
|
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({
|
export const JoinGroupViaInviteSchema = z.object({
|
||||||
firstName: z.string().trim().min(1, 'El nombre es obligatorio'),
|
firstName: z.string().trim().min(1, 'El nombre es obligatorio'),
|
||||||
lastName: z.string().trim().min(1, 'El apellido 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 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({
|
export const BulkAttendeeItemSchema = z.object({
|
||||||
firstName: z.string().trim().min(1, 'El nombre es obligatorio'),
|
firstName: z.string().trim().min(1, 'El nombre es obligatorio'),
|
||||||
lastName: z.string().trim().optional().default(''),
|
lastName: z.string().trim().optional().default(''),
|
||||||
@@ -73,4 +93,20 @@ export const BulkCreateAttendeesResultSchema = z.object({
|
|||||||
message: z.string(),
|
message: z.string(),
|
||||||
created: z.array(AttendeeDtoSchema),
|
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>;
|
||||||
@@ -56,15 +56,7 @@ export const GroupListSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type GroupList = z.output<typeof GroupListSchema>;
|
export type GroupList = z.output<typeof GroupListSchema>;
|
||||||
|
|
||||||
export const CreateGroupFromOrganizationSchema = z.strictObject({
|
export const CreateGroupResultSchema = z.object({
|
||||||
organizationId: z.string().min(1),
|
|
||||||
});
|
|
||||||
export type CreateGroupFromOrganization = z.output<typeof CreateGroupFromOrganizationSchema>;
|
|
||||||
|
|
||||||
export const CreateGroupFromOrganizationResultSchema = z.object({
|
|
||||||
group: GroupDtoSchema,
|
group: GroupDtoSchema,
|
||||||
alreadyExists: z.boolean(),
|
|
||||||
});
|
});
|
||||||
export type CreateGroupFromOrganizationResult = z.output<
|
export type CreateGroupResult = z.output<typeof CreateGroupResultSchema>;
|
||||||
typeof CreateGroupFromOrganizationResultSchema
|
|
||||||
>;
|
|
||||||
@@ -28,4 +28,43 @@ export const WaitlistListSchema = z.object({
|
|||||||
data: z.array(WaitlistEntryDtoSchema),
|
data: z.array(WaitlistEntryDtoSchema),
|
||||||
pagination: paginationSchema,
|
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>;
|
||||||
Reference in New Issue
Block a user