Compare commits

7 Commits

Author SHA1 Message Date
a363e9e8fc Merge pull request 'feat(home): implement home summary feature with routes and use case' (#4) from feat/home-screen into development
Reviewed-on: #4
2026-09-23 13:35:59 +00:00
Jose Selesan
f1e0fc5aae feat(home): implement home summary feature with routes and use case
- Added home routes and corresponding route handler for fetching home summary.
- Created GetHomeSummary use case to aggregate user data including groups, attendees, and upcoming payments.
- Introduced HomeSummaryDto and related schemas for data validation.
- Implemented nextClassOccurrence function to determine the next class based on user timezone.
- Added tests for home summary functionality and next class occurrence logic.
2026-09-23 10:35:24 -03:00
17dca1ad89 Merge pull request 'feat/waiting-list' (#3) from feat/waiting-list into development
Reviewed-on: #3
2026-09-23 12:36:46 +00:00
Jose Selesan
c1b99804b3 Removed "Back" link in create group page 2026-09-23 09:28:29 -03:00
Jose Selesan
5572ef0869 feat(groups): implement group creation functionality and refactor related components
- Removed organization dependency from group creation logic.
- Introduced new route and use-case for creating groups.
- Refactored form handling for group creation into a reusable GroupForm component.
- Updated API calls to support new group creation endpoint.
- Adjusted tests to reflect changes in group creation logic and validation.
- Removed obsolete organizations route and related components.
- Updated breadcrumb navigation and routing for group management.
2026-09-23 09:19:52 -03:00
Jose Selesan
fc30927b8b feat: manage waitlist and remove group members
- Add group-waitlist module (list/promote/remove entries)
- Remove attendee with optional atomic promotion from waitlist
- Block removal when attendee has payments (attendee_has_payments)
- Waitlist detail modal matching members UI; optimistic add-to-waitlist
- Responsive tweaks for the members/waitlist tabs
2026-09-22 17:04:41 -03:00
Jose Selesan
785d54df7e feat: add group waitlist functionality
- Introduced GroupWaitlistEntry model in Prisma schema to manage waitlisted users for groups.
- Implemented API route to add users to the group waitlist with validation.
- Enhanced attendee creation logic to handle group capacity and waitlisting scenarios.
- Added new problem builders for handling waitlist-related errors.
- Updated frontend to support waitlist interactions, including modals for capacity warnings.
- Created tests for waitlist functionality, ensuring proper handling of full groups and existing waitlist entries.
2026-09-22 16:28:54 -03:00
57 changed files with 3755 additions and 780 deletions

View File

@@ -31,7 +31,7 @@ bun --filter @gruperly/backend db:* # db:generate / db:migrate / db:push
- `src/lib/` — `prisma.ts` (`getPrismaClient`, proxy `default` y clase `UnitOfWork`), `pagination.ts` (offset/metadata), `email.ts`, `error-message.ts`. - `src/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

View File

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

View File

@@ -19,6 +19,7 @@ model Group {
members GroupMember[] 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

View File

@@ -14,6 +14,7 @@ import { attendeesRoutes } from './modules/attendees';
import { authRoutes } from './modules/auth'; import { authRoutes } from './modules/auth';
import { groupsRoutes } from './modules/groups'; import { groupsRoutes } from './modules/groups';
import { healthCheckRoutes } from './modules/health-check'; import { healthCheckRoutes } from './modules/health-check';
import { homeRoutes } from './modules/home';
import { invitationsRoutes } from './modules/invitations'; import { invitationsRoutes } from './modules/invitations';
import { onboardingRoutes } from './modules/onboarding'; import { onboardingRoutes } from './modules/onboarding';
import { paymentsRoutes } from './modules/payments'; import { paymentsRoutes } from './modules/payments';
@@ -36,6 +37,7 @@ api.route('/auth', authRoutes);
api.route('/health', healthCheckRoutes); api.route('/health', healthCheckRoutes);
api.route('/invitations', invitationsRoutes); api.route('/invitations', invitationsRoutes);
api.route('/groups', groupsRoutes); api.route('/groups', groupsRoutes);
api.route('/home', homeRoutes);
api.route('/onboarding', onboardingRoutes); api.route('/onboarding', onboardingRoutes);
api.route('/attendees', attendeesRoutes); api.route('/attendees', attendeesRoutes);
api.route('/payments', paymentsRoutes); api.route('/payments', paymentsRoutes);

View File

@@ -47,23 +47,6 @@ export function forbiddenProblem(params: {
}; };
} }
export function organizationNotFoundProblem(id: string): ProblemDetails {
return {
type: `${PROBLEM_DOMAIN}/problems/organization-not-found`,
title: 'Not Found',
status: 404,
detail: `Organization ${id} was not found.`,
code: 'organization_not_found',
};
}
export function groupOwnerRequiredProblem(): ProblemDetails {
return forbiddenProblem({
detail: 'Only the organization owner can create the group.',
code: 'group_owner_required',
});
}
export function databaseUnavailableProblem(params?: { export function databaseUnavailableProblem(params?: {
instance?: string; instance?: string;
}): ProblemDetails { }): ProblemDetails {
@@ -101,3 +84,26 @@ export function onboardingAlreadyCompletedProblem(): ProblemDetails {
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',
});
}

View File

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

View File

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

View File

@@ -13,8 +13,9 @@ route.post('/:groupId/attendees', validate.json(CreateAttendeeSchema), async (c)
} }
const groupId = c.req.param('groupId'); const 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 });
}); });

View File

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

View File

@@ -0,0 +1,24 @@
import { type RemoveAttendeeQuery, RemoveAttendeeQuerySchema } from '@gruperly/shared';
import { Hono } from 'hono';
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
import { validate } from '@/http/validate';
import { RemoveAttendee } from './use-case';
const route = new Hono();
route.delete('/:groupId/attendees/:attendeeId', validate.query(RemoveAttendeeQuerySchema), async (c) => {
const user = c.get('user');
if (!user) {
return problemJson(c, unauthorizedProblem(c.req.path));
}
const groupId = c.req.param('groupId');
const attendeeId = c.req.param('attendeeId');
const query = c.req.valid('query') as RemoveAttendeeQuery;
const useCase = new RemoveAttendee();
const result = await useCase.execute(groupId, attendeeId, user.id, {
promoteFromWaitlist: query.promoteFromWaitlist,
});
return resultJson(c, result);
});
export default route;

View File

@@ -0,0 +1,73 @@
import type { Prisma, PrismaClient } from '@generated/prisma/client';
import type { ProblemDetails, RemoveAttendeeResult, Result } from '@gruperly/shared';
import { err, ok } from '@gruperly/shared';
import { attendeeHasPaymentsProblem, notFoundResourceProblem } from '@/http/problem-builders';
import { default as prisma, UnitOfWork } from '@/lib/prisma';
import {
findGroupOwnedByUser,
toGroupWaitlistEntryDto,
} from '@/modules/group-waitlist/lib/helpers';
import { promoteFirstPendingWaitlistEntry } from '@/modules/group-waitlist/lib/promote';
type RemoveAttendeeDeps = {
db?: Pick<PrismaClient, 'group' | 'attendee' | 'payment'>;
unitOfWork?: UnitOfWork;
};
type RemoveAttendeeOptions = {
promoteFromWaitlist?: boolean;
};
export class RemoveAttendee {
constructor(private readonly deps: RemoveAttendeeDeps = {}) {}
async execute(
groupId: string,
attendeeId: string,
userId: string,
options: RemoveAttendeeOptions = {},
): Promise<Result<RemoveAttendeeResult, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const unitOfWork = this.deps.unitOfWork ?? new UnitOfWork(prisma);
const groupResult = await findGroupOwnedByUser(db, groupId, userId);
if (!groupResult.ok) {
return groupResult;
}
const group = groupResult.value;
const attendee = await db.attendee.findFirst({ where: { id: attendeeId, groupId } });
if (!attendee) {
return err(notFoundResourceProblem('Attendee', attendeeId));
}
const paymentsCount = await db.payment.count({ where: { attendeeId } });
if (paymentsCount > 0) {
return err(attendeeHasPaymentsProblem());
}
return unitOfWork.executeResult(
async (tx: Prisma.TransactionClient): Promise<Result<RemoveAttendeeResult, ProblemDetails>> => {
await tx.attendee.delete({ where: { id: attendeeId } });
if (!options.promoteFromWaitlist) {
return ok({ removedAttendeeId: attendeeId, promoted: null });
}
const promoteResult = await promoteFirstPendingWaitlistEntry(tx, group);
if (!promoteResult.ok) {
return err(promoteResult.error);
}
if (!promoteResult.value) {
return ok({ removedAttendeeId: attendeeId, promoted: null });
}
await tx.groupWaitlistEntry.delete({ where: { id: promoteResult.value.entry.id } });
return ok({
removedAttendeeId: attendeeId,
promoted: toGroupWaitlistEntryDto(promoteResult.value.entry),
});
},
);
}
}

View File

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

View File

@@ -0,0 +1,43 @@
import type { PrismaClient } from '@generated/prisma/client';
import type { GroupWaitlistList, GroupWaitlistQuery, ProblemDetails, Result } from '@gruperly/shared';
import { ok } from '@gruperly/shared';
import { getPaginationMetadata, getPaginationOffset } from '@/lib/pagination';
import prisma from '@/lib/prisma';
import { findGroupForUser, type GroupWaitlistEntryRecord, toGroupWaitlistEntryDto } from '../../lib/helpers';
type ListGroupWaitlistEntriesDeps = {
db?: Pick<PrismaClient, 'group' | 'groupWaitlistEntry'>;
};
export class ListGroupWaitlistEntries {
constructor(private readonly deps: ListGroupWaitlistEntriesDeps = {}) {}
async execute(
groupId: string,
userId: string,
query: GroupWaitlistQuery,
): Promise<Result<GroupWaitlistList, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const groupResult = await findGroupForUser(db, groupId, userId);
if (!groupResult.ok) {
return groupResult;
}
const where = { groupId, status: 'PENDING' as const };
const [records, total] = await Promise.all([
db.groupWaitlistEntry.findMany({
where,
skip: getPaginationOffset(query),
take: query.pageSize,
orderBy: { createdAt: 'asc' },
}),
db.groupWaitlistEntry.count({ where }),
]);
return ok({
data: records.map((record) => toGroupWaitlistEntryDto(record as unknown as GroupWaitlistEntryRecord)),
pagination: getPaginationMetadata(query, total),
});
}
}

View File

@@ -0,0 +1,19 @@
import { Hono } from 'hono';
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
import { PromoteGroupWaitlistEntry } from './use-case';
const route = new Hono();
route.post('/:groupId/waitlist/:entryId/promote', async (c) => {
const user = c.get('user');
if (!user) {
return problemJson(c, unauthorizedProblem(c.req.path));
}
const groupId = c.req.param('groupId');
const entryId = c.req.param('entryId');
const useCase = new PromoteGroupWaitlistEntry();
const result = await useCase.execute(groupId, entryId, user.id);
return resultJson(c, result);
});
export default route;

View File

@@ -0,0 +1,56 @@
import type { Prisma, PrismaClient } from '@generated/prisma/client';
import type { ProblemDetails, PromoteGroupWaitlistEntryResult, Result } from '@gruperly/shared';
import { err, ok } from '@gruperly/shared';
import { notFoundResourceProblem } from '@/http/problem-builders';
import { default as prisma, UnitOfWork } from '@/lib/prisma';
import {
findGroupOwnedByUser,
type GroupWaitlistEntryRecord,
} from '../../lib/helpers';
import { promoteWaitlistEntryRecord } from '../../lib/promote';
type PromoteGroupWaitlistEntryDeps = {
db?: Pick<PrismaClient, 'group' | 'attendee' | 'groupWaitlistEntry'>;
unitOfWork?: UnitOfWork;
};
export class PromoteGroupWaitlistEntry {
constructor(private readonly deps: PromoteGroupWaitlistEntryDeps = {}) {}
async execute(
groupId: string,
entryId: string,
userId: string,
): Promise<Result<PromoteGroupWaitlistEntryResult, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const unitOfWork = this.deps.unitOfWork ?? new UnitOfWork(prisma);
const groupResult = await findGroupOwnedByUser(db, groupId, userId);
if (!groupResult.ok) {
return groupResult;
}
const entry = await db.groupWaitlistEntry.findFirst({
where: { id: entryId, groupId },
});
if (!entry) {
return err(notFoundResourceProblem('GroupWaitlistEntry', entryId));
}
return unitOfWork.executeResult(
async (tx: Prisma.TransactionClient) => {
const promoteResult = await promoteWaitlistEntryRecord(
tx,
groupResult.value,
entry as unknown as GroupWaitlistEntryRecord,
);
if (!promoteResult.ok) {
return promoteResult;
}
await tx.groupWaitlistEntry.delete({ where: { id: entry.id } });
return ok({ attendee: promoteResult.value });
},
);
}
}

View File

@@ -0,0 +1,19 @@
import { Hono } from 'hono';
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
import { RemoveGroupWaitlistEntry } from './use-case';
const route = new Hono();
route.delete('/:groupId/waitlist/:entryId', async (c) => {
const user = c.get('user');
if (!user) {
return problemJson(c, unauthorizedProblem(c.req.path));
}
const groupId = c.req.param('groupId');
const entryId = c.req.param('entryId');
const useCase = new RemoveGroupWaitlistEntry();
const result = await useCase.execute(groupId, entryId, user.id);
return resultJson(c, result);
});
export default route;

View File

@@ -0,0 +1,37 @@
import type { PrismaClient } from '@generated/prisma/client';
import type { ProblemDetails, RemoveGroupWaitlistEntryResult, Result } from '@gruperly/shared';
import { err, ok } from '@gruperly/shared';
import { notFoundResourceProblem } from '@/http/problem-builders';
import prisma from '@/lib/prisma';
import { findGroupOwnedByUser } from '../../lib/helpers';
type RemoveGroupWaitlistEntryDeps = {
db?: Pick<PrismaClient, 'group' | 'groupWaitlistEntry'>;
};
export class RemoveGroupWaitlistEntry {
constructor(private readonly deps: RemoveGroupWaitlistEntryDeps = {}) {}
async execute(
groupId: string,
entryId: string,
userId: string,
): Promise<Result<RemoveGroupWaitlistEntryResult, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const groupResult = await findGroupOwnedByUser(db, groupId, userId);
if (!groupResult.ok) {
return groupResult;
}
const existing = await db.groupWaitlistEntry.findFirst({
where: { id: entryId, groupId },
});
if (!existing) {
return err(notFoundResourceProblem('GroupWaitlistEntry', entryId));
}
await db.groupWaitlistEntry.delete({ where: { id: entryId } });
return ok({ deleted: true });
}
}

View File

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

View File

@@ -0,0 +1,65 @@
import type { Group, PrismaClient } from '@generated/prisma/client';
import type { GroupWaitlistEntryDto, ProblemDetails, Result } from '@gruperly/shared';
import { err, ok } from '@gruperly/shared';
import { noGroupAccessProblem, notFoundResourceProblem } from '@/http/problem-builders';
export type GroupWaitlistEntryRecord = {
id: string;
groupId: string;
fullName: string;
phone: string;
email: string | null;
notes: string | null;
status: GroupWaitlistEntryDto['status'];
createdAt: Date;
updatedAt: Date;
};
export function toGroupWaitlistEntryDto(record: GroupWaitlistEntryRecord): GroupWaitlistEntryDto {
return {
id: record.id,
groupId: record.groupId,
fullName: record.fullName,
phone: record.phone,
email: record.email,
notes: record.notes,
status: record.status,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
};
}
export async function findGroupForUser(
db: Pick<PrismaClient, 'group'>,
groupId: string,
userId: string,
): Promise<Result<Group, ProblemDetails>> {
const group = await db.group.findUnique({
where: { id: groupId },
include: { members: { where: { userId } } },
});
if (!group) {
return err(notFoundResourceProblem('Group', groupId));
}
const isOwner = group.createdById === userId;
const isMember = group.members.length > 0;
if (!isOwner && !isMember) {
return err(noGroupAccessProblem());
}
return ok(group);
}
export async function findGroupOwnedByUser(
db: Pick<PrismaClient, 'group'>,
groupId: string,
userId: string,
): Promise<Result<Group, ProblemDetails>> {
const group = await db.group.findUnique({ where: { id: groupId } });
if (!group) {
return err(notFoundResourceProblem('Group', groupId));
}
if (group.createdById !== userId) {
return err(noGroupAccessProblem());
}
return ok(group);
}

View File

@@ -0,0 +1,83 @@
import type { PrismaClient } from '@generated/prisma/client';
import type { AttendeeDto, ProblemDetails, Result } from '@gruperly/shared';
import { err, ok } from '@gruperly/shared';
import { capacityReachedProblem, conflictProblem } from '@/http/problem-builders';
import { type AttendeeRecord, toAttendeeDto } from '@/modules/attendees/lib/helpers';
import { type GroupWaitlistEntryRecord } from './helpers';
export type PromoteDb = Pick<PrismaClient, 'attendee' | 'groupWaitlistEntry'>;
type PromoteGroup = {
id: string;
capacity: number | null;
};
export async function findFirstPendingWaitlistEntry(
db: PromoteDb,
groupId: string,
): Promise<GroupWaitlistEntryRecord | null> {
const record = await db.groupWaitlistEntry.findFirst({
where: { groupId, status: 'PENDING' },
orderBy: { createdAt: 'asc' },
});
return record as GroupWaitlistEntryRecord | null;
}
export async function promoteWaitlistEntryRecord(
db: PromoteDb,
group: PromoteGroup,
entry: GroupWaitlistEntryRecord,
): Promise<Result<AttendeeDto, ProblemDetails>> {
if (group.capacity !== null) {
const currentCount = await db.attendee.count({ where: { groupId: group.id } });
if (currentCount >= group.capacity) {
return err(capacityReachedProblem(group.capacity));
}
}
const existing = await db.attendee.findFirst({
where: { groupId: group.id, phone: entry.phone },
});
if (existing) {
return err(
conflictProblem({
detail: 'Ya existe un alumno registrado con este número de teléfono en este grupo.',
code: 'attendee_already_registered',
}),
);
}
const attendee = await db.attendee.create({
data: {
groupId: group.id,
fullName: entry.fullName,
phone: entry.phone,
email: entry.email,
notes: entry.notes,
},
});
return ok(toAttendeeDto(attendee as unknown as AttendeeRecord));
}
export type PromoteFirstResult = {
attendee: AttendeeDto;
entry: GroupWaitlistEntryRecord;
};
export async function promoteFirstPendingWaitlistEntry(
db: PromoteDb,
group: PromoteGroup,
): Promise<Result<PromoteFirstResult | null, ProblemDetails>> {
const entry = await findFirstPendingWaitlistEntry(db, group.id);
if (!entry) {
return ok(null);
}
const result = await promoteWaitlistEntryRecord(db, group, entry);
if (!result.ok) {
return result;
}
return ok({ attendee: result.value, entry });
}

View File

@@ -0,0 +1,12 @@
import { Hono } from 'hono';
import getAllRoute from './features/get-all/route';
import promoteRoute from './features/promote/route';
import removeRoute from './features/remove/route';
const routes = new Hono();
routes.route('/', getAllRoute);
routes.route('/', promoteRoute);
routes.route('/', removeRoute);
export default routes;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import type { PrismaClient } from '@generated/prisma/client'; import type { 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 };

View File

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

View File

@@ -0,0 +1,23 @@
import type { HomeQuery } from '@gruperly/shared';
import { HomeQuerySchema } from '@gruperly/shared';
import { Hono } from 'hono';
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
import { validate } from '@/http/validate';
import { GetHomeSummary } from './use-case';
const route = new Hono();
route.get('/', validate.query(HomeQuerySchema), async (c) => {
const user = c.get('user');
if (!user) {
return problemJson(c, unauthorizedProblem(c.req.path));
}
const query = c.req.valid('query') as HomeQuery;
const useCase = new GetHomeSummary();
const result = await useCase.execute(user.id, query);
return resultJson(c, result);
});
export default route;

View File

@@ -0,0 +1,134 @@
import type { PrismaClient } from '@generated/prisma/client';
import type {
HomeQuery,
HomeSummary,
NextClass,
ProblemDetails,
Result,
} from '@gruperly/shared';
import { ok } from '@gruperly/shared';
import prisma from '@/lib/prisma';
import { buildGroupWhereForUser } from '@/modules/groups/lib/helpers';
import { DEFAULT_TIME_ZONE, nextClassOccurrence } from '../../lib/scheduler';
export const UPCOMING_PAYMENTS_LIMIT = 5;
type HomeSummaryDeps = {
db?: Pick<PrismaClient, 'group' | 'payment'>;
now?: Date;
timeZone?: string;
};
type GroupSummaryRecord = {
id: string;
name: string;
days: NextClass['days'];
time: string | null;
_count: { attendees: number };
};
export class GetHomeSummary {
constructor(private readonly deps: HomeSummaryDeps = {}) {}
async execute(userId: string, query: HomeQuery = {}): Promise<Result<HomeSummary, ProblemDetails>> {
const db = this.deps.db ?? prisma;
const now = this.deps.now ?? new Date();
const timeZone = this.deps.timeZone ?? query.timeZone ?? DEFAULT_TIME_ZONE;
const groups = await db.group.findMany({
where: buildGroupWhereForUser(userId),
select: {
id: true,
name: true,
days: true,
time: true,
_count: { select: { attendees: true } },
},
});
if (groups.length === 0) {
return ok({
stats: { groups: 0, attendees: 0, pendingPayments: 0, pendingAmount: 0 },
nextClass: null,
upcomingPayments: [],
});
}
const groupIds = groups.map((group) => group.id);
const attendees = groups.reduce((sum, group) => sum + group._count.attendees, 0);
const [pendingAggregate, upcomingPayments] = await Promise.all([
db.payment.aggregate({
where: { groupId: { in: groupIds }, status: { in: ['PENDING', 'OVERDUE'] } },
_count: true,
_sum: { amount: true },
}),
db.payment.findMany({
where: { groupId: { in: groupIds }, status: { in: ['PENDING', 'OVERDUE'] } },
orderBy: [{ dueDate: 'asc' as const }],
take: UPCOMING_PAYMENTS_LIMIT,
include: {
group: { select: { name: true } },
attendee: { select: { fullName: true } },
},
}),
]);
return ok({
stats: {
groups: groups.length,
attendees,
pendingPayments: pendingAggregate._count,
pendingAmount: pendingAggregate._sum.amount
? Number(pendingAggregate._sum.amount.toString())
: 0,
},
nextClass: this.findNextClass(groups, now, timeZone),
upcomingPayments: upcomingPayments.map((payment) => ({
id: payment.id,
amount: Number(payment.amount.toString()),
currency: payment.currency,
dueDate: payment.dueDate.toISOString(),
status: payment.status,
groupName: payment.group.name,
attendeeName: payment.attendee.fullName,
})),
});
}
private findNextClass(
groups: GroupSummaryRecord[],
now: Date,
timeZone: string,
): NextClass | null {
let best: NextClass | null = null;
for (const group of groups) {
if (!group.days || group.days.length === 0 || !group.time) {
continue;
}
const occurrence = nextClassOccurrence({
days: group.days,
time: group.time,
now,
timeZone,
});
if (!occurrence) {
continue;
}
if (best && occurrence.occurrenceAt.getTime() >= new Date(best.occurrenceAt).getTime()) {
continue;
}
best = {
groupId: group.id,
name: group.name,
days: group.days,
time: group.time,
occurrenceAt: occurrence.occurrenceAt.toISOString(),
isNow: occurrence.isNow,
};
}
return best;
}
}

View File

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

View File

@@ -0,0 +1,146 @@
import type { WeekDay } from '@gruperly/shared';
export const DEFAULT_TIME_ZONE = process.env.APP_TIMEZONE ?? 'America/Mexico_City';
// Ventana (en horas) durante la que una clase ya arrancada se muestra como "en curso".
export const CLASS_WINDOW_HOURS = 2;
export const CLASS_WINDOW_MS = CLASS_WINDOW_HOURS * 60 * 60 * 1000;
const WEEKDAY_TO_DOW: Record<WeekDay, number> = {
SUNDAY: 0,
MONDAY: 1,
TUESDAY: 2,
WEDNESDAY: 3,
THURSDAY: 4,
FRIDAY: 5,
SATURDAY: 6,
};
export type NextClassOccurrence = {
occurrenceAt: Date;
isNow: boolean;
};
// Convierte un tiempo de "pared" (wall clock) expresado en `timeZone` al instante absoluto.
function zonedWallClockToUtc(wall: string, timeZone: string): Date {
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(wall);
if (!match) {
throw new Error(`Invalid wall clock: ${wall}`);
}
const [, year, month, day, hour, minute] = match;
const asUtc = new Date(
Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute)),
);
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
hourCycle: 'h23',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
.formatToParts(asUtc)
.reduce<Record<string, string>>((acc, part) => {
acc[part.type] = part.value;
return acc;
}, {});
const tzRepresentation = new Date(
Date.UTC(
Number(parts.year),
Number(parts.month) - 1,
Number(parts.day),
Number(parts.hour),
Number(parts.minute),
Number(parts.second),
),
);
const offsetMs = asUtc.getTime() - tzRepresentation.getTime();
return new Date(asUtc.getTime() + offsetMs);
}
function wallDateInTimeZone(now: Date, timeZone: string): { year: number; month: number; day: number } {
const wall = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
.formatToParts(now)
.reduce<Record<string, string>>((acc, part) => {
acc[part.type] = part.value;
return acc;
}, {});
return {
year: Number(wall.year),
month: Number(wall.month),
day: Number(wall.day),
};
}
export function nextClassOccurrence(opt: {
days: WeekDay[];
time: string;
now: Date;
timeZone: string;
}): NextClassOccurrence | null {
const { days, time, now, timeZone } = opt;
if (days.length === 0 || !time) {
return null;
}
const [hour, minute] = time.split(':').map(Number);
if (
Number.isNaN(hour) ||
Number.isNaN(minute) ||
hour < 0 ||
hour > 23 ||
minute < 0 ||
minute > 59
) {
return null;
}
const dowSet = new Set(days.map((day) => WEEKDAY_TO_DOW[day]));
const { year, month, day } = wallDateInTimeZone(now, timeZone);
let best: NextClassOccurrence | null = null;
for (let offset = 0; offset <= 7; offset++) {
const candidateDay = new Date(Date.UTC(year, month - 1, day + offset, 0, 0, 0));
const dow = candidateDay.getUTCDay();
if (!dowSet.has(dow)) {
continue;
}
const wall = `${candidateDay.toISOString().slice(0, 10)}T${pad(hour)}:${pad(minute)}`;
const candidate = zonedWallClockToUtc(wall, timeZone);
if (offset === 0) {
if (candidate.getTime() > now.getTime()) {
best = { occurrenceAt: candidate, isNow: false };
break;
}
if (now.getTime() >= candidate.getTime() && now.getTime() - candidate.getTime() < CLASS_WINDOW_MS) {
return { occurrenceAt: candidate, isNow: true };
}
continue;
}
if (!best || candidate.getTime() < best.occurrenceAt.getTime()) {
best = { occurrenceAt: candidate, isNow: false };
}
}
return best;
}
function pad(value: number): string {
return String(value).padStart(2, '0');
}

View File

@@ -0,0 +1,8 @@
import { Hono } from 'hono';
import getSummaryRoute from './features/get-summary/route';
const routes = new Hono();
routes.route('/', getSummaryRoute);
export default routes;

View File

@@ -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,6 +92,7 @@ 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',
}); });

View File

@@ -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)', () => {

View File

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

View File

@@ -0,0 +1,112 @@
import { describe, expect, it } from 'bun:test';
import { CLASS_WINDOW_HOURS, nextClassOccurrence } from '@/modules/home/lib/scheduler';
const TZ = 'America/Mexico_City';
describe('nextClassOccurrence', () => {
it('devuelve la próxima clase de hoy cuando aún no arrancó', () => {
const now = new Date('2026-09-23T18:00:00.000Z'); // miércoles 12:00 hora CDMX
const result = nextClassOccurrence({
days: ['WEDNESDAY'],
time: '18:00',
now,
timeZone: TZ,
});
expect(result).toEqual({
occurrenceAt: new Date('2026-09-24T00:00:00.000Z'),
isNow: false,
});
});
it('marca en curso si la clase arrancó hace menos de la ventana', () => {
const now = new Date('2026-09-24T01:00:00.000Z'); // miércoles 19:00 hora CDMX (1 h tras la clase)
const result = nextClassOccurrence({
days: ['WEDNESDAY'],
time: '18:00',
now,
timeZone: TZ,
});
expect(result).toEqual({
occurrenceAt: new Date('2026-09-24T00:00:00.000Z'),
isNow: true,
});
});
it('salta a la próxima semana si la clase ya pasó la ventana', () => {
const now = new Date('2026-09-24T03:00:00.000Z'); // miércoles 21:00 hora CDMX (3 h tras la clase)
const result = nextClassOccurrence({
days: ['WEDNESDAY'],
time: '18:00',
now,
timeZone: TZ,
});
expect(result).toEqual({
occurrenceAt: new Date('2026-10-01T00:00:00.000Z'),
isNow: false,
});
});
it('elige la ocurrencia más próxima entre varios días', () => {
const now = new Date('2026-09-23T18:00:00.000Z'); // miércoles 12:00 hora CDMX
const result = nextClassOccurrence({
days: ['MONDAY', 'WEDNESDAY', 'FRIDAY'],
time: '18:00',
now,
timeZone: TZ,
});
expect(result).toEqual({
occurrenceAt: new Date('2026-09-24T00:00:00.000Z'),
isNow: false,
});
});
it('cruza de mes sin problema', () => {
const now = new Date('2026-09-29T18:00:00.000Z'); // martes 12:00 hora CDMX (último día de septiembre es miércoles 30)
const result = nextClassOccurrence({
days: ['THURSDAY'],
time: '09:00',
now,
timeZone: TZ,
});
expect(result).toEqual({
occurrenceAt: new Date('2026-10-01T15:00:00.000Z'),
isNow: false,
});
});
it('devuelve null si no hay días de clase', () => {
const result = nextClassOccurrence({
days: [],
time: '18:00',
now: new Date('2026-09-23T18:00:00.000Z'),
timeZone: TZ,
});
expect(result).toBeNull();
});
it('devuelve null si la hora es inválida', () => {
const result = nextClassOccurrence({
days: ['WEDNESDAY'],
time: '99:99',
now: new Date('2026-09-23T18:00:00.000Z'),
timeZone: TZ,
});
expect(result).toBeNull();
});
it('expone el tamaño de la ventana de "en curso"', () => {
expect(CLASS_WINDOW_HOURS).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,277 @@
import { beforeEach, describe, expect, it, mock, vi } from 'bun:test';
import { Hono } from 'hono';
const db = {
group: {
findMany: mock(),
},
payment: {
aggregate: mock(),
findMany: 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 { homeRoutes } from '@/modules/home';
import { GetHomeSummary } from '@/modules/home/features/get-summary/use-case';
const TZ = 'America/Mexico_City';
const userId = 'user-1';
const groupA = {
id: 'g1',
name: 'Cuadrilla A',
days: ['WEDNESDAY'],
time: '18:00',
price: 500,
billingType: 'MONTHLY',
dueDay: 5,
capacity: 8,
_count: { attendees: 3 },
};
const groupB = {
id: 'g2',
name: 'Cuadrilla B',
days: [],
time: null,
price: null,
billingType: null,
dueDay: null,
capacity: null,
_count: { attendees: 2 },
};
const madeUpAmount = (value: string) => ({ toString: () => value });
function makeApp(userValue: unknown) {
const app = new Hono();
app.use('*', async (c, next) => {
c.set('user', userValue as never);
await next();
});
app.route('/home', homeRoutes);
return app;
}
describe('GetHomeSummary', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('agrega stats, próxima clase y próximos cobros del usuario', async () => {
prisma.group.findMany.mockResolvedValue([groupB, groupA]);
prisma.payment.aggregate.mockResolvedValue({
_count: 4,
_sum: { amount: madeUpAmount('2100') },
});
prisma.payment.findMany.mockResolvedValue([
{
id: 'p1',
amount: madeUpAmount('500'),
currency: 'MXN',
dueDate: new Date('2026-09-25T12:00:00.000Z'),
status: 'PENDING',
group: { name: 'Cuadrilla A' },
attendee: { fullName: 'Ana García' },
},
]);
const useCase = new GetHomeSummary({ db, now: new Date('2026-09-23T18:00:00.000Z'), timeZone: TZ });
const result = await useCase.execute(userId);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value).toEqual({
stats: { groups: 2, attendees: 5, pendingPayments: 4, pendingAmount: 2100 },
nextClass: {
groupId: 'g1',
name: 'Cuadrilla A',
days: ['WEDNESDAY'],
time: '18:00',
occurrenceAt: '2026-09-24T00:00:00.000Z',
isNow: false,
},
upcomingPayments: [
{
id: 'p1',
amount: 500,
currency: 'MXN',
dueDate: '2026-09-25T12:00:00.000Z',
status: 'PENDING',
groupName: 'Cuadrilla A',
attendeeName: 'Ana García',
},
],
});
const where = {
OR: [{ createdById: userId }, { members: { some: { userId } } }],
};
expect(prisma.group.findMany).toHaveBeenCalledWith({ where, select: expect.any(Object) });
expect(prisma.payment.aggregate).toHaveBeenCalledWith({
where: { groupId: { in: ['g2', 'g1'] }, status: { in: ['PENDING', 'OVERDUE'] } },
_count: true,
_sum: { amount: true },
});
expect(prisma.payment.findMany).toHaveBeenCalledWith({
where: { groupId: { in: ['g2', 'g1'] }, status: { in: ['PENDING', 'OVERDUE'] } },
orderBy: [{ dueDate: 'asc' }],
take: 5,
include: { group: { select: { name: true } }, attendee: { select: { fullName: true } } },
});
});
it('marca como en curso la clase que ya arrancó', async () => {
prisma.group.findMany.mockResolvedValue([groupA]);
const useCase = new GetHomeSummary({ db, now: new Date('2026-09-24T01:00:00.000Z'), timeZone: TZ });
const result = await useCase.execute(userId);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.nextClass).toMatchObject({
groupId: 'g1',
occurrenceAt: '2026-09-24T00:00:00.000Z',
isNow: true,
});
});
it('elige el grupo con la próxima clase más cercana', async () => {
prisma.group.findMany.mockResolvedValue([
groupA,
{
id: 'g3',
name: 'Cuadrilla C',
days: ['THURSDAY'],
time: '09:00',
price: null,
billingType: null,
dueDay: null,
capacity: null,
_count: { attendees: 0 },
},
]);
const useCase = new GetHomeSummary({ db, now: new Date('2026-09-23T18:00:00.000Z'), timeZone: TZ });
const result = await useCase.execute(userId);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.nextClass?.groupId).toBe('g1');
expect(result.value.nextClass?.occurrenceAt).toBe('2026-09-24T00:00:00.000Z');
});
it('devuelve nextClass null si ningún grupo tiene horario', async () => {
prisma.group.findMany.mockResolvedValue([groupB]);
const useCase = new GetHomeSummary({ db, now: new Date('2026-09-23T18:00:00.000Z'), timeZone: TZ });
const result = await useCase.execute(userId);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.nextClass).toBeNull();
expect(prisma.payment.aggregate).toHaveBeenCalled();
expect(prisma.payment.findMany).toHaveBeenCalled();
});
it('computa la próxima clase en la zona horaria del cliente', async () => {
prisma.group.findMany.mockResolvedValue([
{ ...groupA, days: ['WEDNESDAY'], time: '10:00' },
]);
// 10:23 en Buenos Aires (UTC-3). La clase de las 10:00 ya arrancó.
const useCase = new GetHomeSummary({ db, now: new Date('2026-09-23T13:23:00.000Z') });
const result = await useCase.execute(userId, { timeZone: 'America/Argentina/Buenos_Aires' });
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.nextClass).toMatchObject({
groupId: 'g1',
occurrenceAt: '2026-09-23T13:00:00.000Z',
isNow: true,
});
});
it('usa la zona por defecto si no se envía una', async () => {
prisma.group.findMany.mockResolvedValue([
{ ...groupA, days: ['WEDNESDAY'], time: '10:00' },
]);
// 10:23 en Buenos Aires, pero la zona por defecto es CDMX → las 07:23 CDMX (aún no empieza).
const useCase = new GetHomeSummary({ db, now: new Date('2026-09-23T13:23:00.000Z') });
const result = await useCase.execute(userId);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.nextClass).toMatchObject({
occurrenceAt: '2026-09-23T16:00:00.000Z',
isNow: false,
});
});
it('devuelve ceros y listas vacías cuando el usuario no tiene grupos', async () => {
prisma.group.findMany.mockResolvedValue([]);
const useCase = new GetHomeSummary({ db, now: new Date('2026-09-23T18:00:00.000Z'), timeZone: TZ });
const result = await useCase.execute(userId);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value).toEqual({
stats: { groups: 0, attendees: 0, pendingPayments: 0, pendingAmount: 0 },
nextClass: null,
upcomingPayments: [],
});
expect(prisma.payment.aggregate).not.toHaveBeenCalled();
expect(prisma.payment.findMany).not.toHaveBeenCalled();
});
});
describe('home routes', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('devuelve el resumen del home autenticado', async () => {
prisma.group.findMany.mockResolvedValue([]);
const res = await makeApp({ id: userId }).request('/home');
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
stats: { groups: 0, attendees: 0, pendingPayments: 0, pendingAmount: 0 },
nextClass: null,
upcomingPayments: [],
});
});
it('rechaza el home sin sesión', async () => {
const res = await makeApp(null).request('/home');
expect(res.status).toBe(401);
expect(await res.json()).toMatchObject({ code: 'unauthorized' });
});
it('rechaza una zona horaria inválida', async () => {
const res = await makeApp({ id: userId }).request('/home?timeZone=No/Es_Una_Zona');
expect(res.status).toBe(400);
});
});

View File

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

View File

@@ -0,0 +1,352 @@
import { beforeEach, describe, expect, it, mock, vi } from 'bun:test';
import { Hono } from 'hono';
const db = {
group: {
findUnique: mock(),
},
attendee: {
findFirst: mock(),
findMany: mock(),
count: mock(),
create: mock(),
delete: mock(),
},
groupWaitlistEntry: {
findMany: mock(),
findFirst: mock(),
count: mock(),
create: mock(),
delete: mock(),
},
payment: {
count: mock(),
},
};
mock.module('@/lib/prisma', () => ({
default: db,
getPrismaClient: mock(),
UnitOfWork: class {
executeResult = mock(async (cb: (tx: unknown) => Promise<unknown>) => cb(db));
execute = mock(async (cb: (tx: unknown) => Promise<unknown>) => cb(db));
},
}));
import prisma from '@/lib/prisma';
import { groupsRoutes } from '@/modules/groups';
const teacherId = 'teacher-1';
const mockGroup = {
id: 'group-1',
name: 'Taller de Pintura',
description: null,
createdById: teacherId,
inviteToken: null,
createdAt: new Date('2026-09-01T10:00:00.000Z'),
updatedAt: new Date('2026-09-01T10:00:00.000Z'),
days: ['MONDAY'],
time: '18:00',
capacity: 20,
price: 1500,
billingType: 'MONTHLY',
dueDay: 10,
members: [],
};
const waitlistEntry = (overrides: Record<string, unknown> = {}) => ({
id: 'wl-1',
groupId: 'group-1',
fullName: 'Martín Gómez',
phone: '+5491133445566',
email: null,
notes: null,
status: 'PENDING',
createdAt: new Date('2026-09-10T10:00:00.000Z'),
updatedAt: new Date('2026-09-10T10:00:00.000Z'),
...overrides,
});
const attendee = (overrides: Record<string, unknown> = {}) => ({
id: 'att-1',
groupId: 'group-1',
fullName: 'Martín Gómez',
phone: '+5491133445566',
email: null,
guardianName: null,
guardianPhone: null,
notes: null,
createdAt: new Date('2026-09-18T10:00:00.000Z'),
updatedAt: new Date('2026-09-18T10:00:00.000Z'),
...overrides,
});
function makeApp(userValue: unknown) {
const app = new Hono();
app.use('*', async (c, next) => {
c.set('user', userValue as never);
await next();
});
app.route('/groups', groupsRoutes);
return app;
}
describe('group waitlist management', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('GET /groups/:groupId/waitlist', () => {
it('lists pending entries in FIFO order with pagination', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
prisma.groupWaitlistEntry.findMany.mockResolvedValue([
waitlistEntry({ id: 'wl-1', fullName: 'Ana García', createdAt: new Date('2026-09-08T10:00:00.000Z') }),
waitlistEntry({ id: 'wl-2', fullName: 'Pedro López', createdAt: new Date('2026-09-09T10:00:00.000Z') }),
] as never);
prisma.groupWaitlistEntry.count.mockResolvedValue(2);
const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist');
expect(res.status).toBe(200);
const data = await res.json();
expect(data.data).toHaveLength(2);
expect(data.data[0].fullName).toBe('Ana García');
expect(data.pagination).toEqual({ page: 1, pageSize: 10, total: 2, totalPages: 1 });
expect(prisma.groupWaitlistEntry.findMany).toHaveBeenCalledWith({
where: { groupId: 'group-1', status: 'PENDING' },
skip: 0,
take: 10,
orderBy: { createdAt: 'asc' },
});
});
it('rejects user without access to the group', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
const res = await makeApp({ id: 'other-user' }).request('/groups/group-1/waitlist');
expect(res.status).toBe(403);
expect(prisma.groupWaitlistEntry.findMany).not.toHaveBeenCalled();
});
});
describe('POST /groups/:groupId/waitlist/:entryId/promote', () => {
it('creates an attendee from the entry and removes it from the waitlist', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(waitlistEntry() as never);
prisma.attendee.count.mockResolvedValue(5);
prisma.attendee.findFirst.mockResolvedValue(null);
prisma.attendee.create.mockResolvedValue(attendee() as never);
const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-1/promote', {
method: 'POST',
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.attendee.fullName).toBe('Martín Gómez');
expect(prisma.attendee.create).toHaveBeenCalledWith({
data: expect.objectContaining({ groupId: 'group-1', fullName: 'Martín Gómez', phone: '+5491133445566' }),
});
expect(prisma.groupWaitlistEntry.delete).toHaveBeenCalledWith({ where: { id: 'wl-1' } });
});
it('rejects with 409 when the group reached its capacity', async () => {
prisma.group.findUnique.mockResolvedValue({ ...mockGroup, capacity: 5 } as never);
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(waitlistEntry() as never);
prisma.attendee.count.mockResolvedValue(5);
const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-1/promote', {
method: 'POST',
});
expect(res.status).toBe(409);
const body = await res.json();
expect(body.code).toBe('group_capacity_reached');
expect(prisma.attendee.create).not.toHaveBeenCalled();
expect(prisma.groupWaitlistEntry.delete).not.toHaveBeenCalled();
});
it('rejects with 409 when the phone already belongs to an attendee', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(waitlistEntry() as never);
prisma.attendee.count.mockResolvedValue(5);
prisma.attendee.findFirst.mockResolvedValue({ id: 'att-existing' } as never);
const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-1/promote', {
method: 'POST',
});
expect(res.status).toBe(409);
const body = await res.json();
expect(body.code).toBe('attendee_already_registered');
expect(prisma.attendee.create).not.toHaveBeenCalled();
});
it('returns 404 when the entry does not exist', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(null);
const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-missing/promote', {
method: 'POST',
});
expect(res.status).toBe(404);
});
it('rejects a non-owner user', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
const res = await makeApp({ id: 'other-user' }).request('/groups/group-1/waitlist/wl-1/promote', {
method: 'POST',
});
expect(res.status).toBe(403);
expect(prisma.attendee.create).not.toHaveBeenCalled();
});
});
describe('DELETE /groups/:groupId/waitlist/:entryId', () => {
it('removes a waitlist entry', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(waitlistEntry() as never);
const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-1', {
method: 'DELETE',
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.deleted).toBe(true);
expect(prisma.groupWaitlistEntry.delete).toHaveBeenCalledWith({ where: { id: 'wl-1' } });
});
it('returns 404 when the entry does not exist', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(null);
const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-missing', {
method: 'DELETE',
});
expect(res.status).toBe(404);
expect(prisma.groupWaitlistEntry.delete).not.toHaveBeenCalled();
});
it('rejects a non-owner user', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
const res = await makeApp({ id: 'other-user' }).request('/groups/group-1/waitlist/wl-1', {
method: 'DELETE',
});
expect(res.status).toBe(403);
expect(prisma.groupWaitlistEntry.delete).not.toHaveBeenCalled();
});
});
describe('DELETE /groups/:groupId/attendees/:attendeeId', () => {
it('removes an attendee without promotion', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
prisma.attendee.findFirst.mockResolvedValue(attendee() as never);
prisma.payment.count.mockResolvedValue(0);
const res = await makeApp({ id: teacherId }).request('/groups/group-1/attendees/att-1', {
method: 'DELETE',
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.removedAttendeeId).toBe('att-1');
expect(body.promoted).toBeNull();
expect(prisma.attendee.delete).toHaveBeenCalledWith({ where: { id: 'att-1' } });
});
it('removes an attendee and promotes the first pending waitlist entry atomically', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
prisma.attendee.findFirst.mockImplementation(async ({ where }: { where?: { id?: string } }) => {
if (where?.id) return attendee() as never;
return null;
});
prisma.payment.count.mockResolvedValue(0);
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(
waitlistEntry({ id: 'wl-1', fullName: 'Sofía Ruiz', phone: '+5491133778899' }) as never,
);
prisma.attendee.count.mockResolvedValue(5);
prisma.attendee.create.mockResolvedValue(
attendee({ id: 'att-2', fullName: 'Sofía Ruiz', phone: '+5491133778899' }) as never,
);
const res = await makeApp({ id: teacherId }).request(
'/groups/group-1/attendees/att-1?promoteFromWaitlist=true',
{ method: 'DELETE' },
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.removedAttendeeId).toBe('att-1');
expect(body.promoted).toMatchObject({ id: 'wl-1', fullName: 'Sofía Ruiz' });
expect(prisma.attendee.delete).toHaveBeenCalledWith({ where: { id: 'att-1' } });
expect(prisma.groupWaitlistEntry.delete).toHaveBeenCalledWith({ where: { id: 'wl-1' } });
expect(prisma.attendee.create).toHaveBeenCalledWith({
data: expect.objectContaining({ fullName: 'Sofía Ruiz', phone: '+5491133778899' }),
});
});
it('removes the attendee when promote requested but waitlist is empty', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
prisma.attendee.findFirst.mockResolvedValue(attendee() as never);
prisma.payment.count.mockResolvedValue(0);
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(null);
const res = await makeApp({ id: teacherId }).request(
'/groups/group-1/attendees/att-1?promoteFromWaitlist=true',
{ method: 'DELETE' },
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.removedAttendeeId).toBe('att-1');
expect(body.promoted).toBeNull();
expect(prisma.attendee.create).not.toHaveBeenCalled();
});
it('blocks removal when the attendee has payments', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
prisma.attendee.findFirst.mockResolvedValue(attendee() as never);
prisma.payment.count.mockResolvedValue(2);
const res = await makeApp({ id: teacherId }).request('/groups/group-1/attendees/att-1', {
method: 'DELETE',
});
expect(res.status).toBe(409);
const body = await res.json();
expect(body.code).toBe('attendee_has_payments');
expect(prisma.attendee.delete).not.toHaveBeenCalled();
});
it('returns 404 when the attendee does not belong to the group', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
prisma.attendee.findFirst.mockResolvedValue(null);
const res = await makeApp({ id: teacherId }).request('/groups/group-1/attendees/att-missing', {
method: 'DELETE',
});
expect(res.status).toBe(404);
expect(prisma.attendee.delete).not.toHaveBeenCalled();
});
it('rejects a non-owner user', async () => {
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
const res = await makeApp({ id: 'other-user' }).request('/groups/group-1/attendees/att-1', {
method: 'DELETE',
});
expect(res.status).toBe(403);
expect(prisma.attendee.delete).not.toHaveBeenCalled();
});
});
});

View File

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

View File

@@ -22,11 +22,6 @@ const BREADCRUMBS: Record<string, Crumb[]> = {
{ label: 'Ajustes', to: '/settings' }, { label: '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' },

View File

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

View File

@@ -6,15 +6,25 @@ import type {
ConnectPayment, ConnectPayment,
ConnectPaymentResult, ConnectPaymentResult,
CreateAttendee, CreateAttendee,
CreateAttendeeResult,
CreateFirstGroup, CreateFirstGroup,
CreateFirstGroupResult, CreateFirstGroupResult,
CreateGroupResult,
CreateGroupWaitlistEntry,
GroupDto, GroupDto,
GroupInviteInfoDto, GroupInviteInfoDto,
GroupList, GroupList,
GroupWaitlistEntryDto,
GroupWaitlistList,
HomeSummaryDto,
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,8 +81,26 @@ 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 getHomeSummary = () => {
let timeZone: string | undefined
try {
timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone
} catch {
// Sin zona horaria disponible: el backend usa su valor por defecto.
timeZone = undefined
}
const params = timeZone ? `?timeZone=${encodeURIComponent(timeZone)}` : ''
return apiFetch<HomeSummaryDto>(`/api/v1/home${params}`)
}
export const getGroup = (groupId: string) => export const getGroup = (groupId: string) =>
apiFetch<GroupDto>(`/api/v1/groups/${groupId}`) apiFetch<GroupDto>(`/api/v1/groups/${groupId}`)
@@ -87,13 +115,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),
}) })
@@ -106,3 +147,28 @@ 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',
},
)

View File

@@ -1,4 +1,4 @@
import type { BillingType, WeekDay } from '@gruperly/shared' import type { BillingType, PaymentDto, WeekDay } from '@gruperly/shared'
export const WEEK_DAY_FULL_LABELS: Record<WeekDay, string> = { export const WEEK_DAY_FULL_LABELS: Record<WeekDay, string> = {
MONDAY: 'Lunes', MONDAY: 'Lunes',
@@ -15,6 +15,35 @@ export const BILLING_LABELS: Record<BillingType, string> = {
PER_CLASS: 'Por clase', PER_CLASS: 'Por clase',
} }
export const PAYMENT_STATUS_LABELS: Record<PaymentDto['status'], string> = {
PENDING: 'Pendiente',
PAID: 'Pagado',
OVERDUE: 'Vencido',
CANCELLED: 'Cancelado',
}
const DAY_MS = 86_400_000
function startOfDay(date: Date): number {
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()
}
export function formatRelativeDateTime(iso: string, now: Date = new Date()): string {
const date = new Date(iso)
const time = date.toLocaleTimeString('es-MX', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
const diffDays = Math.round((startOfDay(date) - startOfDay(now)) / DAY_MS)
if (diffDays === 0) return `Hoy · ${time}`
if (diffDays === 1) return `Mañana · ${time}`
const label = date.toLocaleDateString('es-MX', { weekday: 'short', day: 'numeric', month: 'short' })
return `${label} · ${time}`
}
export function formatSchedule(days: readonly WeekDay[], time: string | null): string { export function formatSchedule(days: readonly WeekDay[], time: string | null): string {
const dayNames = days.map((day) => WEEK_DAY_FULL_LABELS[day]) const dayNames = days.map((day) => WEEK_DAY_FULL_LABELS[day])
return time ? `${dayNames.join(', ')} · ${time}` : dayNames.join(', ') return time ? `${dayNames.join(', ')} · ${time}` : dayNames.join(', ')

View File

@@ -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,
]), ]),
]) ])

View File

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

View File

@@ -1,7 +1,14 @@
import { useState, useRef, type ReactNode } from 'react'; import { 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 createAttendeeMutation = useMutation({ const resetQuickForm = () => {
mutationFn: (payload: CreateAttendee) => createAttendee(groupId, payload),
onSuccess: (created) => {
toast.success(`Miembro ${created.fullName} agregado con éxito.`);
queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] });
setFirstName(''); setFirstName('');
setLastName(''); setLastName('');
setPhone(''); setPhone('');
setEmail(''); setEmail('');
setNotes(''); setNotes('');
};
const closeAddAttendeeFlow = () => {
resetQuickForm();
setPendingCapacityPayload(null);
setIsCapacityModalOpen(false);
setIsAddAttendeeModalOpen(false); setIsAddAttendeeModalOpen(false);
};
const handleCreateSuccess = (result: CreateAttendeeResult) => {
if (result.outcome === 'created') {
toast.success(`Miembro ${result.attendee.fullName} agregado con éxito.`);
} else {
toast.info(result.message);
}
queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] });
closeAddAttendeeFlow();
};
const createAttendeeMutation = useMutation({
mutationFn: (payload: CreateAttendee) => createAttendee(groupId, payload),
onMutate: (payload) => setPendingCapacityPayload(payload),
onSuccess: handleCreateSuccess,
onError: (err: Error) => {
if (err instanceof ApiError && err.problem?.code === 'group_capacity_reached') {
setIsCapacityModalOpen(true);
return;
}
toast.error(err.message || 'Error al agregar miembro.');
}, },
});
// Force add (owner overrides the full capacity)
const createAttendeeForceMutation = useMutation({
mutationFn: (payload: CreateAttendee) => createAttendee(groupId, payload, { allowOverflow: true }),
onSuccess: handleCreateSuccess,
onError: (err: Error) => { 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,18 +565,38 @@ 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 gap-3">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div> <div className="flex items-center rounded-xl bg-primary-soft p-1 w-full sm:w-[26rem]">
<h2 className="text-lg font-semibold text-primary"> <button
Miembros del Grupo ({attendees.length}) type="button"
</h2> onClick={() => setListSection('members')}
<p className="text-xs text-foreground/60"> className={`flex flex-1 items-center justify-center gap-1.5 py-2 text-xs font-semibold rounded-lg transition-all ${
Listado de todos los miembros incorporados al grupo. listSection === 'members'
</p> ? '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> </div>
{listSection === 'members' ? (
<div className="relative w-full sm:w-64"> <div className="relative w-full sm:w-64">
<Search className="absolute left-3 top-2.5 size-4 text-foreground/40" /> <Search className="absolute left-3 top-2.5 size-4 text-foreground/40" />
<Input <Input
@@ -403,8 +606,18 @@ export function GroupDetailView() {
className="pl-9 h-9 text-xs" className="pl-9 h-9 text-xs"
/> />
</div> </div>
) : null}
</div> </div>
<p className="text-xs text-foreground/60">
{listSection === 'members'
? 'Listado de todos los miembros incorporados al grupo.'
: 'Personas que esperan un cupo libre en el grupo. Al pasar a alguien al grupo, se crea automáticamente su registro como miembro.'}
</p>
</div>
{listSection === 'members' ? (
<>
{attendeesQuery.isPending ? ( {attendeesQuery.isPending ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<Loader2 className="size-6 animate-spin text-accent" /> <Loader2 className="size-6 animate-spin text-accent" />
@@ -484,6 +697,69 @@ export function GroupDetailView() {
</table> </table>
</div> </div>
) : null} ) : 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> </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>

View File

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

View File

@@ -1,8 +1,231 @@
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { CalendarClock, Loader2, Plus, UserCheck, Users, Wallet, type LucideIcon } from 'lucide-react'
import type { HomeSummaryDto, NextClassDto, PaymentDto, UpcomingPaymentDto } from '@gruperly/shared'
import { Badge, Button } from '../components/ui'
import { getHomeSummary } from '../lib/api'
import {
formatPrice,
formatRelativeDateTime,
formatSchedule,
PAYMENT_STATUS_LABELS,
} from '../lib/format'
import { cn } from '../lib/utils'
const PAYMENT_BADGE_VARIANT: Record<
PaymentDto['status'],
'success' | 'warning' | 'danger' | 'neutral'
> = {
PENDING: 'warning',
OVERDUE: 'danger',
PAID: 'success',
CANCELLED: 'neutral',
}
function NextClassHero({ nextClass }: { nextClass: NextClassDto }) {
const navigate = useNavigate()
const hasSchedule = (nextClass.days?.length ?? 0) > 0
return (
<article className="relative overflow-hidden rounded-xl border border-accent/30 bg-surface p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<div className="min-w-0">
<p className="text-xs font-medium uppercase tracking-wide text-foreground/50">
{nextClass.isNow ? 'Clase en curso' : 'Próxima clase'}
</p>
<h2 className="mt-1 truncate text-xl font-bold text-primary">{nextClass.name}</h2>
{hasSchedule ? (
<div className="mt-2 flex items-center gap-2 text-sm text-foreground/70">
<CalendarClock className="size-4 shrink-0 text-accent" />
<span>{formatSchedule(nextClass.days ?? [], nextClass.time)}</span>
</div>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
<Badge variant={nextClass.isNow ? 'success' : 'neutral'}>
{nextClass.isNow ? 'En curso ahora' : formatRelativeDateTime(nextClass.occurrenceAt)}
</Badge>
</div>
</div>
<div className="mt-4 flex justify-end border-t border-border pt-3">
<Button
variant="outline"
size="sm"
onClick={() => void navigate({ to: `/groups/${nextClass.groupId}` })}
>
Ver grupo
</Button>
</div>
</article>
)
}
function UpcomingPaymentRow({ payment }: { payment: UpcomingPaymentDto }) {
return (
<li className="flex items-center justify-between gap-3 rounded-xl border border-border bg-surface px-4 py-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-primary">{payment.attendeeName}</p>
<p className="mt-0.5 truncate text-xs text-foreground/60">
{payment.groupName} · {formatRelativeDateTime(payment.dueDate)}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="text-sm font-semibold text-primary">{formatPrice(payment.amount)}</span>
<Badge variant={PAYMENT_BADGE_VARIANT[payment.status]}>
{PAYMENT_STATUS_LABELS[payment.status]}
</Badge>
</div>
</li>
)
}
function SummaryStats({ summary }: { summary: HomeSummaryDto }) {
const navigate = useNavigate()
const stats: {
label: string
value: string
sub?: string
icon: LucideIcon
to: '/groups' | '/payments'
}[] = [
{
label: 'Grupos activos',
value: String(summary.stats.groups),
icon: Users,
to: '/groups',
},
{
label: 'Cobros pendientes',
value: summary.stats.pendingAmount > 0 ? formatPrice(summary.stats.pendingAmount) : '0',
sub: `${summary.stats.pendingPayments} por cobrar`,
icon: Wallet,
to: '/payments',
},
{
label: 'Asistentes',
value: String(summary.stats.attendees),
icon: UserCheck,
to: '/groups',
},
]
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{stats.map((stat) => {
const Icon = stat.icon
return (
<button
key={stat.label}
type="button"
onClick={() => void navigate({ to: stat.to })}
className={cn(
'flex items-center gap-3 rounded-xl border border-border bg-surface p-4 text-left transition-all',
'hover:border-accent/40',
)}
>
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-accent-soft text-accent">
<Icon className="size-5" />
</span>
<span className="min-w-0">
<span className="block text-xl font-bold leading-tight text-primary">{stat.value}</span>
<span className="block truncate text-xs text-foreground/60">
{stat.sub ?? stat.label}
</span>
</span>
</button>
)
})}
</div>
)
}
export function HomeView() { export function HomeView() {
const navigate = useNavigate()
const summaryQuery = useQuery({
queryKey: ['home-summary'],
queryFn: getHomeSummary,
})
return ( return (
<section> <section>
<div>
<h1 className="text-2xl font-bold text-primary">Inicio</h1> <h1 className="text-2xl font-bold text-primary">Inicio</h1>
<p className="mt-2 text-sm text-foreground/60">Bienvenido a Gruperly.</p> <p className="mt-2 text-sm text-foreground/60">Tu actividad de cobros de un vistazo.</p>
</div>
{summaryQuery.isPending ? (
<div className="mt-8 flex items-center justify-center py-16">
<Loader2 className="size-6 animate-spin text-foreground/40" />
</div>
) : null}
{summaryQuery.isError ? (
<div className="mt-8 space-y-3 rounded-xl bg-danger-soft px-4 py-3 text-sm text-danger">
<p>No pudimos cargar tu resumen.</p>
<Button variant="outline" size="sm" onClick={() => void summaryQuery.refetch()}>
Reintentar
</Button>
</div>
) : null}
{summaryQuery.isSuccess && summaryQuery.data.stats.groups === 0 ? (
<div className="mt-8 rounded-xl border border-dashed border-border bg-surface px-4 py-12 text-center">
<p className="font-medium text-primary">Todavía no tenés grupos</p>
<p className="mt-1 text-sm text-foreground/60">
Crea tu primer grupo para empezar a cobrar.
</p>
<Button
variant="primary"
className="mt-5"
onClick={() => void navigate({ to: '/groups/new' })}
>
<Plus className="size-4" />
Crear tu primer grupo
</Button>
</div>
) : null}
{summaryQuery.isSuccess && summaryQuery.data.stats.groups > 0 ? (
<div className="mt-6 space-y-6">
{summaryQuery.data.nextClass ? (
<NextClassHero nextClass={summaryQuery.data.nextClass} />
) : (
<div className="rounded-xl border border-dashed border-border bg-surface px-4 py-8 text-center">
<p className="text-sm font-medium text-primary">Sin clases programadas</p>
<p className="mt-1 text-sm text-foreground/60">
Agregá días y horario a tus grupos para ver tu próxima clase acá.
</p>
</div>
)}
<SummaryStats summary={summaryQuery.data} />
<div>
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-bold text-primary">Próximos cobros</h2>
<Button
variant="ghost"
size="sm"
onClick={() => void navigate({ to: '/payments' })}
>
Ver todos
</Button>
</div>
{summaryQuery.data.upcomingPayments.length > 0 ? (
<ul className="mt-3 space-y-3">
{summaryQuery.data.upcomingPayments.map((payment) => (
<UpcomingPaymentRow key={payment.id} payment={payment} />
))}
</ul>
) : (
<p className="mt-3 rounded-xl border border-dashed border-border bg-surface px-4 py-6 text-center text-sm text-foreground/60">
Sin cobros pendientes por ahora.
</p>
)}
</div>
</div>
) : null}
</section> </section>
) )
} }

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { Link } from '@tanstack/react-router' import { 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" />

View File

@@ -4,6 +4,7 @@ export * from './schemas/attendees.js';
export * from './schemas/enums.js'; export * from './schemas/enums.js';
export * from './schemas/groups.js'; export * from './schemas/groups.js';
export * from './schemas/health-check.js'; export * from './schemas/health-check.js';
export * from './schemas/home.js';
export * from './schemas/onboarding.js'; export * from './schemas/onboarding.js';
export * from './schemas/pagination.js'; export * from './schemas/pagination.js';
export * from './schemas/payments.js'; export * from './schemas/payments.js';

View File

@@ -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(''),
@@ -74,3 +94,19 @@ export const BulkCreateAttendeesResultSchema = z.object({
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>;

View File

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

View File

@@ -0,0 +1,62 @@
import { z } from 'zod';
import { weekdaySchema } from './enums.js';
import { paymentStatusSchema } from './payments.js';
const isoDateTimeSchema = z.string().datetime();
const timeZoneSchema = z
.string()
.refine((tz) => {
try {
new Intl.DateTimeFormat('en-US', { timeZone: tz });
return true;
} catch {
return false;
}
}, 'Zona horaria IANA inválida');
export const HomeStatsSchema = z.object({
groups: z.number().int().nonnegative(),
attendees: z.number().int().nonnegative(),
pendingPayments: z.number().int().nonnegative(),
pendingAmount: z.number().nonnegative(),
});
export type HomeStats = z.output<typeof HomeStatsSchema>;
export const NextClassSchema = z.object({
groupId: z.string(),
name: z.string(),
days: z.array(weekdaySchema).nullable(),
time: z.string().nullable(),
occurrenceAt: isoDateTimeSchema,
isNow: z.boolean(),
});
export type NextClass = z.output<typeof NextClassSchema>;
export const UpcomingPaymentSchema = z.object({
id: z.string(),
amount: z.coerce.number().positive(),
currency: z.string(),
dueDate: isoDateTimeSchema,
status: paymentStatusSchema,
groupName: z.string(),
attendeeName: z.string(),
});
export type UpcomingPayment = z.output<typeof UpcomingPaymentSchema>;
export const HomeSummarySchema = z.object({
stats: HomeStatsSchema,
nextClass: NextClassSchema.nullable(),
upcomingPayments: z.array(UpcomingPaymentSchema),
});
export type HomeSummary = z.output<typeof HomeSummarySchema>;
export type HomeSummaryDto = HomeSummary;
export type HomeStatsDto = HomeStats;
export type NextClassDto = NextClass;
export type UpcomingPaymentDto = UpcomingPayment;
export const HomeQuerySchema = z.object({
timeZone: timeZoneSchema.optional(),
});
export type HomeQuery = z.output<typeof HomeQuerySchema>;

View File

@@ -29,3 +29,42 @@ export const WaitlistListSchema = z.object({
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>;