Compare commits
5 Commits
a937c827bb
...
17dca1ad89
| Author | SHA1 | Date | |
|---|---|---|---|
| 17dca1ad89 | |||
|
|
c1b99804b3 | ||
|
|
5572ef0869 | ||
|
|
fc30927b8b | ||
|
|
785d54df7e |
@@ -31,7 +31,7 @@ bun --filter @gruperly/backend db:* # db:generate / db:migrate / db:push
|
||||
- `src/lib/` — `prisma.ts` (`getPrismaClient`, proxy `default` y clase `UnitOfWork`), `pagination.ts` (offset/metadata), `email.ts`, `error-message.ts`.
|
||||
- `src/logger.ts` — pino + pino-pretty.
|
||||
- Módulos existentes: `health-check`, `auth` (**Better Auth 1.7.2 público**, montado en `/api/v1/auth` vía `basePath` del server; cookie de sesión con `path: "/"`), `groups`, `attendees`, `payments`, `waitlist` (listados paginados `{ data, pagination }`).
|
||||
- `apps/web` — Frontend React 19 + Vite + Tailwind v4. Entry `src/main.tsx` → `src/router.tsx`. Puerto **6173** (`vite.config.ts`). Auth client con `basePath: '/api/v1/auth'` (`src/lib/auth-client.ts`); el backend llama a `/api/v1/groups/from-organization` (`src/routes/organizations.tsx`).
|
||||
- `apps/web` — Frontend React 19 + Vite + Tailwind v4. Entry `src/main.tsx` → `src/router.tsx`. Puerto **6173** (`vite.config.ts`). Auth client con `basePath: '/api/v1/auth'` (`src/lib/auth-client.ts`).
|
||||
- `packages/shared` — Esquemas Zod (v3.24) + tipos + `Result` + Problem Details. **Se consume como TS fuente directo** (`exports` apunta a `src/index.ts`, sin build previo); se resuelve vía el symlink de bun en `node_modules` (`@gruperly/shared` no está en `paths` de los tsconfig). El `paths` de los tsconfig solo mapea `@/*` → `src/*` y `@generated/*` → `generated/*`.
|
||||
- `packages/config` — `tsconfig.base.json`; tsconfigs lo extienden con `"extends": "@gruperly/config/tsconfig.base.json"` (por eso `@gruperly/config` es devDependency de cada paquete).
|
||||
|
||||
@@ -52,6 +52,7 @@ bun --filter @gruperly/backend db:* # db:generate / db:migrate / db:push
|
||||
- Nav (Inicio/Grupos/Cobros/Ajustes) vive en `apps/web/src/components/layout/nav-items.ts`; es la fuente única para `BottomNav` (móvil) y `Sidebar` (desktop) — no dupliques la lista.
|
||||
- **UI**: primitivos propios en `apps/web/src/components/ui/` (avatar, button, badge) más helper `cn()` en `src/lib/utils.ts` (clsx + tailwind-merge). Aunque `stack.md` mencione Shadcn, **todavía no está instalado** (sin Radix); úsalos directos.
|
||||
- Layout mobile-first: `RootLayout` usa columna `max-w-md` en móvil y dos columnas (Sidebar + contenido `max-w-6xl`) en `lg+`.
|
||||
- **Sin "volver atrás" en mobile**: en ninguna vista se muestra un link/botón de navegación a la página anterior cuando se ve en mobile (se apoya en el gesto de back del dispositivo). Los links de "volver" solo en desktop: usar `hidden ... lg:inline-flex` (p. ej. `create-group`, `group-detail`) o `hidden lg:flex` (`Breadcrumb`). No confundir con botones "Volver" de pasos dentro de un wizard/formulario: esos sí se mantienen.
|
||||
|
||||
## Fuentes de contexto
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "group_waitlist_entries" (
|
||||
"id" TEXT NOT NULL,
|
||||
"groupId" TEXT NOT NULL,
|
||||
"fullName" TEXT NOT NULL,
|
||||
"phone" TEXT NOT NULL,
|
||||
"email" TEXT,
|
||||
"notes" TEXT,
|
||||
"status" "WaitlistStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "group_waitlist_entries_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "group_waitlist_entries_groupId_idx" ON "group_waitlist_entries"("groupId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "group_waitlist_entries_groupId_phone_key" ON "group_waitlist_entries"("groupId", "phone");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "group_waitlist_entries" ADD CONSTRAINT "group_waitlist_entries_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "groups"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -19,6 +19,7 @@ model Group {
|
||||
members GroupMember[]
|
||||
attendees Attendee[]
|
||||
payments Payment[]
|
||||
waitlist GroupWaitlistEntry[]
|
||||
|
||||
@@map("groups")
|
||||
}
|
||||
@@ -120,6 +121,24 @@ model WaitlistEntry {
|
||||
@@map("waitlist_entries")
|
||||
}
|
||||
|
||||
model GroupWaitlistEntry {
|
||||
id String @id @default(cuid())
|
||||
groupId String
|
||||
fullName String
|
||||
phone String
|
||||
email String?
|
||||
notes String?
|
||||
status WaitlistStatus @default(PENDING) // PENDING, INVITED, JOINED, DECLINED
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([groupId, phone])
|
||||
@@index([groupId])
|
||||
@@map("group_waitlist_entries")
|
||||
}
|
||||
|
||||
enum WaitlistStatus {
|
||||
PENDING
|
||||
INVITED
|
||||
|
||||
@@ -47,23 +47,6 @@ export function forbiddenProblem(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function organizationNotFoundProblem(id: string): ProblemDetails {
|
||||
return {
|
||||
type: `${PROBLEM_DOMAIN}/problems/organization-not-found`,
|
||||
title: 'Not Found',
|
||||
status: 404,
|
||||
detail: `Organization ${id} was not found.`,
|
||||
code: 'organization_not_found',
|
||||
};
|
||||
}
|
||||
|
||||
export function groupOwnerRequiredProblem(): ProblemDetails {
|
||||
return forbiddenProblem({
|
||||
detail: 'Only the organization owner can create the group.',
|
||||
code: 'group_owner_required',
|
||||
});
|
||||
}
|
||||
|
||||
export function databaseUnavailableProblem(params?: {
|
||||
instance?: string;
|
||||
}): ProblemDetails {
|
||||
@@ -100,4 +83,27 @@ export function onboardingAlreadyCompletedProblem(): ProblemDetails {
|
||||
detail: 'Ya completaste el onboarding de Gruperly.',
|
||||
code: 'onboarding_already_completed',
|
||||
});
|
||||
}
|
||||
|
||||
export function capacityReachedProblem(capacity: number | null): ProblemDetails {
|
||||
return conflictProblem({
|
||||
detail: capacity
|
||||
? `El grupo alcanzó su cupo máximo de ${capacity} miembros.`
|
||||
: 'El grupo alcanzó su cupo máximo de miembros.',
|
||||
code: 'group_capacity_reached',
|
||||
});
|
||||
}
|
||||
|
||||
export function alreadyWaitlistedProblem(): ProblemDetails {
|
||||
return conflictProblem({
|
||||
detail: 'Este número de teléfono ya está en la lista de espera del grupo.',
|
||||
code: 'already_waitlisted',
|
||||
});
|
||||
}
|
||||
|
||||
export function attendeeHasPaymentsProblem(): ProblemDetails {
|
||||
return conflictProblem({
|
||||
detail: 'Este miembro tiene cobros asociados. Gestiona o cancela sus cobros antes de quitarlo del grupo.',
|
||||
code: 'attendee_has_payments',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { type CreateGroupWaitlistEntry, CreateGroupWaitlistEntrySchema } from '@gruperly/shared';
|
||||
import { Hono } from 'hono';
|
||||
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
|
||||
import { validate } from '@/http/validate';
|
||||
import { AddToGroupWaitlist } from './use-case';
|
||||
|
||||
const route = new Hono();
|
||||
|
||||
route.post('/:groupId/waitlist', validate.json(CreateGroupWaitlistEntrySchema), async (c) => {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
return problemJson(c, unauthorizedProblem(c.req.path));
|
||||
}
|
||||
const groupId = c.req.param('groupId');
|
||||
const body = c.req.valid('json') as CreateGroupWaitlistEntry;
|
||||
const useCase = new AddToGroupWaitlist();
|
||||
const result = await useCase.execute(groupId, user.id, body);
|
||||
return resultJson(c, result, { status: 201 });
|
||||
});
|
||||
|
||||
export default route;
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { PrismaClient } from '@generated/prisma/client';
|
||||
import type { CreateGroupWaitlistEntry, GroupWaitlistEntryDto, ProblemDetails, Result } from '@gruperly/shared';
|
||||
import { err, ok } from '@gruperly/shared';
|
||||
import {
|
||||
alreadyWaitlistedProblem,
|
||||
conflictProblem,
|
||||
noGroupAccessProblem,
|
||||
notFoundResourceProblem,
|
||||
} from '@/http/problem-builders';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { normalizePhone } from '../../lib/helpers';
|
||||
|
||||
type AddToGroupWaitlistDeps = {
|
||||
db?: Pick<PrismaClient, 'group' | 'attendee' | 'groupWaitlistEntry'>;
|
||||
};
|
||||
|
||||
type GroupWaitlistRecord = {
|
||||
id: string;
|
||||
groupId: string;
|
||||
fullName: string;
|
||||
phone: string;
|
||||
email: string | null;
|
||||
notes: string | null;
|
||||
status: GroupWaitlistEntryDto['status'];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
function toGroupWaitlistEntryDto(record: GroupWaitlistRecord): GroupWaitlistEntryDto {
|
||||
return {
|
||||
id: record.id,
|
||||
groupId: record.groupId,
|
||||
fullName: record.fullName,
|
||||
phone: record.phone,
|
||||
email: record.email,
|
||||
notes: record.notes,
|
||||
status: record.status,
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export class AddToGroupWaitlist {
|
||||
constructor(private readonly deps: AddToGroupWaitlistDeps = {}) {}
|
||||
|
||||
async execute(
|
||||
groupId: string,
|
||||
userId: string,
|
||||
payload: CreateGroupWaitlistEntry,
|
||||
): Promise<Result<GroupWaitlistEntryDto, ProblemDetails>> {
|
||||
const db = this.deps.db ?? prisma;
|
||||
|
||||
const group = await db.group.findUnique({
|
||||
where: { id: groupId },
|
||||
include: {
|
||||
members: { where: { userId } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!group) {
|
||||
return err(notFoundResourceProblem('Group', groupId));
|
||||
}
|
||||
|
||||
const isOwner = group.createdById === userId;
|
||||
const isMember = group.members.length > 0;
|
||||
if (!isOwner && !isMember) {
|
||||
return err(noGroupAccessProblem());
|
||||
}
|
||||
|
||||
const phone = normalizePhone(payload.phone);
|
||||
|
||||
const existingAttendee = await db.attendee.findFirst({
|
||||
where: {
|
||||
groupId,
|
||||
OR: [
|
||||
{ phone },
|
||||
{ phone: payload.phone.trim() },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (existingAttendee) {
|
||||
return err(
|
||||
conflictProblem({
|
||||
detail: 'Ya existe un alumno registrado con este número de teléfono en este grupo.',
|
||||
code: 'attendee_already_registered',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const existingWaitlistEntry = await db.groupWaitlistEntry.findFirst({
|
||||
where: { groupId, phone },
|
||||
});
|
||||
|
||||
if (existingWaitlistEntry) {
|
||||
return err(alreadyWaitlistedProblem());
|
||||
}
|
||||
|
||||
const fullName = `${payload.firstName.trim()} ${payload.lastName.trim()}`.trim();
|
||||
const entry = await db.groupWaitlistEntry.create({
|
||||
data: {
|
||||
groupId,
|
||||
fullName,
|
||||
phone,
|
||||
email: payload.email?.trim() || null,
|
||||
notes: payload.notes?.trim() || null,
|
||||
},
|
||||
});
|
||||
|
||||
return ok(toGroupWaitlistEntryDto(entry as unknown as GroupWaitlistRecord));
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,9 @@ route.post('/:groupId/attendees', validate.json(CreateAttendeeSchema), async (c)
|
||||
}
|
||||
const groupId = c.req.param('groupId');
|
||||
const body = c.req.valid('json') as CreateAttendee;
|
||||
const allowOverflow = c.req.query('allowOverflow') === 'true';
|
||||
const useCase = new CreateAttendeeUseCase();
|
||||
const result = await useCase.execute(groupId, user.id, body);
|
||||
const result = await useCase.execute(groupId, user.id, body, { allowOverflow });
|
||||
return resultJson(c, result, { status: 201 });
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import type { PrismaClient } from '@generated/prisma/client';
|
||||
import type { AttendeeDto, CreateAttendee, ProblemDetails, Result } from '@gruperly/shared';
|
||||
import type { CreateAttendee, CreateAttendeeResult, ProblemDetails, Result } from '@gruperly/shared';
|
||||
import { err, ok } from '@gruperly/shared';
|
||||
import { conflictProblem, noGroupAccessProblem, notFoundResourceProblem } from '@/http/problem-builders';
|
||||
import {
|
||||
alreadyWaitlistedProblem,
|
||||
capacityReachedProblem,
|
||||
conflictProblem,
|
||||
noGroupAccessProblem,
|
||||
notFoundResourceProblem,
|
||||
} from '@/http/problem-builders';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { type AttendeeRecord, normalizePhone, toAttendeeDto } from '../../lib/helpers';
|
||||
|
||||
type CreateAttendeeDeps = {
|
||||
db?: Pick<PrismaClient, 'group' | 'attendee'>;
|
||||
db?: Pick<PrismaClient, 'group' | 'attendee' | 'groupWaitlistEntry'>;
|
||||
};
|
||||
|
||||
type CreateAttendeeOptions = {
|
||||
allowOverflow?: boolean;
|
||||
};
|
||||
|
||||
export class CreateAttendeeUseCase {
|
||||
@@ -16,7 +26,8 @@ export class CreateAttendeeUseCase {
|
||||
groupId: string,
|
||||
userId: string,
|
||||
payload: CreateAttendee,
|
||||
): Promise<Result<AttendeeDto, ProblemDetails>> {
|
||||
options: CreateAttendeeOptions = {},
|
||||
): Promise<Result<CreateAttendeeResult, ProblemDetails>> {
|
||||
const db = this.deps.db ?? prisma;
|
||||
|
||||
const group = await db.group.findUnique({
|
||||
@@ -57,6 +68,42 @@ export class CreateAttendeeUseCase {
|
||||
}
|
||||
|
||||
const fullName = `${payload.firstName.trim()} ${payload.lastName.trim()}`.trim();
|
||||
|
||||
if (group.capacity !== null) {
|
||||
const currentCount = await db.attendee.count({ where: { groupId } });
|
||||
|
||||
if (currentCount >= group.capacity) {
|
||||
if (isOwner && !options.allowOverflow) {
|
||||
return err(capacityReachedProblem(group.capacity));
|
||||
}
|
||||
|
||||
if (!isOwner) {
|
||||
const existingWaitlistEntry = await db.groupWaitlistEntry.findFirst({
|
||||
where: { groupId, phone },
|
||||
});
|
||||
|
||||
if (existingWaitlistEntry) {
|
||||
return err(alreadyWaitlistedProblem());
|
||||
}
|
||||
|
||||
await db.groupWaitlistEntry.create({
|
||||
data: {
|
||||
groupId,
|
||||
fullName,
|
||||
phone,
|
||||
email: payload.email?.trim() || null,
|
||||
notes: payload.notes?.trim() || null,
|
||||
},
|
||||
});
|
||||
|
||||
return ok({
|
||||
outcome: 'waitlisted',
|
||||
message: `${fullName} fue agregado a la lista de espera del grupo.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const attendee = await db.attendee.create({
|
||||
data: {
|
||||
groupId,
|
||||
@@ -67,6 +114,9 @@ export class CreateAttendeeUseCase {
|
||||
},
|
||||
});
|
||||
|
||||
return ok(toAttendeeDto(attendee as unknown as AttendeeRecord));
|
||||
return ok({
|
||||
outcome: 'created',
|
||||
attendee: toAttendeeDto(attendee as unknown as AttendeeRecord),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
24
apps/backend/src/modules/attendees/features/remove/route.ts
Normal file
24
apps/backend/src/modules/attendees/features/remove/route.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { type RemoveAttendeeQuery, RemoveAttendeeQuerySchema } from '@gruperly/shared';
|
||||
import { Hono } from 'hono';
|
||||
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
|
||||
import { validate } from '@/http/validate';
|
||||
import { RemoveAttendee } from './use-case';
|
||||
|
||||
const route = new Hono();
|
||||
|
||||
route.delete('/:groupId/attendees/:attendeeId', validate.query(RemoveAttendeeQuerySchema), async (c) => {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
return problemJson(c, unauthorizedProblem(c.req.path));
|
||||
}
|
||||
const groupId = c.req.param('groupId');
|
||||
const attendeeId = c.req.param('attendeeId');
|
||||
const query = c.req.valid('query') as RemoveAttendeeQuery;
|
||||
const useCase = new RemoveAttendee();
|
||||
const result = await useCase.execute(groupId, attendeeId, user.id, {
|
||||
promoteFromWaitlist: query.promoteFromWaitlist,
|
||||
});
|
||||
return resultJson(c, result);
|
||||
});
|
||||
|
||||
export default route;
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Prisma, PrismaClient } from '@generated/prisma/client';
|
||||
import type { ProblemDetails, RemoveAttendeeResult, Result } from '@gruperly/shared';
|
||||
import { err, ok } from '@gruperly/shared';
|
||||
import { attendeeHasPaymentsProblem, notFoundResourceProblem } from '@/http/problem-builders';
|
||||
import { default as prisma, UnitOfWork } from '@/lib/prisma';
|
||||
import {
|
||||
findGroupOwnedByUser,
|
||||
toGroupWaitlistEntryDto,
|
||||
} from '@/modules/group-waitlist/lib/helpers';
|
||||
import { promoteFirstPendingWaitlistEntry } from '@/modules/group-waitlist/lib/promote';
|
||||
|
||||
type RemoveAttendeeDeps = {
|
||||
db?: Pick<PrismaClient, 'group' | 'attendee' | 'payment'>;
|
||||
unitOfWork?: UnitOfWork;
|
||||
};
|
||||
|
||||
type RemoveAttendeeOptions = {
|
||||
promoteFromWaitlist?: boolean;
|
||||
};
|
||||
|
||||
export class RemoveAttendee {
|
||||
constructor(private readonly deps: RemoveAttendeeDeps = {}) {}
|
||||
|
||||
async execute(
|
||||
groupId: string,
|
||||
attendeeId: string,
|
||||
userId: string,
|
||||
options: RemoveAttendeeOptions = {},
|
||||
): Promise<Result<RemoveAttendeeResult, ProblemDetails>> {
|
||||
const db = this.deps.db ?? prisma;
|
||||
const unitOfWork = this.deps.unitOfWork ?? new UnitOfWork(prisma);
|
||||
|
||||
const groupResult = await findGroupOwnedByUser(db, groupId, userId);
|
||||
if (!groupResult.ok) {
|
||||
return groupResult;
|
||||
}
|
||||
const group = groupResult.value;
|
||||
|
||||
const attendee = await db.attendee.findFirst({ where: { id: attendeeId, groupId } });
|
||||
if (!attendee) {
|
||||
return err(notFoundResourceProblem('Attendee', attendeeId));
|
||||
}
|
||||
|
||||
const paymentsCount = await db.payment.count({ where: { attendeeId } });
|
||||
if (paymentsCount > 0) {
|
||||
return err(attendeeHasPaymentsProblem());
|
||||
}
|
||||
|
||||
return unitOfWork.executeResult(
|
||||
async (tx: Prisma.TransactionClient): Promise<Result<RemoveAttendeeResult, ProblemDetails>> => {
|
||||
await tx.attendee.delete({ where: { id: attendeeId } });
|
||||
|
||||
if (!options.promoteFromWaitlist) {
|
||||
return ok({ removedAttendeeId: attendeeId, promoted: null });
|
||||
}
|
||||
|
||||
const promoteResult = await promoteFirstPendingWaitlistEntry(tx, group);
|
||||
if (!promoteResult.ok) {
|
||||
return err(promoteResult.error);
|
||||
}
|
||||
if (!promoteResult.value) {
|
||||
return ok({ removedAttendeeId: attendeeId, promoted: null });
|
||||
}
|
||||
|
||||
await tx.groupWaitlistEntry.delete({ where: { id: promoteResult.value.entry.id } });
|
||||
return ok({
|
||||
removedAttendeeId: attendeeId,
|
||||
promoted: toGroupWaitlistEntryDto(promoteResult.value.entry),
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { type GroupWaitlistQuery, GroupWaitlistQuerySchema } from '@gruperly/shared';
|
||||
import { Hono } from 'hono';
|
||||
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
|
||||
import { validate } from '@/http/validate';
|
||||
import { ListGroupWaitlistEntries } from './use-case';
|
||||
|
||||
const route = new Hono();
|
||||
|
||||
route.get('/:groupId/waitlist', validate.query(GroupWaitlistQuerySchema), async (c) => {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
return problemJson(c, unauthorizedProblem(c.req.path));
|
||||
}
|
||||
const groupId = c.req.param('groupId');
|
||||
const query = c.req.valid('query') as GroupWaitlistQuery;
|
||||
const useCase = new ListGroupWaitlistEntries();
|
||||
const result = await useCase.execute(groupId, user.id, query);
|
||||
return resultJson(c, result);
|
||||
});
|
||||
|
||||
export default route;
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { PrismaClient } from '@generated/prisma/client';
|
||||
import type { GroupWaitlistList, GroupWaitlistQuery, ProblemDetails, Result } from '@gruperly/shared';
|
||||
import { ok } from '@gruperly/shared';
|
||||
import { getPaginationMetadata, getPaginationOffset } from '@/lib/pagination';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { findGroupForUser, type GroupWaitlistEntryRecord, toGroupWaitlistEntryDto } from '../../lib/helpers';
|
||||
|
||||
type ListGroupWaitlistEntriesDeps = {
|
||||
db?: Pick<PrismaClient, 'group' | 'groupWaitlistEntry'>;
|
||||
};
|
||||
|
||||
export class ListGroupWaitlistEntries {
|
||||
constructor(private readonly deps: ListGroupWaitlistEntriesDeps = {}) {}
|
||||
|
||||
async execute(
|
||||
groupId: string,
|
||||
userId: string,
|
||||
query: GroupWaitlistQuery,
|
||||
): Promise<Result<GroupWaitlistList, ProblemDetails>> {
|
||||
const db = this.deps.db ?? prisma;
|
||||
|
||||
const groupResult = await findGroupForUser(db, groupId, userId);
|
||||
if (!groupResult.ok) {
|
||||
return groupResult;
|
||||
}
|
||||
|
||||
const where = { groupId, status: 'PENDING' as const };
|
||||
const [records, total] = await Promise.all([
|
||||
db.groupWaitlistEntry.findMany({
|
||||
where,
|
||||
skip: getPaginationOffset(query),
|
||||
take: query.pageSize,
|
||||
orderBy: { createdAt: 'asc' },
|
||||
}),
|
||||
db.groupWaitlistEntry.count({ where }),
|
||||
]);
|
||||
|
||||
return ok({
|
||||
data: records.map((record) => toGroupWaitlistEntryDto(record as unknown as GroupWaitlistEntryRecord)),
|
||||
pagination: getPaginationMetadata(query, total),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Hono } from 'hono';
|
||||
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
|
||||
import { PromoteGroupWaitlistEntry } from './use-case';
|
||||
|
||||
const route = new Hono();
|
||||
|
||||
route.post('/:groupId/waitlist/:entryId/promote', async (c) => {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
return problemJson(c, unauthorizedProblem(c.req.path));
|
||||
}
|
||||
const groupId = c.req.param('groupId');
|
||||
const entryId = c.req.param('entryId');
|
||||
const useCase = new PromoteGroupWaitlistEntry();
|
||||
const result = await useCase.execute(groupId, entryId, user.id);
|
||||
return resultJson(c, result);
|
||||
});
|
||||
|
||||
export default route;
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Prisma, PrismaClient } from '@generated/prisma/client';
|
||||
import type { ProblemDetails, PromoteGroupWaitlistEntryResult, Result } from '@gruperly/shared';
|
||||
import { err, ok } from '@gruperly/shared';
|
||||
import { notFoundResourceProblem } from '@/http/problem-builders';
|
||||
import { default as prisma, UnitOfWork } from '@/lib/prisma';
|
||||
import {
|
||||
findGroupOwnedByUser,
|
||||
type GroupWaitlistEntryRecord,
|
||||
} from '../../lib/helpers';
|
||||
import { promoteWaitlistEntryRecord } from '../../lib/promote';
|
||||
|
||||
type PromoteGroupWaitlistEntryDeps = {
|
||||
db?: Pick<PrismaClient, 'group' | 'attendee' | 'groupWaitlistEntry'>;
|
||||
unitOfWork?: UnitOfWork;
|
||||
};
|
||||
|
||||
export class PromoteGroupWaitlistEntry {
|
||||
constructor(private readonly deps: PromoteGroupWaitlistEntryDeps = {}) {}
|
||||
|
||||
async execute(
|
||||
groupId: string,
|
||||
entryId: string,
|
||||
userId: string,
|
||||
): Promise<Result<PromoteGroupWaitlistEntryResult, ProblemDetails>> {
|
||||
const db = this.deps.db ?? prisma;
|
||||
const unitOfWork = this.deps.unitOfWork ?? new UnitOfWork(prisma);
|
||||
|
||||
const groupResult = await findGroupOwnedByUser(db, groupId, userId);
|
||||
if (!groupResult.ok) {
|
||||
return groupResult;
|
||||
}
|
||||
|
||||
const entry = await db.groupWaitlistEntry.findFirst({
|
||||
where: { id: entryId, groupId },
|
||||
});
|
||||
if (!entry) {
|
||||
return err(notFoundResourceProblem('GroupWaitlistEntry', entryId));
|
||||
}
|
||||
|
||||
return unitOfWork.executeResult(
|
||||
async (tx: Prisma.TransactionClient) => {
|
||||
const promoteResult = await promoteWaitlistEntryRecord(
|
||||
tx,
|
||||
groupResult.value,
|
||||
entry as unknown as GroupWaitlistEntryRecord,
|
||||
);
|
||||
if (!promoteResult.ok) {
|
||||
return promoteResult;
|
||||
}
|
||||
|
||||
await tx.groupWaitlistEntry.delete({ where: { id: entry.id } });
|
||||
return ok({ attendee: promoteResult.value });
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Hono } from 'hono';
|
||||
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
|
||||
import { RemoveGroupWaitlistEntry } from './use-case';
|
||||
|
||||
const route = new Hono();
|
||||
|
||||
route.delete('/:groupId/waitlist/:entryId', async (c) => {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
return problemJson(c, unauthorizedProblem(c.req.path));
|
||||
}
|
||||
const groupId = c.req.param('groupId');
|
||||
const entryId = c.req.param('entryId');
|
||||
const useCase = new RemoveGroupWaitlistEntry();
|
||||
const result = await useCase.execute(groupId, entryId, user.id);
|
||||
return resultJson(c, result);
|
||||
});
|
||||
|
||||
export default route;
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { PrismaClient } from '@generated/prisma/client';
|
||||
import type { ProblemDetails, RemoveGroupWaitlistEntryResult, Result } from '@gruperly/shared';
|
||||
import { err, ok } from '@gruperly/shared';
|
||||
import { notFoundResourceProblem } from '@/http/problem-builders';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { findGroupOwnedByUser } from '../../lib/helpers';
|
||||
|
||||
type RemoveGroupWaitlistEntryDeps = {
|
||||
db?: Pick<PrismaClient, 'group' | 'groupWaitlistEntry'>;
|
||||
};
|
||||
|
||||
export class RemoveGroupWaitlistEntry {
|
||||
constructor(private readonly deps: RemoveGroupWaitlistEntryDeps = {}) {}
|
||||
|
||||
async execute(
|
||||
groupId: string,
|
||||
entryId: string,
|
||||
userId: string,
|
||||
): Promise<Result<RemoveGroupWaitlistEntryResult, ProblemDetails>> {
|
||||
const db = this.deps.db ?? prisma;
|
||||
|
||||
const groupResult = await findGroupOwnedByUser(db, groupId, userId);
|
||||
if (!groupResult.ok) {
|
||||
return groupResult;
|
||||
}
|
||||
|
||||
const existing = await db.groupWaitlistEntry.findFirst({
|
||||
where: { id: entryId, groupId },
|
||||
});
|
||||
if (!existing) {
|
||||
return err(notFoundResourceProblem('GroupWaitlistEntry', entryId));
|
||||
}
|
||||
|
||||
await db.groupWaitlistEntry.delete({ where: { id: entryId } });
|
||||
return ok({ deleted: true });
|
||||
}
|
||||
}
|
||||
1
apps/backend/src/modules/group-waitlist/index.ts
Normal file
1
apps/backend/src/modules/group-waitlist/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as groupWaitlistRoutes } from './routes';
|
||||
65
apps/backend/src/modules/group-waitlist/lib/helpers.ts
Normal file
65
apps/backend/src/modules/group-waitlist/lib/helpers.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { Group, PrismaClient } from '@generated/prisma/client';
|
||||
import type { GroupWaitlistEntryDto, ProblemDetails, Result } from '@gruperly/shared';
|
||||
import { err, ok } from '@gruperly/shared';
|
||||
import { noGroupAccessProblem, notFoundResourceProblem } from '@/http/problem-builders';
|
||||
|
||||
export type GroupWaitlistEntryRecord = {
|
||||
id: string;
|
||||
groupId: string;
|
||||
fullName: string;
|
||||
phone: string;
|
||||
email: string | null;
|
||||
notes: string | null;
|
||||
status: GroupWaitlistEntryDto['status'];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export function toGroupWaitlistEntryDto(record: GroupWaitlistEntryRecord): GroupWaitlistEntryDto {
|
||||
return {
|
||||
id: record.id,
|
||||
groupId: record.groupId,
|
||||
fullName: record.fullName,
|
||||
phone: record.phone,
|
||||
email: record.email,
|
||||
notes: record.notes,
|
||||
status: record.status,
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function findGroupForUser(
|
||||
db: Pick<PrismaClient, 'group'>,
|
||||
groupId: string,
|
||||
userId: string,
|
||||
): Promise<Result<Group, ProblemDetails>> {
|
||||
const group = await db.group.findUnique({
|
||||
where: { id: groupId },
|
||||
include: { members: { where: { userId } } },
|
||||
});
|
||||
if (!group) {
|
||||
return err(notFoundResourceProblem('Group', groupId));
|
||||
}
|
||||
const isOwner = group.createdById === userId;
|
||||
const isMember = group.members.length > 0;
|
||||
if (!isOwner && !isMember) {
|
||||
return err(noGroupAccessProblem());
|
||||
}
|
||||
return ok(group);
|
||||
}
|
||||
|
||||
export async function findGroupOwnedByUser(
|
||||
db: Pick<PrismaClient, 'group'>,
|
||||
groupId: string,
|
||||
userId: string,
|
||||
): Promise<Result<Group, ProblemDetails>> {
|
||||
const group = await db.group.findUnique({ where: { id: groupId } });
|
||||
if (!group) {
|
||||
return err(notFoundResourceProblem('Group', groupId));
|
||||
}
|
||||
if (group.createdById !== userId) {
|
||||
return err(noGroupAccessProblem());
|
||||
}
|
||||
return ok(group);
|
||||
}
|
||||
83
apps/backend/src/modules/group-waitlist/lib/promote.ts
Normal file
83
apps/backend/src/modules/group-waitlist/lib/promote.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { PrismaClient } from '@generated/prisma/client';
|
||||
import type { AttendeeDto, ProblemDetails, Result } from '@gruperly/shared';
|
||||
import { err, ok } from '@gruperly/shared';
|
||||
import { capacityReachedProblem, conflictProblem } from '@/http/problem-builders';
|
||||
import { type AttendeeRecord, toAttendeeDto } from '@/modules/attendees/lib/helpers';
|
||||
import { type GroupWaitlistEntryRecord } from './helpers';
|
||||
|
||||
export type PromoteDb = Pick<PrismaClient, 'attendee' | 'groupWaitlistEntry'>;
|
||||
|
||||
type PromoteGroup = {
|
||||
id: string;
|
||||
capacity: number | null;
|
||||
};
|
||||
|
||||
export async function findFirstPendingWaitlistEntry(
|
||||
db: PromoteDb,
|
||||
groupId: string,
|
||||
): Promise<GroupWaitlistEntryRecord | null> {
|
||||
const record = await db.groupWaitlistEntry.findFirst({
|
||||
where: { groupId, status: 'PENDING' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
return record as GroupWaitlistEntryRecord | null;
|
||||
}
|
||||
|
||||
export async function promoteWaitlistEntryRecord(
|
||||
db: PromoteDb,
|
||||
group: PromoteGroup,
|
||||
entry: GroupWaitlistEntryRecord,
|
||||
): Promise<Result<AttendeeDto, ProblemDetails>> {
|
||||
if (group.capacity !== null) {
|
||||
const currentCount = await db.attendee.count({ where: { groupId: group.id } });
|
||||
if (currentCount >= group.capacity) {
|
||||
return err(capacityReachedProblem(group.capacity));
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await db.attendee.findFirst({
|
||||
where: { groupId: group.id, phone: entry.phone },
|
||||
});
|
||||
if (existing) {
|
||||
return err(
|
||||
conflictProblem({
|
||||
detail: 'Ya existe un alumno registrado con este número de teléfono en este grupo.',
|
||||
code: 'attendee_already_registered',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const attendee = await db.attendee.create({
|
||||
data: {
|
||||
groupId: group.id,
|
||||
fullName: entry.fullName,
|
||||
phone: entry.phone,
|
||||
email: entry.email,
|
||||
notes: entry.notes,
|
||||
},
|
||||
});
|
||||
|
||||
return ok(toAttendeeDto(attendee as unknown as AttendeeRecord));
|
||||
}
|
||||
|
||||
export type PromoteFirstResult = {
|
||||
attendee: AttendeeDto;
|
||||
entry: GroupWaitlistEntryRecord;
|
||||
};
|
||||
|
||||
export async function promoteFirstPendingWaitlistEntry(
|
||||
db: PromoteDb,
|
||||
group: PromoteGroup,
|
||||
): Promise<Result<PromoteFirstResult | null, ProblemDetails>> {
|
||||
const entry = await findFirstPendingWaitlistEntry(db, group.id);
|
||||
if (!entry) {
|
||||
return ok(null);
|
||||
}
|
||||
|
||||
const result = await promoteWaitlistEntryRecord(db, group, entry);
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return ok({ attendee: result.value, entry });
|
||||
}
|
||||
12
apps/backend/src/modules/group-waitlist/routes.ts
Normal file
12
apps/backend/src/modules/group-waitlist/routes.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Hono } from 'hono';
|
||||
import getAllRoute from './features/get-all/route';
|
||||
import promoteRoute from './features/promote/route';
|
||||
import removeRoute from './features/remove/route';
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
routes.route('/', getAllRoute);
|
||||
routes.route('/', promoteRoute);
|
||||
routes.route('/', removeRoute);
|
||||
|
||||
export default routes;
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { CreateGroupFromOrganization } from '@gruperly/shared';
|
||||
import { CreateGroupFromOrganizationSchema } from '@gruperly/shared';
|
||||
import { Hono } from 'hono';
|
||||
import { problemJson, unauthorizedProblem } from '@/http/problem-details';
|
||||
import { validate } from '@/http/validate';
|
||||
import { CreateGroupFromOrganization as UseCase } from './use-case';
|
||||
|
||||
const route = new Hono();
|
||||
|
||||
route.post('/', validate.json(CreateGroupFromOrganizationSchema), async (c) => {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
return problemJson(c, unauthorizedProblem(c.req.path));
|
||||
}
|
||||
|
||||
const data = c.req.valid('json') as CreateGroupFromOrganization;
|
||||
const useCase = new UseCase();
|
||||
const result = await useCase.execute(data, user.id);
|
||||
|
||||
if (!result.ok) {
|
||||
return problemJson(c, result.error);
|
||||
}
|
||||
|
||||
return c.json(result.value, result.value.alreadyExists ? 200 : 201);
|
||||
});
|
||||
|
||||
export default route;
|
||||
@@ -1,83 +0,0 @@
|
||||
import type { Prisma } from '@generated/prisma/client';
|
||||
import { Role } from '@generated/prisma/client';
|
||||
import type {
|
||||
CreateGroupFromOrganization as CreateGroupFromOrganizationInput,
|
||||
CreateGroupFromOrganizationResult,
|
||||
ProblemDetails,
|
||||
Result,
|
||||
} from '@gruperly/shared';
|
||||
import { err, ok } from '@gruperly/shared';
|
||||
import {
|
||||
groupOwnerRequiredProblem,
|
||||
organizationNotFoundProblem,
|
||||
} from '@/http/problem-builders';
|
||||
import { default as prisma, UnitOfWork } from '@/lib/prisma';
|
||||
import { type GroupDb, type GroupRecord, toGroupDto } from '../../lib';
|
||||
|
||||
type CreateGroupFromOrganizationDeps = {
|
||||
db?: Pick<GroupDb, 'group' | 'groupMember' | 'organization'>;
|
||||
unitOfWork?: UnitOfWork;
|
||||
};
|
||||
|
||||
export class CreateGroupFromOrganization {
|
||||
constructor(private readonly deps: CreateGroupFromOrganizationDeps = {}) {}
|
||||
|
||||
async execute(
|
||||
data: CreateGroupFromOrganizationInput,
|
||||
userId: string,
|
||||
): Promise<Result<CreateGroupFromOrganizationResult, ProblemDetails>> {
|
||||
const db = this.deps.db ?? prisma;
|
||||
const unitOfWork = this.deps.unitOfWork ?? new UnitOfWork(prisma);
|
||||
|
||||
const organization = await db.organization.findUnique({
|
||||
where: { id: data.organizationId },
|
||||
include: { members: true },
|
||||
});
|
||||
if (!organization) {
|
||||
return err(organizationNotFoundProblem(data.organizationId));
|
||||
}
|
||||
|
||||
const membership = organization.members.find((member: { userId: string; role: string }) => member.userId === userId);
|
||||
if (membership?.role !== 'owner') {
|
||||
return err(groupOwnerRequiredProblem());
|
||||
}
|
||||
|
||||
const existing = await db.group.findFirst({
|
||||
where: { createdById: userId, name: organization.name },
|
||||
});
|
||||
if (existing) {
|
||||
return ok({ group: toGroupDto(existing), alreadyExists: true });
|
||||
}
|
||||
|
||||
const transaction = unitOfWork.executeResult(
|
||||
async (tx: Prisma.TransactionClient) => {
|
||||
const created = await tx.group.create({
|
||||
data: {
|
||||
name: organization.name,
|
||||
createdById: userId,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.groupMember.create({
|
||||
data: {
|
||||
groupId: created.id,
|
||||
userId,
|
||||
role: Role.OWNER,
|
||||
},
|
||||
});
|
||||
|
||||
return ok(created);
|
||||
},
|
||||
);
|
||||
|
||||
const result = await transaction;
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return ok({
|
||||
group: toGroupDto(result.value as GroupRecord),
|
||||
alreadyExists: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
22
apps/backend/src/modules/groups/features/create/route.ts
Normal file
22
apps/backend/src/modules/groups/features/create/route.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { CreateFirstGroup } from '@gruperly/shared';
|
||||
import { CreateFirstGroupSchema } from '@gruperly/shared';
|
||||
import { Hono } from 'hono';
|
||||
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
|
||||
import { validate } from '@/http/validate';
|
||||
import { CreateGroup as CreateGroupUseCase } from './use-case';
|
||||
|
||||
const route = new Hono();
|
||||
|
||||
route.post('/', validate.json(CreateFirstGroupSchema), async (c) => {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
return problemJson(c, unauthorizedProblem(c.req.path));
|
||||
}
|
||||
|
||||
const data = c.req.valid('json') as CreateFirstGroup;
|
||||
const useCase = new CreateGroupUseCase();
|
||||
const result = await useCase.execute(data, user.id);
|
||||
return resultJson(c, result, { status: 201 });
|
||||
});
|
||||
|
||||
export default route;
|
||||
68
apps/backend/src/modules/groups/features/create/use-case.ts
Normal file
68
apps/backend/src/modules/groups/features/create/use-case.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { Prisma, PrismaClient } from '@generated/prisma/client';
|
||||
import { Role } from '@generated/prisma/client';
|
||||
import type {
|
||||
CreateFirstGroup as CreateGroupInput,
|
||||
CreateGroupResult,
|
||||
ProblemDetails,
|
||||
Result,
|
||||
} from '@gruperly/shared';
|
||||
import { err, ok } from '@gruperly/shared';
|
||||
import { paymentNotSetupProblem } from '@/http/problem-builders';
|
||||
import { default as prisma, UnitOfWork } from '@/lib/prisma';
|
||||
import { type GroupRecord, toGroupDto } from '../../lib';
|
||||
|
||||
type CreateGroupDeps = {
|
||||
db?: Pick<PrismaClient, 'group' | 'groupMember' | 'merchantAccount'>;
|
||||
unitOfWork?: UnitOfWork;
|
||||
};
|
||||
|
||||
export class CreateGroup {
|
||||
constructor(private readonly deps: CreateGroupDeps = {}) {}
|
||||
|
||||
async execute(
|
||||
data: CreateGroupInput,
|
||||
userId: string,
|
||||
): Promise<Result<CreateGroupResult, ProblemDetails>> {
|
||||
const db = this.deps.db ?? prisma;
|
||||
const unitOfWork = this.deps.unitOfWork ?? new UnitOfWork(prisma);
|
||||
|
||||
const merchantAccount = await db.merchantAccount.findUnique({ where: { userId } });
|
||||
if (!merchantAccount) {
|
||||
return err(paymentNotSetupProblem());
|
||||
}
|
||||
|
||||
const transaction = unitOfWork.executeResult(
|
||||
async (tx: Prisma.TransactionClient) => {
|
||||
const created = await tx.group.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
createdById: userId,
|
||||
days: data.days,
|
||||
time: data.time,
|
||||
capacity: data.capacity,
|
||||
price: data.price,
|
||||
billingType: data.billingType,
|
||||
dueDay: data.dueDay,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.groupMember.create({
|
||||
data: {
|
||||
groupId: created.id,
|
||||
userId,
|
||||
role: Role.OWNER,
|
||||
},
|
||||
});
|
||||
|
||||
return ok(created);
|
||||
},
|
||||
);
|
||||
|
||||
const result = await transaction;
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return ok({ group: toGroupDto(result.value as GroupRecord) });
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PrismaClient } from '@generated/prisma/client';
|
||||
import type { 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 };
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Hono } from 'hono';
|
||||
import addToGroupWaitlistRoute from '../attendees/features/add-to-waitlist/route';
|
||||
import bulkCreateAttendeesRoute from '../attendees/features/bulk-create/route';
|
||||
import createAttendeeRoute from '../attendees/features/create/route';
|
||||
import createFromOrganizationRoute from './features/create-from-organization/route';
|
||||
import removeAttendeeRoute from '../attendees/features/remove/route';
|
||||
import groupWaitlistRoutes from '../group-waitlist/routes';
|
||||
import createGroupRoute from './features/create/route';
|
||||
import getAllRoute from './features/get-all/route';
|
||||
import getByIdRoute from './features/get-by-id/route';
|
||||
import inviteTokenRoute from './features/invite-token/route';
|
||||
@@ -10,11 +13,14 @@ import listAttendeesRoute from './features/list-attendees/route';
|
||||
const routes = new Hono();
|
||||
|
||||
routes.route('/', getAllRoute);
|
||||
routes.route('/from-organization', createFromOrganizationRoute);
|
||||
routes.route('/', createGroupRoute);
|
||||
routes.route('/', inviteTokenRoute);
|
||||
routes.route('/', listAttendeesRoute);
|
||||
routes.route('/', createAttendeeRoute);
|
||||
routes.route('/', bulkCreateAttendeesRoute);
|
||||
routes.route('/', getByIdRoute);
|
||||
routes.route('/', addToGroupWaitlistRoute);
|
||||
routes.route('/', groupWaitlistRoutes);
|
||||
routes.route('/', removeAttendeeRoute);
|
||||
|
||||
export default routes;
|
||||
@@ -6,12 +6,13 @@ import prisma from '@/lib/prisma';
|
||||
import { type AttendeeRecord, normalizePhone, toAttendeeDto } from '@/modules/attendees/lib/helpers';
|
||||
|
||||
type JoinViaInviteDeps = {
|
||||
db?: Pick<PrismaClient, 'group' | 'attendee'>;
|
||||
db?: Pick<PrismaClient, 'group' | 'attendee' | 'groupWaitlistEntry'>;
|
||||
};
|
||||
|
||||
export type JoinViaInviteResult = {
|
||||
attendee: AttendeeDto;
|
||||
status: 'registered' | 'waitlisted';
|
||||
message: string;
|
||||
attendee: AttendeeDto | null;
|
||||
};
|
||||
|
||||
export class JoinViaInvite {
|
||||
@@ -52,6 +53,35 @@ export class JoinViaInvite {
|
||||
}
|
||||
|
||||
const fullName = `${payload.firstName.trim()} ${payload.lastName.trim()}`.trim();
|
||||
|
||||
if (group.capacity !== null) {
|
||||
const currentCount = await db.attendee.count({ where: { groupId: group.id } });
|
||||
|
||||
if (currentCount >= group.capacity) {
|
||||
const existingWaitlistEntry = await db.groupWaitlistEntry.findFirst({
|
||||
where: { groupId: group.id, phone },
|
||||
});
|
||||
|
||||
if (!existingWaitlistEntry) {
|
||||
await db.groupWaitlistEntry.create({
|
||||
data: {
|
||||
groupId: group.id,
|
||||
fullName,
|
||||
phone,
|
||||
email: payload.email?.trim() || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return ok({
|
||||
status: 'waitlisted',
|
||||
message:
|
||||
'El grupo está completo. Fuiste agregado a la lista de espera. Te contactaremos cuando haya un lugar disponible.',
|
||||
attendee: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const created = await db.attendee.create({
|
||||
data: {
|
||||
groupId: group.id,
|
||||
@@ -62,8 +92,9 @@ export class JoinViaInvite {
|
||||
});
|
||||
|
||||
return ok({
|
||||
status: 'registered',
|
||||
attendee: toAttendeeDto(created as unknown as AttendeeRecord),
|
||||
message: 'Inscripción realizada con éxito',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,12 @@ const db = {
|
||||
create: mock(),
|
||||
count: mock(),
|
||||
},
|
||||
groupWaitlistEntry: {
|
||||
findMany: mock(),
|
||||
findFirst: mock(),
|
||||
create: mock(),
|
||||
count: mock(),
|
||||
},
|
||||
};
|
||||
|
||||
mock.module('@/lib/prisma', () => ({
|
||||
@@ -130,8 +136,9 @@ describe('attendee incorporation & invite-token in groups', () => {
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const data = await res.json();
|
||||
expect(data.fullName).toBe('Martín Gómez');
|
||||
expect(data.phone).toBe('+5491133445566');
|
||||
expect(data.outcome).toBe('created');
|
||||
expect(data.attendee.fullName).toBe('Martín Gómez');
|
||||
expect(data.attendee.phone).toBe('+5491133445566');
|
||||
expect(prisma.attendee.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
groupId: 'group-1',
|
||||
@@ -161,6 +168,244 @@ describe('attendee incorporation & invite-token in groups', () => {
|
||||
expect(res.status).toBe(409);
|
||||
expect(prisma.attendee.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects owner with 409 group_capacity_reached when the group is full', async () => {
|
||||
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||
prisma.attendee.findFirst.mockResolvedValue(null);
|
||||
prisma.attendee.count.mockResolvedValue(20); // capacity full
|
||||
|
||||
const app = makeApp({ id: teacherId });
|
||||
const res = await app.request('/groups/group-1/attendees', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
firstName: 'Martín',
|
||||
lastName: 'Gómez',
|
||||
phone: '1133445566',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
const body = await res.json();
|
||||
expect(body.code).toBe('group_capacity_reached');
|
||||
expect(prisma.attendee.create).not.toHaveBeenCalled();
|
||||
expect(prisma.groupWaitlistEntry.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows the owner to add anyway when allowOverflow is set', async () => {
|
||||
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||
prisma.attendee.findFirst.mockResolvedValue(null);
|
||||
prisma.attendee.count.mockResolvedValue(20);
|
||||
prisma.attendee.create.mockResolvedValue({
|
||||
id: 'att-1',
|
||||
groupId: 'group-1',
|
||||
fullName: 'Martín Gómez',
|
||||
email: null,
|
||||
phone: '+5491133445566',
|
||||
guardianName: null,
|
||||
guardianPhone: null,
|
||||
notes: null,
|
||||
createdAt: new Date('2026-09-18T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-09-18T10:00:00.000Z'),
|
||||
} as never);
|
||||
|
||||
const app = makeApp({ id: teacherId });
|
||||
const res = await app.request('/groups/group-1/attendees?allowOverflow=true', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
firstName: 'Martín',
|
||||
lastName: 'Gómez',
|
||||
phone: '1133445566',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.outcome).toBe('created');
|
||||
expect(body.attendee.fullName).toBe('Martín Gómez');
|
||||
expect(prisma.attendee.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds to the waitlist when a non-owner member tries to add to a full group', async () => {
|
||||
const memberGroup = {
|
||||
...mockGroup,
|
||||
members: [{ id: 'gm-1', role: 'MEMBER' }],
|
||||
};
|
||||
prisma.group.findUnique.mockResolvedValue(memberGroup as never);
|
||||
prisma.attendee.findFirst.mockResolvedValue(null);
|
||||
prisma.attendee.count.mockResolvedValue(20);
|
||||
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(null);
|
||||
prisma.groupWaitlistEntry.create.mockResolvedValue({
|
||||
id: 'wl-1',
|
||||
groupId: 'group-1',
|
||||
fullName: 'Martín Gómez',
|
||||
phone: '+5491133445566',
|
||||
email: null,
|
||||
notes: null,
|
||||
status: 'PENDING',
|
||||
createdAt: new Date('2026-09-18T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-09-18T10:00:00.000Z'),
|
||||
} as never);
|
||||
|
||||
const app = makeApp({ id: 'member-1' });
|
||||
const res = await app.request('/groups/group-1/attendees', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
firstName: 'Martín',
|
||||
lastName: 'Gómez',
|
||||
phone: '1133445566',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.outcome).toBe('waitlisted');
|
||||
expect(prisma.attendee.create).not.toHaveBeenCalled();
|
||||
expect(prisma.groupWaitlistEntry.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
groupId: 'group-1',
|
||||
fullName: 'Martín Gómez',
|
||||
phone: '1133445566',
|
||||
email: null,
|
||||
notes: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('still waitlists a non-owner member even when allowOverflow is set', async () => {
|
||||
const memberGroup = {
|
||||
...mockGroup,
|
||||
members: [{ id: 'gm-1', role: 'MEMBER' }],
|
||||
};
|
||||
prisma.group.findUnique.mockResolvedValue(memberGroup as never);
|
||||
prisma.attendee.findFirst.mockResolvedValue(null);
|
||||
prisma.attendee.count.mockResolvedValue(20);
|
||||
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(null);
|
||||
|
||||
const app = makeApp({ id: 'member-1' });
|
||||
const res = await app.request('/groups/group-1/attendees?allowOverflow=true', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
firstName: 'Martín',
|
||||
lastName: 'Gómez',
|
||||
phone: '1133445566',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.outcome).toBe('waitlisted');
|
||||
expect(prisma.attendee.create).not.toHaveBeenCalled();
|
||||
expect(prisma.groupWaitlistEntry.create).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /groups/:groupId/waitlist (add to group waitlist)', () => {
|
||||
it('creates a waitlist entry for the group', async () => {
|
||||
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||
prisma.attendee.findFirst.mockResolvedValue(null);
|
||||
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(null);
|
||||
prisma.groupWaitlistEntry.create.mockResolvedValue({
|
||||
id: 'wl-1',
|
||||
groupId: 'group-1',
|
||||
fullName: 'Martín Gómez',
|
||||
phone: '+5491133445566',
|
||||
email: 'martin@example.com',
|
||||
notes: 'Viene con su hermano',
|
||||
status: 'PENDING',
|
||||
createdAt: new Date('2026-09-18T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-09-18T10:00:00.000Z'),
|
||||
} as never);
|
||||
|
||||
const app = makeApp({ id: teacherId });
|
||||
const res = await app.request('/groups/group-1/waitlist', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
firstName: 'Martín',
|
||||
lastName: 'Gómez',
|
||||
phone: '+54 9 11 3344-5566',
|
||||
email: 'martin@example.com',
|
||||
notes: 'Viene con su hermano',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.fullName).toBe('Martín Gómez');
|
||||
expect(body.phone).toBe('+5491133445566');
|
||||
expect(prisma.groupWaitlistEntry.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
groupId: 'group-1',
|
||||
fullName: 'Martín Gómez',
|
||||
phone: '+5491133445566',
|
||||
email: 'martin@example.com',
|
||||
notes: 'Viene con su hermano',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects when the phone number already belongs to an attendee', async () => {
|
||||
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||
prisma.attendee.findFirst.mockResolvedValue({ id: 'att-existing' } as never);
|
||||
|
||||
const app = makeApp({ id: teacherId });
|
||||
const res = await app.request('/groups/group-1/waitlist', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
firstName: 'Martín',
|
||||
lastName: 'Gómez',
|
||||
phone: '1133445566',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
const body = await res.json();
|
||||
expect(body.code).toBe('attendee_already_registered');
|
||||
expect(prisma.groupWaitlistEntry.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects when the phone number is already on the waitlist', async () => {
|
||||
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||
prisma.attendee.findFirst.mockResolvedValue(null);
|
||||
prisma.groupWaitlistEntry.findFirst.mockResolvedValue({ id: 'wl-existing' } as never);
|
||||
|
||||
const app = makeApp({ id: teacherId });
|
||||
const res = await app.request('/groups/group-1/waitlist', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
firstName: 'Martín',
|
||||
lastName: 'Gómez',
|
||||
phone: '1133445566',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
const body = await res.json();
|
||||
expect(body.code).toBe('already_waitlisted');
|
||||
});
|
||||
|
||||
it('rejects user without access to the group', async () => {
|
||||
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||
|
||||
const app = makeApp({ id: 'other-user' });
|
||||
const res = await app.request('/groups/group-1/waitlist', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
firstName: 'Martín',
|
||||
lastName: 'Gómez',
|
||||
phone: '1133445566',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /groups/:groupId/attendees/bulk (bulk import)', () => {
|
||||
|
||||
@@ -4,14 +4,13 @@ import { Hono } from 'hono';
|
||||
const db = {
|
||||
group: {
|
||||
findMany: mock(),
|
||||
findFirst: mock(),
|
||||
count: mock(),
|
||||
create: mock(),
|
||||
},
|
||||
groupMember: {
|
||||
create: mock(),
|
||||
},
|
||||
organization: {
|
||||
merchantAccount: {
|
||||
findUnique: mock(),
|
||||
},
|
||||
};
|
||||
@@ -102,19 +101,29 @@ describe('groups routes', () => {
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('creates a group from an owned organization', async () => {
|
||||
const organization = {
|
||||
id: 'org-1',
|
||||
name: 'Escuela Alfa',
|
||||
members: [{ userId, role: 'owner' }],
|
||||
};
|
||||
prisma.organization.findUnique.mockResolvedValue(organization as never);
|
||||
prisma.group.findFirst.mockResolvedValue(null);
|
||||
prisma.group.create.mockResolvedValue(group);
|
||||
it('creates a group with billing configuration', async () => {
|
||||
prisma.merchantAccount.findUnique.mockResolvedValue({ id: 'merchant-1', userId });
|
||||
prisma.group.create.mockResolvedValue({
|
||||
...group,
|
||||
days: ['MONDAY'],
|
||||
time: '09:00',
|
||||
capacity: 20,
|
||||
price: 500,
|
||||
billingType: 'MONTHLY',
|
||||
dueDay: 5,
|
||||
});
|
||||
|
||||
const res = await makeApp({ id: userId }).request('/groups/from-organization', {
|
||||
const res = await makeApp({ id: userId }).request('/groups', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ organizationId: 'org-1' }),
|
||||
body: JSON.stringify({
|
||||
name: 'Cuadrilla Alfa',
|
||||
days: ['MONDAY'],
|
||||
time: '09:00',
|
||||
capacity: 20,
|
||||
price: 500,
|
||||
billingType: 'MONTHLY',
|
||||
dueDay: 5,
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
@@ -122,77 +131,63 @@ describe('groups routes', () => {
|
||||
expect(await res.json()).toEqual({
|
||||
group: {
|
||||
...group,
|
||||
days: ['MONDAY'],
|
||||
time: '09:00',
|
||||
capacity: 20,
|
||||
price: 500,
|
||||
billingType: 'MONTHLY',
|
||||
dueDay: 5,
|
||||
createdAt: group.createdAt.toISOString(),
|
||||
updatedAt: group.updatedAt.toISOString(),
|
||||
},
|
||||
alreadyExists: false,
|
||||
});
|
||||
expect(prisma.group.create).toHaveBeenCalledWith({
|
||||
data: { name: 'Escuela Alfa', createdById: userId },
|
||||
data: {
|
||||
name: 'Cuadrilla Alfa',
|
||||
createdById: userId,
|
||||
days: ['MONDAY'],
|
||||
time: '09:00',
|
||||
capacity: 20,
|
||||
price: 500,
|
||||
billingType: 'MONTHLY',
|
||||
dueDay: 5,
|
||||
},
|
||||
});
|
||||
expect(prisma.groupMember.create).toHaveBeenCalledWith({
|
||||
data: { groupId: 'group-1', userId, role: 'OWNER' },
|
||||
});
|
||||
});
|
||||
|
||||
it('reuses an existing group with the same name', async () => {
|
||||
const organization = {
|
||||
id: 'org-1',
|
||||
name: 'Escuela Alfa',
|
||||
members: [{ userId, role: 'owner' }],
|
||||
};
|
||||
prisma.organization.findUnique.mockResolvedValue(organization as never);
|
||||
prisma.group.findFirst.mockResolvedValue(group);
|
||||
it('rejects creating a group without a linked merchant account', async () => {
|
||||
prisma.merchantAccount.findUnique.mockResolvedValue(null);
|
||||
|
||||
const res = await makeApp({ id: userId }).request('/groups/from-organization', {
|
||||
const res = await makeApp({ id: userId }).request('/groups', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ organizationId: 'org-1' }),
|
||||
body: JSON.stringify({
|
||||
name: 'Cuadrilla Alfa',
|
||||
days: ['MONDAY'],
|
||||
time: '09:00',
|
||||
capacity: 20,
|
||||
price: 500,
|
||||
billingType: 'MONTHLY',
|
||||
dueDay: 5,
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.alreadyExists).toBe(true);
|
||||
expect(res.status).toBe(409);
|
||||
expect(await res.json()).toMatchObject({ code: 'payment_not_setup' });
|
||||
expect(prisma.group.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects creating a group for an unknown organization', async () => {
|
||||
prisma.organization.findUnique.mockResolvedValue(null);
|
||||
|
||||
const res = await makeApp({ id: userId }).request('/groups/from-organization', {
|
||||
it('rejects creating a group with an invalid payload', async () => {
|
||||
const res = await makeApp({ id: userId }).request('/groups', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ organizationId: 'missing' }),
|
||||
body: JSON.stringify({ name: 'x' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(await res.json()).toMatchObject({ code: 'organization_not_found' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(prisma.group.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects creating a group when the user is not the owner', async () => {
|
||||
const organization = {
|
||||
id: 'org-1',
|
||||
name: 'Escuela Alfa',
|
||||
members: [{ userId: 'other-user', role: 'owner' }],
|
||||
};
|
||||
prisma.organization.findUnique.mockResolvedValue(organization as never);
|
||||
|
||||
const res = await makeApp({ id: userId }).request('/groups/from-organization', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ organizationId: 'org-1' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(await res.json()).toMatchObject({ code: 'group_owner_required' });
|
||||
expect(prisma.group.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects creating a group without a session', async () => {
|
||||
const res = await makeApp(null).request('/groups/from-organization', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ organizationId: 'org-1' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,11 @@ const db = {
|
||||
attendee: {
|
||||
findFirst: mock(),
|
||||
create: mock(),
|
||||
count: mock(),
|
||||
},
|
||||
groupWaitlistEntry: {
|
||||
findFirst: mock(),
|
||||
create: mock(),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -151,5 +156,48 @@ describe('public invitations routes', () => {
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('adds to the waitlist when the group is full', async () => {
|
||||
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||
prisma.attendee.findFirst.mockResolvedValue(null);
|
||||
prisma.attendee.count.mockResolvedValue(15); // capacity full
|
||||
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(null);
|
||||
prisma.groupWaitlistEntry.create.mockResolvedValue({
|
||||
id: 'wl-1',
|
||||
groupId: 'group-1',
|
||||
fullName: 'Lucía Méndez',
|
||||
phone: '+5491122334455',
|
||||
email: 'lucia@test.com',
|
||||
notes: null,
|
||||
status: 'PENDING',
|
||||
createdAt: new Date('2026-09-18T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-09-18T10:00:00.000Z'),
|
||||
} as never);
|
||||
|
||||
const res = await app.request('/invitations/valid-token-123/join', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
firstName: 'Lucía',
|
||||
lastName: 'Méndez',
|
||||
phone: '+54 9 11 2233-4455',
|
||||
email: 'lucia@test.com',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.status).toBe('waitlisted');
|
||||
expect(body.attendee).toBeNull();
|
||||
expect(prisma.attendee.create).not.toHaveBeenCalled();
|
||||
expect(prisma.groupWaitlistEntry.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
groupId: 'group-1',
|
||||
fullName: 'Lucía Méndez',
|
||||
phone: '+5491122334455',
|
||||
email: 'lucia@test.com',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
352
apps/backend/test/waitlist-management.test.ts
Normal file
352
apps/backend/test/waitlist-management.test.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import { beforeEach, describe, expect, it, mock, vi } from 'bun:test';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
const db = {
|
||||
group: {
|
||||
findUnique: mock(),
|
||||
},
|
||||
attendee: {
|
||||
findFirst: mock(),
|
||||
findMany: mock(),
|
||||
count: mock(),
|
||||
create: mock(),
|
||||
delete: mock(),
|
||||
},
|
||||
groupWaitlistEntry: {
|
||||
findMany: mock(),
|
||||
findFirst: mock(),
|
||||
count: mock(),
|
||||
create: mock(),
|
||||
delete: mock(),
|
||||
},
|
||||
payment: {
|
||||
count: mock(),
|
||||
},
|
||||
};
|
||||
|
||||
mock.module('@/lib/prisma', () => ({
|
||||
default: db,
|
||||
getPrismaClient: mock(),
|
||||
UnitOfWork: class {
|
||||
executeResult = mock(async (cb: (tx: unknown) => Promise<unknown>) => cb(db));
|
||||
execute = mock(async (cb: (tx: unknown) => Promise<unknown>) => cb(db));
|
||||
},
|
||||
}));
|
||||
|
||||
import prisma from '@/lib/prisma';
|
||||
import { groupsRoutes } from '@/modules/groups';
|
||||
|
||||
const teacherId = 'teacher-1';
|
||||
const mockGroup = {
|
||||
id: 'group-1',
|
||||
name: 'Taller de Pintura',
|
||||
description: null,
|
||||
createdById: teacherId,
|
||||
inviteToken: null,
|
||||
createdAt: new Date('2026-09-01T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-09-01T10:00:00.000Z'),
|
||||
days: ['MONDAY'],
|
||||
time: '18:00',
|
||||
capacity: 20,
|
||||
price: 1500,
|
||||
billingType: 'MONTHLY',
|
||||
dueDay: 10,
|
||||
members: [],
|
||||
};
|
||||
|
||||
const waitlistEntry = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'wl-1',
|
||||
groupId: 'group-1',
|
||||
fullName: 'Martín Gómez',
|
||||
phone: '+5491133445566',
|
||||
email: null,
|
||||
notes: null,
|
||||
status: 'PENDING',
|
||||
createdAt: new Date('2026-09-10T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-09-10T10:00:00.000Z'),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const attendee = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'att-1',
|
||||
groupId: 'group-1',
|
||||
fullName: 'Martín Gómez',
|
||||
phone: '+5491133445566',
|
||||
email: null,
|
||||
guardianName: null,
|
||||
guardianPhone: null,
|
||||
notes: null,
|
||||
createdAt: new Date('2026-09-18T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-09-18T10:00:00.000Z'),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
function makeApp(userValue: unknown) {
|
||||
const app = new Hono();
|
||||
app.use('*', async (c, next) => {
|
||||
c.set('user', userValue as never);
|
||||
await next();
|
||||
});
|
||||
app.route('/groups', groupsRoutes);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('group waitlist management', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /groups/:groupId/waitlist', () => {
|
||||
it('lists pending entries in FIFO order with pagination', async () => {
|
||||
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||
prisma.groupWaitlistEntry.findMany.mockResolvedValue([
|
||||
waitlistEntry({ id: 'wl-1', fullName: 'Ana García', createdAt: new Date('2026-09-08T10:00:00.000Z') }),
|
||||
waitlistEntry({ id: 'wl-2', fullName: 'Pedro López', createdAt: new Date('2026-09-09T10:00:00.000Z') }),
|
||||
] as never);
|
||||
prisma.groupWaitlistEntry.count.mockResolvedValue(2);
|
||||
|
||||
const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.data).toHaveLength(2);
|
||||
expect(data.data[0].fullName).toBe('Ana García');
|
||||
expect(data.pagination).toEqual({ page: 1, pageSize: 10, total: 2, totalPages: 1 });
|
||||
expect(prisma.groupWaitlistEntry.findMany).toHaveBeenCalledWith({
|
||||
where: { groupId: 'group-1', status: 'PENDING' },
|
||||
skip: 0,
|
||||
take: 10,
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects user without access to the group', async () => {
|
||||
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||
|
||||
const res = await makeApp({ id: 'other-user' }).request('/groups/group-1/waitlist');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(prisma.groupWaitlistEntry.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /groups/:groupId/waitlist/:entryId/promote', () => {
|
||||
it('creates an attendee from the entry and removes it from the waitlist', async () => {
|
||||
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(waitlistEntry() as never);
|
||||
prisma.attendee.count.mockResolvedValue(5);
|
||||
prisma.attendee.findFirst.mockResolvedValue(null);
|
||||
prisma.attendee.create.mockResolvedValue(attendee() as never);
|
||||
|
||||
const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-1/promote', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.attendee.fullName).toBe('Martín Gómez');
|
||||
expect(prisma.attendee.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ groupId: 'group-1', fullName: 'Martín Gómez', phone: '+5491133445566' }),
|
||||
});
|
||||
expect(prisma.groupWaitlistEntry.delete).toHaveBeenCalledWith({ where: { id: 'wl-1' } });
|
||||
});
|
||||
|
||||
it('rejects with 409 when the group reached its capacity', async () => {
|
||||
prisma.group.findUnique.mockResolvedValue({ ...mockGroup, capacity: 5 } as never);
|
||||
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(waitlistEntry() as never);
|
||||
prisma.attendee.count.mockResolvedValue(5);
|
||||
|
||||
const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-1/promote', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
const body = await res.json();
|
||||
expect(body.code).toBe('group_capacity_reached');
|
||||
expect(prisma.attendee.create).not.toHaveBeenCalled();
|
||||
expect(prisma.groupWaitlistEntry.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects with 409 when the phone already belongs to an attendee', async () => {
|
||||
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(waitlistEntry() as never);
|
||||
prisma.attendee.count.mockResolvedValue(5);
|
||||
prisma.attendee.findFirst.mockResolvedValue({ id: 'att-existing' } as never);
|
||||
|
||||
const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-1/promote', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
const body = await res.json();
|
||||
expect(body.code).toBe('attendee_already_registered');
|
||||
expect(prisma.attendee.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 404 when the entry does not exist', async () => {
|
||||
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(null);
|
||||
|
||||
const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-missing/promote', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('rejects a non-owner user', async () => {
|
||||
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||
|
||||
const res = await makeApp({ id: 'other-user' }).request('/groups/group-1/waitlist/wl-1/promote', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(prisma.attendee.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /groups/:groupId/waitlist/:entryId', () => {
|
||||
it('removes a waitlist entry', async () => {
|
||||
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(waitlistEntry() as never);
|
||||
|
||||
const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-1', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.deleted).toBe(true);
|
||||
expect(prisma.groupWaitlistEntry.delete).toHaveBeenCalledWith({ where: { id: 'wl-1' } });
|
||||
});
|
||||
|
||||
it('returns 404 when the entry does not exist', async () => {
|
||||
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(null);
|
||||
|
||||
const res = await makeApp({ id: teacherId }).request('/groups/group-1/waitlist/wl-missing', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(prisma.groupWaitlistEntry.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a non-owner user', async () => {
|
||||
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||
|
||||
const res = await makeApp({ id: 'other-user' }).request('/groups/group-1/waitlist/wl-1', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(prisma.groupWaitlistEntry.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /groups/:groupId/attendees/:attendeeId', () => {
|
||||
it('removes an attendee without promotion', async () => {
|
||||
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||
prisma.attendee.findFirst.mockResolvedValue(attendee() as never);
|
||||
prisma.payment.count.mockResolvedValue(0);
|
||||
|
||||
const res = await makeApp({ id: teacherId }).request('/groups/group-1/attendees/att-1', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.removedAttendeeId).toBe('att-1');
|
||||
expect(body.promoted).toBeNull();
|
||||
expect(prisma.attendee.delete).toHaveBeenCalledWith({ where: { id: 'att-1' } });
|
||||
});
|
||||
|
||||
it('removes an attendee and promotes the first pending waitlist entry atomically', async () => {
|
||||
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||
prisma.attendee.findFirst.mockImplementation(async ({ where }: { where?: { id?: string } }) => {
|
||||
if (where?.id) return attendee() as never;
|
||||
return null;
|
||||
});
|
||||
prisma.payment.count.mockResolvedValue(0);
|
||||
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(
|
||||
waitlistEntry({ id: 'wl-1', fullName: 'Sofía Ruiz', phone: '+5491133778899' }) as never,
|
||||
);
|
||||
prisma.attendee.count.mockResolvedValue(5);
|
||||
prisma.attendee.create.mockResolvedValue(
|
||||
attendee({ id: 'att-2', fullName: 'Sofía Ruiz', phone: '+5491133778899' }) as never,
|
||||
);
|
||||
|
||||
const res = await makeApp({ id: teacherId }).request(
|
||||
'/groups/group-1/attendees/att-1?promoteFromWaitlist=true',
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.removedAttendeeId).toBe('att-1');
|
||||
expect(body.promoted).toMatchObject({ id: 'wl-1', fullName: 'Sofía Ruiz' });
|
||||
expect(prisma.attendee.delete).toHaveBeenCalledWith({ where: { id: 'att-1' } });
|
||||
expect(prisma.groupWaitlistEntry.delete).toHaveBeenCalledWith({ where: { id: 'wl-1' } });
|
||||
expect(prisma.attendee.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ fullName: 'Sofía Ruiz', phone: '+5491133778899' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('removes the attendee when promote requested but waitlist is empty', async () => {
|
||||
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||
prisma.attendee.findFirst.mockResolvedValue(attendee() as never);
|
||||
prisma.payment.count.mockResolvedValue(0);
|
||||
prisma.groupWaitlistEntry.findFirst.mockResolvedValue(null);
|
||||
|
||||
const res = await makeApp({ id: teacherId }).request(
|
||||
'/groups/group-1/attendees/att-1?promoteFromWaitlist=true',
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.removedAttendeeId).toBe('att-1');
|
||||
expect(body.promoted).toBeNull();
|
||||
expect(prisma.attendee.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks removal when the attendee has payments', async () => {
|
||||
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||
prisma.attendee.findFirst.mockResolvedValue(attendee() as never);
|
||||
prisma.payment.count.mockResolvedValue(2);
|
||||
|
||||
const res = await makeApp({ id: teacherId }).request('/groups/group-1/attendees/att-1', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
const body = await res.json();
|
||||
expect(body.code).toBe('attendee_has_payments');
|
||||
expect(prisma.attendee.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 404 when the attendee does not belong to the group', async () => {
|
||||
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||
prisma.attendee.findFirst.mockResolvedValue(null);
|
||||
|
||||
const res = await makeApp({ id: teacherId }).request('/groups/group-1/attendees/att-missing', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(prisma.attendee.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a non-owner user', async () => {
|
||||
prisma.group.findUnique.mockResolvedValue(mockGroup as never);
|
||||
|
||||
const res = await makeApp({ id: 'other-user' }).request('/groups/group-1/attendees/att-1', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(prisma.attendee.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
231
apps/web/src/components/groups/GroupForm.tsx
Normal file
231
apps/web/src/components/groups/GroupForm.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import type { BillingType, CreateFirstGroup, WeekDay } from '@gruperly/shared'
|
||||
import { CreateFirstGroupSchema } from '@gruperly/shared'
|
||||
import { ArrowLeft, Check, Loader2 } from 'lucide-react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { Button, Input, Label } from '../ui'
|
||||
import { BILLING_TYPES, WEEK_DAY_CHIPS } from '../onboarding/constants'
|
||||
|
||||
const defaultValues: CreateFirstGroup = {
|
||||
name: '',
|
||||
days: [],
|
||||
time: '09:00',
|
||||
capacity: 1,
|
||||
price: 0,
|
||||
billingType: 'MONTHLY',
|
||||
dueDay: 1,
|
||||
}
|
||||
|
||||
type GroupFormProps = {
|
||||
heading: string
|
||||
description: string
|
||||
submitLabel: string
|
||||
isSubmitting: boolean
|
||||
errorMessage?: string | null
|
||||
onSubmit: (values: CreateFirstGroup) => void
|
||||
onBack?: () => void
|
||||
}
|
||||
|
||||
export function GroupForm({
|
||||
heading,
|
||||
description,
|
||||
submitLabel,
|
||||
isSubmitting,
|
||||
errorMessage,
|
||||
onSubmit,
|
||||
onBack,
|
||||
}: GroupFormProps) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<CreateFirstGroup>({
|
||||
resolver: zodResolver(CreateFirstGroupSchema),
|
||||
defaultValues,
|
||||
mode: 'onTouched',
|
||||
})
|
||||
|
||||
const days = watch('days')
|
||||
const billingType = watch('billingType')
|
||||
const dueDay = watch('dueDay')
|
||||
|
||||
const toggleDay = (day: WeekDay) => {
|
||||
const next = days.includes(day) ? days.filter((d) => d !== day) : [...days, day]
|
||||
setValue('days', next, { shouldValidate: true })
|
||||
}
|
||||
|
||||
const selectBillingType = (type: BillingType) => {
|
||||
setValue('billingType', type, { shouldValidate: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-5">
|
||||
<header className="space-y-1">
|
||||
<h2 className="text-2xl font-bold text-primary">{heading}</h2>
|
||||
<p className="text-sm text-foreground/60">{description}</p>
|
||||
</header>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="name">Nombre del grupo</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Ej: Yoga Vinyasa · Nivel 1"
|
||||
invalid={!!errors.name}
|
||||
{...register('name')}
|
||||
/>
|
||||
{errors.name ? <p className="mt-1 text-sm text-danger">{errors.name.message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Días de clase</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{WEEK_DAY_CHIPS.map(({ value, label }) => {
|
||||
const isActive = days.includes(value)
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-pressed={isActive}
|
||||
onClick={() => toggleDay(value)}
|
||||
className={cn(
|
||||
'h-9 rounded-full border px-3.5 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'border-accent bg-accent text-on-accent'
|
||||
: 'border-border bg-surface text-primary hover:bg-primary-soft',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{errors.days ? <p className="mt-1 text-sm text-danger">{errors.days.message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="time">Horario</Label>
|
||||
<Input
|
||||
id="time"
|
||||
type="time"
|
||||
invalid={!!errors.time}
|
||||
{...register('time')}
|
||||
/>
|
||||
{errors.time ? <p className="mt-1 text-sm text-danger">{errors.time.message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="capacity">Cupo máximo</Label>
|
||||
<Input
|
||||
id="capacity"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
step={1}
|
||||
invalid={!!errors.capacity}
|
||||
{...register('capacity', { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.capacity ? (
|
||||
<p className="mt-1 text-sm text-danger">{errors.capacity.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="price">Precio</Label>
|
||||
<div className="relative">
|
||||
<span className="pointer-events-none absolute inset-y-0 left-3 flex items-center text-sm text-foreground/50">
|
||||
$
|
||||
</span>
|
||||
<Input
|
||||
id="price"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
className="pl-7"
|
||||
invalid={!!errors.price}
|
||||
{...register('price', { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
{errors.price ? (
|
||||
<p className="mt-1 text-sm text-danger">{errors.price.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Tipo de cobro</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{BILLING_TYPES.map(({ value, label, hint }) => {
|
||||
const isActive = billingType === value
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-pressed={isActive}
|
||||
onClick={() => selectBillingType(value)}
|
||||
className={cn(
|
||||
'rounded-xl border px-3 py-2.5 text-left transition-colors',
|
||||
isActive
|
||||
? 'border-accent bg-accent-soft'
|
||||
: 'border-border bg-surface hover:bg-primary-soft',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'block text-sm font-semibold',
|
||||
isActive ? 'text-accent' : 'text-primary',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className="block text-xs text-foreground/50">{hint}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="dueDay">Día de vencimiento</Label>
|
||||
<Input
|
||||
id="dueDay"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={28}
|
||||
step={1}
|
||||
invalid={!!errors.dueDay}
|
||||
{...register('dueDay', { valueAsNumber: true })}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-foreground/50">
|
||||
Los cobros vencerán el día {isFinite(dueDay) && dueDay ? dueDay : '1'} de cada mes.
|
||||
</p>
|
||||
{errors.dueDay ? (
|
||||
<p className="mt-1 text-sm text-danger">{errors.dueDay.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="rounded-xl bg-danger-soft px-4 py-3 text-sm text-danger">{errorMessage}</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{onBack ? (
|
||||
<Button variant="outline" onClick={onBack} disabled={isSubmitting}>
|
||||
<ArrowLeft className="size-4" />
|
||||
Volver
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="submit" variant="primary" className="flex-1" disabled={isSubmitting}>
|
||||
{isSubmitting ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />}
|
||||
{isSubmitting ? 'Creando…' : submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -22,11 +22,6 @@ const BREADCRUMBS: Record<string, Crumb[]> = {
|
||||
{ label: 'Ajustes', to: '/settings' },
|
||||
{ label: 'Seguridad' },
|
||||
],
|
||||
'/settings/organizations': [
|
||||
{ label: 'Inicio', to: '/' },
|
||||
{ label: 'Ajustes', to: '/settings' },
|
||||
{ label: 'Organizaciones' },
|
||||
],
|
||||
'/profile': [
|
||||
{ label: 'Inicio', to: '/' },
|
||||
{ label: 'Mi perfil' },
|
||||
|
||||
@@ -1,23 +1,7 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import type { BillingType, CreateFirstGroup, OnboardingGroupDto, WeekDay } from '@gruperly/shared'
|
||||
import { CreateFirstGroupSchema } from '@gruperly/shared'
|
||||
import { ArrowLeft, Check, Loader2 } from 'lucide-react'
|
||||
import type { CreateFirstGroup, OnboardingGroupDto } from '@gruperly/shared'
|
||||
import { createFirstGroup } from '../../lib/api'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { Button, Input, Label } from '../ui'
|
||||
import { BILLING_TYPES, WEEK_DAY_CHIPS } from './constants'
|
||||
|
||||
const defaultValues: CreateFirstGroup = {
|
||||
name: '',
|
||||
days: [],
|
||||
time: '09:00',
|
||||
capacity: 1,
|
||||
price: 0,
|
||||
billingType: 'MONTHLY',
|
||||
dueDay: 1,
|
||||
}
|
||||
import { GroupForm } from '../groups/GroupForm'
|
||||
|
||||
type FirstGroupStepProps = {
|
||||
onBack: () => void
|
||||
@@ -25,212 +9,22 @@ type FirstGroupStepProps = {
|
||||
}
|
||||
|
||||
export function FirstGroupStep({ onBack, onCompleted }: FirstGroupStepProps) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<CreateFirstGroup>({
|
||||
resolver: zodResolver(CreateFirstGroupSchema),
|
||||
defaultValues,
|
||||
mode: 'onTouched',
|
||||
})
|
||||
|
||||
const days = watch('days')
|
||||
const billingType = watch('billingType')
|
||||
const dueDay = watch('dueDay')
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (values: CreateFirstGroup) => createFirstGroup(values),
|
||||
onSuccess: (result) => onCompleted(result.group),
|
||||
})
|
||||
|
||||
const toggleDay = (day: WeekDay) => {
|
||||
const next = days.includes(day) ? days.filter((d) => d !== day) : [...days, day]
|
||||
setValue('days', next, { shouldValidate: true })
|
||||
}
|
||||
|
||||
const selectBillingType = (type: BillingType) => {
|
||||
setValue('billingType', type, { shouldValidate: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit((values) => create.mutate(values))} noValidate className="space-y-5">
|
||||
<header className="space-y-1">
|
||||
<h2 className="text-2xl font-bold text-primary">Tu primer grupo</h2>
|
||||
<p className="text-sm text-foreground/60">
|
||||
Definí los datos de la clase que vas a cobrar. Después siempre podés editarlos.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="name">Nombre del grupo</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Ej: Yoga Vinyasa · Nivel 1"
|
||||
invalid={!!errors.name}
|
||||
{...register('name')}
|
||||
/>
|
||||
{errors.name ? <p className="mt-1 text-sm text-danger">{errors.name.message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Días de clase</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{WEEK_DAY_CHIPS.map(({ value, label }) => {
|
||||
const isActive = days.includes(value)
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-pressed={isActive}
|
||||
onClick={() => toggleDay(value)}
|
||||
className={cn(
|
||||
'h-9 rounded-full border px-3.5 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'border-accent bg-accent text-on-accent'
|
||||
: 'border-border bg-surface text-primary hover:bg-primary-soft',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{errors.days ? <p className="mt-1 text-sm text-danger">{errors.days.message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="time">Horario</Label>
|
||||
<Input
|
||||
id="time"
|
||||
type="time"
|
||||
invalid={!!errors.time}
|
||||
{...register('time')}
|
||||
/>
|
||||
{errors.time ? <p className="mt-1 text-sm text-danger">{errors.time.message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="capacity">Cupo máximo</Label>
|
||||
<Input
|
||||
id="capacity"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
step={1}
|
||||
invalid={!!errors.capacity}
|
||||
{...register('capacity', { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.capacity ? (
|
||||
<p className="mt-1 text-sm text-danger">{errors.capacity.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="price">Precio</Label>
|
||||
<div className="relative">
|
||||
<span className="pointer-events-none absolute inset-y-0 left-3 flex items-center text-sm text-foreground/50">
|
||||
$
|
||||
</span>
|
||||
<Input
|
||||
id="price"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
className="pl-7"
|
||||
invalid={!!errors.price}
|
||||
{...register('price', { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
{errors.price ? (
|
||||
<p className="mt-1 text-sm text-danger">{errors.price.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Tipo de cobro</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{BILLING_TYPES.map(({ value, label, hint }) => {
|
||||
const isActive = billingType === value
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-pressed={isActive}
|
||||
onClick={() => selectBillingType(value)}
|
||||
className={cn(
|
||||
'rounded-xl border px-3 py-2.5 text-left transition-colors',
|
||||
isActive
|
||||
? 'border-accent bg-accent-soft'
|
||||
: 'border-border bg-surface hover:bg-primary-soft',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'block text-sm font-semibold',
|
||||
isActive ? 'text-accent' : 'text-primary',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className="block text-xs text-foreground/50">{hint}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="dueDay">Día de vencimiento</Label>
|
||||
<Input
|
||||
id="dueDay"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={28}
|
||||
step={1}
|
||||
invalid={!!errors.dueDay}
|
||||
{...register('dueDay', { valueAsNumber: true })}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-foreground/50">
|
||||
Los cobros vencerán el día {isFinite(dueDay) && dueDay ? dueDay : '1'} de cada mes.
|
||||
</p>
|
||||
{errors.dueDay ? (
|
||||
<p className="mt-1 text-sm text-danger">{errors.dueDay.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{create.isError ? (
|
||||
<p className="rounded-xl bg-danger-soft px-4 py-3 text-sm text-danger">
|
||||
No pudimos crear el grupo. Revisá los datos e intentá de nuevo.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" onClick={onBack} disabled={create.isPending}>
|
||||
<ArrowLeft className="size-4" />
|
||||
Volver
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="flex-1"
|
||||
disabled={create.isPending}
|
||||
>
|
||||
{create.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Check className="size-4" />
|
||||
)}
|
||||
{create.isPending ? 'Creando…' : 'Crear grupo'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<GroupForm
|
||||
heading="Tu primer grupo"
|
||||
description="Definí los datos de la clase que vas a cobrar. Después siempre podés editarlos."
|
||||
submitLabel="Crear grupo"
|
||||
isSubmitting={create.isPending}
|
||||
errorMessage={
|
||||
create.isError ? 'No pudimos crear el grupo. Revisá los datos e intentá de nuevo.' : null
|
||||
}
|
||||
onSubmit={(values) => create.mutate(values)}
|
||||
onBack={onBack}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -6,15 +6,24 @@ import type {
|
||||
ConnectPayment,
|
||||
ConnectPaymentResult,
|
||||
CreateAttendee,
|
||||
CreateAttendeeResult,
|
||||
CreateFirstGroup,
|
||||
CreateFirstGroupResult,
|
||||
CreateGroupResult,
|
||||
CreateGroupWaitlistEntry,
|
||||
GroupDto,
|
||||
GroupInviteInfoDto,
|
||||
GroupList,
|
||||
GroupWaitlistEntryDto,
|
||||
GroupWaitlistList,
|
||||
InviteTokenResult,
|
||||
JoinGroupViaInvite,
|
||||
JoinGroupViaInviteResult,
|
||||
OnboardingStatusDto,
|
||||
ProblemDetails,
|
||||
PromoteGroupWaitlistEntryResult,
|
||||
RemoveAttendeeResult,
|
||||
RemoveGroupWaitlistEntryResult,
|
||||
} from '@gruperly/shared'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4000'
|
||||
@@ -71,6 +80,12 @@ export const createFirstGroup = (payload: CreateFirstGroup) =>
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
export const createGroup = (payload: CreateFirstGroup) =>
|
||||
apiFetch<CreateGroupResult>('/api/v1/groups', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
export const getGroups = () => apiFetch<GroupList>('/api/v1/groups')
|
||||
|
||||
export const getGroup = (groupId: string) =>
|
||||
@@ -87,13 +102,26 @@ export const getInviteInfo = (token: string) =>
|
||||
apiFetch<GroupInviteInfoDto>(`/api/v1/invitations/${token}`)
|
||||
|
||||
export const joinViaInvite = (token: string, payload: JoinGroupViaInvite) =>
|
||||
apiFetch<{ attendee: AttendeeDto; message: string }>(`/api/v1/invitations/${token}/join`, {
|
||||
apiFetch<JoinGroupViaInviteResult>(`/api/v1/invitations/${token}/join`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
export const createAttendee = (groupId: string, payload: CreateAttendee) =>
|
||||
apiFetch<AttendeeDto>(`/api/v1/groups/${groupId}/attendees`, {
|
||||
export const createAttendee = (
|
||||
groupId: string,
|
||||
payload: CreateAttendee,
|
||||
options?: { allowOverflow?: boolean },
|
||||
) =>
|
||||
apiFetch<CreateAttendeeResult>(
|
||||
`/api/v1/groups/${groupId}/attendees${options?.allowOverflow ? '?allowOverflow=true' : ''}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
)
|
||||
|
||||
export const addToGroupWaitlist = (groupId: string, payload: CreateGroupWaitlistEntry) =>
|
||||
apiFetch<GroupWaitlistEntryDto>(`/api/v1/groups/${groupId}/waitlist`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
@@ -105,4 +133,29 @@ export const bulkCreateAttendees = (groupId: string, payload: BulkCreateAttendee
|
||||
})
|
||||
|
||||
export const getGroupAttendees = (groupId: string, page = 1, pageSize = 20) =>
|
||||
apiFetch<AttendeeList>(`/api/v1/groups/${groupId}/attendees?page=${page}&pageSize=${pageSize}`)
|
||||
apiFetch<AttendeeList>(`/api/v1/groups/${groupId}/attendees?page=${page}&pageSize=${pageSize}`)
|
||||
|
||||
export const getGroupWaitlist = (groupId: string, page = 1, pageSize = 100) =>
|
||||
apiFetch<GroupWaitlistList>(`/api/v1/groups/${groupId}/waitlist?page=${page}&pageSize=${pageSize}`)
|
||||
|
||||
export const promoteGroupWaitlistEntry = (groupId: string, entryId: string) =>
|
||||
apiFetch<PromoteGroupWaitlistEntryResult>(`/api/v1/groups/${groupId}/waitlist/${entryId}/promote`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
export const removeGroupWaitlistEntry = (groupId: string, entryId: string) =>
|
||||
apiFetch<RemoveGroupWaitlistEntryResult>(`/api/v1/groups/${groupId}/waitlist/${entryId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
export const removeGroupAttendee = (
|
||||
groupId: string,
|
||||
attendeeId: string,
|
||||
options?: { promoteFromWaitlist?: boolean },
|
||||
) =>
|
||||
apiFetch<RemoveAttendeeResult>(
|
||||
`/api/v1/groups/${groupId}/attendees/${attendeeId}${options?.promoteFromWaitlist ? '?promoteFromWaitlist=true' : ''}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
},
|
||||
)
|
||||
@@ -6,12 +6,12 @@ import { PaymentsView } from './routes/payments'
|
||||
import { SettingsView } from './routes/settings'
|
||||
import { ProfileView } from './routes/profile'
|
||||
import { SecurityPage } from './routes/security'
|
||||
import { OrganizationsPage } from './routes/organizations'
|
||||
import { LoginPage } from './routes/auth/login'
|
||||
import { SignupPage } from './routes/auth/signup'
|
||||
import { VerifyEmailPage } from './routes/auth/verify-email'
|
||||
import { OnboardingView } from './routes/onboarding'
|
||||
import { GroupDetailView } from './routes/group-detail'
|
||||
import { CreateGroupView } from './routes/create-group'
|
||||
import { JoinGroupView } from './routes/join-group'
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
@@ -68,6 +68,12 @@ const groupsRoute = createRoute({
|
||||
component: GroupsView,
|
||||
})
|
||||
|
||||
const createGroupRoute = createRoute({
|
||||
getParentRoute: () => appLayoutRoute,
|
||||
path: '/groups/new',
|
||||
component: CreateGroupView,
|
||||
})
|
||||
|
||||
const groupDetailRoute = createRoute({
|
||||
getParentRoute: () => appLayoutRoute,
|
||||
path: '/groups/$groupId',
|
||||
@@ -98,12 +104,6 @@ const profileRoute = createRoute({
|
||||
component: ProfileView,
|
||||
})
|
||||
|
||||
const organizationsRoute = createRoute({
|
||||
getParentRoute: () => appLayoutRoute,
|
||||
path: '/settings/organizations',
|
||||
component: OrganizationsPage,
|
||||
})
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
loginRoute,
|
||||
signupRoute,
|
||||
@@ -113,11 +113,11 @@ const routeTree = rootRoute.addChildren([
|
||||
appLayoutRoute.addChildren([
|
||||
indexRoute,
|
||||
groupsRoute,
|
||||
createGroupRoute,
|
||||
groupDetailRoute,
|
||||
paymentsRoute,
|
||||
settingsRoute,
|
||||
securityRoute,
|
||||
organizationsRoute,
|
||||
profileRoute,
|
||||
]),
|
||||
])
|
||||
|
||||
39
apps/web/src/routes/create-group.tsx
Normal file
39
apps/web/src/routes/create-group.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate, Link } from '@tanstack/react-router'
|
||||
import type { CreateFirstGroup } from '@gruperly/shared'
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import { GroupForm } from '../components/groups/GroupForm'
|
||||
import { createGroup } from '../lib/api'
|
||||
|
||||
export function CreateGroupView() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (values: CreateFirstGroup) => createGroup(values),
|
||||
onSuccess: (result) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['groups'] })
|
||||
void navigate({ to: '/groups/$groupId', params: { groupId: result.group.id } })
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<section className="mx-auto w-full max-w-md">
|
||||
<Link to="/groups" className="hidden mb-6 items-center gap-1 text-sm font-medium text-foreground/70 transition-colors hover:text-primary lg:inline-flex">
|
||||
<ChevronLeft className="size-4" />
|
||||
Grupos
|
||||
</Link>
|
||||
|
||||
<GroupForm
|
||||
heading="Nuevo grupo"
|
||||
description="Definí los datos de la clase que vas a cobrar. Después siempre podés editarlos."
|
||||
submitLabel="Crear grupo"
|
||||
isSubmitting={create.isPending}
|
||||
errorMessage={
|
||||
create.isError ? 'No pudimos crear el grupo. Revisá los datos e intentá de nuevo.' : null
|
||||
}
|
||||
onSubmit={(values) => create.mutate(values)}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
import { useState, useRef, type ReactNode } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useParams, useNavigate, Link } from '@tanstack/react-router';
|
||||
import type { AttendeeDto } from '@gruperly/shared';
|
||||
import type {
|
||||
AttendeeDto,
|
||||
CreateAttendee,
|
||||
CreateAttendeeResult,
|
||||
CreateGroupWaitlistEntry,
|
||||
GroupWaitlistEntryDto,
|
||||
GroupWaitlistList,
|
||||
} from '@gruperly/shared';
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarClock,
|
||||
@@ -23,17 +30,24 @@ import {
|
||||
StickyNote,
|
||||
Trash2,
|
||||
UploadCloud,
|
||||
UserCheck,
|
||||
UserMinus,
|
||||
UserPlus,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import type { CreateAttendee } from '@gruperly/shared';
|
||||
import { Badge, Button, Input, Label, Modal, useToast } from '../components/ui';
|
||||
import {
|
||||
addToGroupWaitlist,
|
||||
ApiError,
|
||||
bulkCreateAttendees,
|
||||
createAttendee,
|
||||
getGroup,
|
||||
getGroupAttendees,
|
||||
getGroupWaitlist,
|
||||
getInviteToken,
|
||||
promoteGroupWaitlistEntry,
|
||||
removeGroupAttendee,
|
||||
removeGroupWaitlistEntry,
|
||||
} from '../lib/api';
|
||||
import {
|
||||
downloadAttendeeTemplateCsv,
|
||||
@@ -55,6 +69,9 @@ export function GroupDetailView() {
|
||||
const [isAddAttendeeModalOpen, setIsAddAttendeeModalOpen] = useState(false);
|
||||
const [selectedAttendee, setSelectedAttendee] = useState<AttendeeDto | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'quick' | 'bulk'>('quick');
|
||||
const [listSection, setListSection] = useState<'members' | 'waitlist'>('members');
|
||||
const [attendeeToRemove, setAttendeeToRemove] = useState<AttendeeDto | null>(null);
|
||||
const [selectedWaitlistEntry, setSelectedWaitlistEntry] = useState<GroupWaitlistEntryDto | null>(null);
|
||||
|
||||
// Quick form state
|
||||
const [firstName, setFirstName] = useState('');
|
||||
@@ -70,6 +87,11 @@ export function GroupDetailView() {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Capacity & waitlist state
|
||||
const [isCapacityModalOpen, setIsCapacityModalOpen] = useState(false);
|
||||
const [pendingCapacityPayload, setPendingCapacityPayload] = useState<CreateAttendee | null>(null);
|
||||
const [isBulkCapacityModalOpen, setIsBulkCapacityModalOpen] = useState(false);
|
||||
|
||||
// Attendees search filter
|
||||
const [searchFilter, setSearchFilter] = useState('');
|
||||
|
||||
@@ -92,6 +114,12 @@ export function GroupDetailView() {
|
||||
enabled: Boolean(groupId),
|
||||
});
|
||||
|
||||
const waitlistQuery = useQuery({
|
||||
queryKey: ['group-waitlist', groupId],
|
||||
queryFn: () => getGroupWaitlist(groupId, 1, 100),
|
||||
enabled: Boolean(groupId),
|
||||
});
|
||||
|
||||
// Regenerate invite token mutation
|
||||
const regenerateTokenMutation = useMutation({
|
||||
mutationFn: () => getInviteToken(groupId, true),
|
||||
@@ -105,23 +133,103 @@ export function GroupDetailView() {
|
||||
});
|
||||
|
||||
// Quick add attendee mutation
|
||||
const resetQuickForm = () => {
|
||||
setFirstName('');
|
||||
setLastName('');
|
||||
setPhone('');
|
||||
setEmail('');
|
||||
setNotes('');
|
||||
};
|
||||
|
||||
const closeAddAttendeeFlow = () => {
|
||||
resetQuickForm();
|
||||
setPendingCapacityPayload(null);
|
||||
setIsCapacityModalOpen(false);
|
||||
setIsAddAttendeeModalOpen(false);
|
||||
};
|
||||
|
||||
const handleCreateSuccess = (result: CreateAttendeeResult) => {
|
||||
if (result.outcome === 'created') {
|
||||
toast.success(`Miembro ${result.attendee.fullName} agregado con éxito.`);
|
||||
} else {
|
||||
toast.info(result.message);
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] });
|
||||
closeAddAttendeeFlow();
|
||||
};
|
||||
|
||||
const createAttendeeMutation = useMutation({
|
||||
mutationFn: (payload: CreateAttendee) => createAttendee(groupId, payload),
|
||||
onSuccess: (created) => {
|
||||
toast.success(`Miembro ${created.fullName} agregado con éxito.`);
|
||||
queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] });
|
||||
setFirstName('');
|
||||
setLastName('');
|
||||
setPhone('');
|
||||
setEmail('');
|
||||
setNotes('');
|
||||
setIsAddAttendeeModalOpen(false);
|
||||
onMutate: (payload) => setPendingCapacityPayload(payload),
|
||||
onSuccess: handleCreateSuccess,
|
||||
onError: (err: Error) => {
|
||||
if (err instanceof ApiError && err.problem?.code === 'group_capacity_reached') {
|
||||
setIsCapacityModalOpen(true);
|
||||
return;
|
||||
}
|
||||
toast.error(err.message || 'Error al agregar miembro.');
|
||||
},
|
||||
});
|
||||
|
||||
// Force add (owner overrides the full capacity)
|
||||
const createAttendeeForceMutation = useMutation({
|
||||
mutationFn: (payload: CreateAttendee) => createAttendee(groupId, payload, { allowOverflow: true }),
|
||||
onSuccess: handleCreateSuccess,
|
||||
onError: (err: Error) => {
|
||||
toast.error(err.message || 'Error al agregar miembro.');
|
||||
},
|
||||
});
|
||||
|
||||
// Add to group waitlist
|
||||
const addToWaitlistMutation = useMutation({
|
||||
mutationFn: (payload: CreateGroupWaitlistEntry) => addToGroupWaitlist(groupId, payload),
|
||||
onMutate: async (payload: CreateGroupWaitlistEntry) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['group-waitlist', groupId] });
|
||||
const previousWaitlist = queryClient.getQueryData<GroupWaitlistList>(['group-waitlist', groupId]);
|
||||
|
||||
if (previousWaitlist) {
|
||||
const optimisticEntry: GroupWaitlistEntryDto = {
|
||||
id: `temp-${Date.now()}`,
|
||||
groupId,
|
||||
fullName: `${payload.firstName} ${payload.lastName ?? ''}`.trim(),
|
||||
phone: payload.phone,
|
||||
email: payload.email || null,
|
||||
notes: payload.notes ?? null,
|
||||
status: 'PENDING',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
queryClient.setQueryData<GroupWaitlistList>(['group-waitlist', groupId], {
|
||||
data: [optimisticEntry, ...previousWaitlist.data],
|
||||
pagination: {
|
||||
...previousWaitlist.pagination,
|
||||
total: previousWaitlist.pagination.total + 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { previousWaitlist };
|
||||
},
|
||||
onSuccess: (entry) => {
|
||||
toast.info(`${entry.fullName} fue agregado a la lista de espera del grupo.`);
|
||||
queryClient.invalidateQueries({ queryKey: ['group-waitlist', groupId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['group', groupId] });
|
||||
closeAddAttendeeFlow();
|
||||
},
|
||||
onError: (err: Error, _payload, context) => {
|
||||
if (context?.previousWaitlist) {
|
||||
queryClient.setQueryData<GroupWaitlistList>(['group-waitlist', groupId], context.previousWaitlist);
|
||||
}
|
||||
if (err instanceof ApiError && err.problem?.code === 'already_waitlisted') {
|
||||
toast.info('Este número ya está en la lista de espera del grupo.');
|
||||
} else {
|
||||
toast.error(err.message || 'Error al agregar a la lista de espera.');
|
||||
}
|
||||
setPendingCapacityPayload(null);
|
||||
setIsCapacityModalOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
// Bulk import mutation
|
||||
const bulkImportMutation = useMutation({
|
||||
mutationFn: (attendees: Array<{ firstName: string; lastName: string; fullName: string; phone: string; email?: string; notes?: string }>) =>
|
||||
@@ -138,6 +246,63 @@ export function GroupDetailView() {
|
||||
},
|
||||
});
|
||||
|
||||
// Waitlist & removal mutations
|
||||
const invalidateGroupQueries = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['group-attendees', groupId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['group-waitlist', groupId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['group', groupId] });
|
||||
};
|
||||
|
||||
const removeAttendeeMutation = useMutation({
|
||||
mutationFn: (payload: { attendeeId: string; promoteFromWaitlist: boolean }) =>
|
||||
removeGroupAttendee(groupId, payload.attendeeId, { promoteFromWaitlist: payload.promoteFromWaitlist }),
|
||||
onSuccess: (result, payload) => {
|
||||
const removedName = attendees.find((a) => a.id === payload.attendeeId)?.fullName ?? 'El miembro';
|
||||
if (result.promoted) {
|
||||
toast.success(`${removedName} fue quitado del grupo y ${result.promoted.fullName} pasó de la lista de espera al grupo.`);
|
||||
} else {
|
||||
toast.success(`${removedName} fue quitado del grupo.`);
|
||||
}
|
||||
invalidateGroupQueries();
|
||||
setSelectedAttendee(null);
|
||||
setAttendeeToRemove(null);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
if (err instanceof ApiError && err.problem?.code === 'attendee_has_payments') {
|
||||
toast.error(err.problem.detail ?? 'Este miembro tiene cobros asociados.');
|
||||
} else {
|
||||
toast.error(err.message || 'Error al quitar al miembro del grupo.');
|
||||
}
|
||||
setAttendeeToRemove(null);
|
||||
},
|
||||
});
|
||||
|
||||
const promoteWaitlistMutation = useMutation({
|
||||
mutationFn: (entryId: string) => promoteGroupWaitlistEntry(groupId, entryId),
|
||||
onSuccess: (result) => {
|
||||
toast.success(`${result.attendee.fullName} pasó de la lista de espera al grupo.`);
|
||||
invalidateGroupQueries();
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
if (err instanceof ApiError && err.problem?.code === 'group_capacity_reached') {
|
||||
toast.error('El grupo alcanzó su cupo. Quita un miembro o aumenta el cupo primero.');
|
||||
} else {
|
||||
toast.error(err.message || 'No se pudo pasar al miembro al grupo.');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const removeWaitlistEntryMutation = useMutation({
|
||||
mutationFn: (entry: GroupWaitlistEntryDto) => removeGroupWaitlistEntry(groupId, entry.id),
|
||||
onSuccess: (_result, entry) => {
|
||||
toast.success(`${entry.fullName} fue quitado de la lista de espera.`);
|
||||
invalidateGroupQueries();
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(err.message || 'No se pudo quitar de la lista de espera.');
|
||||
},
|
||||
});
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
const url = inviteTokenQuery.data?.inviteUrl;
|
||||
if (!url) return;
|
||||
@@ -240,7 +405,7 @@ export function GroupDetailView() {
|
||||
setParsedContacts((prev) => prev.filter((c) => c.id !== id));
|
||||
};
|
||||
|
||||
const handleConfirmBulkImport = () => {
|
||||
const doBulkImport = () => {
|
||||
const validRows = parsedContacts.filter((c) => c.isValid);
|
||||
if (validRows.length === 0) {
|
||||
toast.error('No hay miembros válidos para importar.');
|
||||
@@ -258,8 +423,25 @@ export function GroupDetailView() {
|
||||
);
|
||||
};
|
||||
|
||||
const handleConfirmBulkImport = () => {
|
||||
if (validRowsCount === 0) {
|
||||
toast.error('No hay miembros válidos para importar.');
|
||||
return;
|
||||
}
|
||||
if (group?.capacity != null && attendeesTotal + validRowsCount > group.capacity) {
|
||||
setIsBulkCapacityModalOpen(true);
|
||||
return;
|
||||
}
|
||||
doBulkImport();
|
||||
};
|
||||
|
||||
const group = groupQuery.data;
|
||||
const attendees = attendeesQuery.data?.data ?? [];
|
||||
const attendeesTotal = attendeesQuery.data?.pagination?.total ?? attendees.length;
|
||||
const waitlistEntries = waitlistQuery.data?.data ?? [];
|
||||
const waitlistTotal = waitlistQuery.data?.pagination?.total ?? waitlistEntries.length;
|
||||
const firstWaitlistEntry = waitlistEntries[0] ?? null;
|
||||
const hasFreeCapacity = group?.capacity == null || attendeesTotal < group.capacity;
|
||||
const filteredAttendees = attendees.filter((a) => {
|
||||
const q = searchFilter.toLowerCase();
|
||||
return (
|
||||
@@ -307,7 +489,7 @@ export function GroupDetailView() {
|
||||
<div>
|
||||
<Link
|
||||
to="/groups"
|
||||
className="inline-flex items-center gap-1 text-sm text-foreground/60 hover:text-primary transition-colors mb-3"
|
||||
className="hidden lg:inline-flex items-center gap-1 text-sm text-foreground/60 hover:text-primary transition-colors mb-3"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
<span>Volver a grupos</span>
|
||||
@@ -360,9 +542,10 @@ export function GroupDetailView() {
|
||||
<div className="flex items-center gap-3">
|
||||
<Users className="size-5 text-accent shrink-0" />
|
||||
<div>
|
||||
<p className="text-xs font-medium text-foreground/50 uppercase">Miembros & Cupo</p>
|
||||
<p className="text-xs font-medium text-foreground/50 uppercase">Miembros & Cupo</p>
|
||||
<p className="font-medium text-primary">
|
||||
{attendees.length} inscritos {group.capacity ? `· Cupo de ${group.capacity}` : ''}
|
||||
{attendees.length} inscritos {waitlistTotal > 0 ? ` · ${waitlistTotal} en espera` : ''}
|
||||
{group.capacity ? ` · Cupo de ${group.capacity}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -382,108 +565,201 @@ export function GroupDetailView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Attendees Management Section */}
|
||||
{/* Miembros & Lista de espera */}
|
||||
<div className="rounded-xl border border-border bg-surface p-5 space-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-primary">
|
||||
Miembros del Grupo ({attendees.length})
|
||||
</h2>
|
||||
<p className="text-xs text-foreground/60">
|
||||
Listado de todos los miembros incorporados al grupo.
|
||||
</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div className="flex items-center rounded-xl bg-primary-soft p-1 w-full sm:w-[26rem]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setListSection('members')}
|
||||
className={`flex flex-1 items-center justify-center gap-1.5 py-2 text-xs font-semibold rounded-lg transition-all ${
|
||||
listSection === 'members'
|
||||
? 'bg-surface text-primary shadow-xs'
|
||||
: 'text-foreground/60 hover:text-primary'
|
||||
}`}
|
||||
>
|
||||
<Users className="size-4 shrink-0" />
|
||||
<span className="whitespace-nowrap">Miembros ({attendeesTotal})</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setListSection('waitlist')}
|
||||
className={`flex flex-1 items-center justify-center gap-1.5 py-2 text-xs font-semibold rounded-lg transition-all ${
|
||||
listSection === 'waitlist'
|
||||
? 'bg-surface text-primary shadow-xs'
|
||||
: 'text-foreground/60 hover:text-primary'
|
||||
}`}
|
||||
>
|
||||
<Clock className="size-4 shrink-0" />
|
||||
<span className="whitespace-nowrap">Lista de espera ({waitlistTotal})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{listSection === 'members' ? (
|
||||
<div className="relative w-full sm:w-64">
|
||||
<Search className="absolute left-3 top-2.5 size-4 text-foreground/40" />
|
||||
<Input
|
||||
placeholder="Buscar por nombre o teléfono..."
|
||||
value={searchFilter}
|
||||
onChange={(e) => setSearchFilter(e.target.value)}
|
||||
className="pl-9 h-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="relative w-full sm:w-64">
|
||||
<Search className="absolute left-3 top-2.5 size-4 text-foreground/40" />
|
||||
<Input
|
||||
placeholder="Buscar por nombre o teléfono..."
|
||||
value={searchFilter}
|
||||
onChange={(e) => setSearchFilter(e.target.value)}
|
||||
className="pl-9 h-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-foreground/60">
|
||||
{listSection === 'members'
|
||||
? 'Listado de todos los miembros incorporados al grupo.'
|
||||
: 'Personas que esperan un cupo libre en el grupo. Al pasar a alguien al grupo, se crea automáticamente su registro como miembro.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{attendeesQuery.isPending ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="size-6 animate-spin text-accent" />
|
||||
</div>
|
||||
) : null}
|
||||
{listSection === 'members' ? (
|
||||
<>
|
||||
{attendeesQuery.isPending ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="size-6 animate-spin text-accent" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{attendeesQuery.isSuccess && attendees.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border py-12 px-4 text-center">
|
||||
<Users className="size-10 text-foreground/30 mx-auto mb-2" />
|
||||
<p className="font-medium text-primary">Todavía no hay miembros en este grupo</p>
|
||||
<p className="text-sm text-foreground/60 mt-1 max-w-sm mx-auto">
|
||||
Puedes compartir el enlace de invitación único o agregar miembros de forma manual o masiva.
|
||||
</p>
|
||||
<div className="mt-4 flex items-center justify-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsInviteModalOpen(true)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Share2 className="size-4" />
|
||||
Compartir Enlace
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => setIsAddAttendeeModalOpen(true)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Agregar Miembros
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{attendeesQuery.isSuccess && attendees.length > 0 && filteredAttendees.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-foreground/60">
|
||||
No se encontraron miembros que coincidan con la búsqueda.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{filteredAttendees.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-border text-xs uppercase text-foreground/60 font-semibold">
|
||||
<tr>
|
||||
<th className="pb-3 px-3">Nombre</th>
|
||||
<th className="pb-3 px-3">Teléfono</th>
|
||||
<th className="pb-3 px-3 w-8" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{filteredAttendees.map((attendee) => (
|
||||
<tr
|
||||
key={attendee.id}
|
||||
className="hover:bg-primary-soft/50 transition-colors cursor-pointer"
|
||||
onClick={() => setSelectedAttendee(attendee)}
|
||||
{attendeesQuery.isSuccess && attendees.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border py-12 px-4 text-center">
|
||||
<Users className="size-10 text-foreground/30 mx-auto mb-2" />
|
||||
<p className="font-medium text-primary">Todavía no hay miembros en este grupo</p>
|
||||
<p className="text-sm text-foreground/60 mt-1 max-w-sm mx-auto">
|
||||
Puedes compartir el enlace de invitación único o agregar miembros de forma manual o masiva.
|
||||
</p>
|
||||
<div className="mt-4 flex items-center justify-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsInviteModalOpen(true)}
|
||||
className="gap-2"
|
||||
>
|
||||
<td className="py-3 px-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="size-8 rounded-full bg-accent/10 text-accent font-semibold flex items-center justify-center text-xs">
|
||||
{attendee.fullName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="font-medium text-primary">{attendee.fullName}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-3 text-foreground/70">
|
||||
{attendee.phone || <span className="text-foreground/40">—</span>}
|
||||
</td>
|
||||
<td className="py-3 px-1 text-foreground/30">
|
||||
<ChevronRight className="size-4" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<Share2 className="size-4" />
|
||||
Compartir Enlace
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => setIsAddAttendeeModalOpen(true)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Agregar Miembros
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{attendeesQuery.isSuccess && attendees.length > 0 && filteredAttendees.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-foreground/60">
|
||||
No se encontraron miembros que coincidan con la búsqueda.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{filteredAttendees.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-border text-xs uppercase text-foreground/60 font-semibold">
|
||||
<tr>
|
||||
<th className="pb-3 px-3">Nombre</th>
|
||||
<th className="pb-3 px-3">Teléfono</th>
|
||||
<th className="pb-3 px-3 w-8" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{filteredAttendees.map((attendee) => (
|
||||
<tr
|
||||
key={attendee.id}
|
||||
className="hover:bg-primary-soft/50 transition-colors cursor-pointer"
|
||||
onClick={() => setSelectedAttendee(attendee)}
|
||||
>
|
||||
<td className="py-3 px-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="size-8 rounded-full bg-accent/10 text-accent font-semibold flex items-center justify-center text-xs">
|
||||
{attendee.fullName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="font-medium text-primary">{attendee.fullName}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-3 text-foreground/70">
|
||||
{attendee.phone || <span className="text-foreground/40">—</span>}
|
||||
</td>
|
||||
<td className="py-3 px-1 text-foreground/30">
|
||||
<ChevronRight className="size-4" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{waitlistQuery.isPending ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="size-6 animate-spin text-accent" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{waitlistQuery.isSuccess && waitlistEntries.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border py-12 px-4 text-center">
|
||||
<Clock className="size-10 text-foreground/30 mx-auto mb-2" />
|
||||
<p className="font-medium text-primary">No hay nadie en la lista de espera</p>
|
||||
<p className="text-sm text-foreground/60 mt-1 max-w-sm mx-auto">
|
||||
Cuando el grupo alcance su cupo, las personas podrán sumarse a la espera y podrás pasarlas al grupo desde aquí.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{waitlistEntries.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-border text-xs uppercase text-foreground/60 font-semibold">
|
||||
<tr>
|
||||
<th className="pb-3 px-3">Nombre</th>
|
||||
<th className="pb-3 px-3">Teléfono</th>
|
||||
<th className="pb-3 px-3 w-8" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{waitlistEntries.map((entry) => (
|
||||
<tr
|
||||
key={entry.id}
|
||||
className="hover:bg-primary-soft/50 transition-colors cursor-pointer"
|
||||
onClick={() => setSelectedWaitlistEntry(entry)}
|
||||
>
|
||||
<td className="py-3 px-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="size-8 rounded-full bg-accent/10 text-accent font-semibold flex items-center justify-center text-xs">
|
||||
{entry.fullName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-primary truncate">{entry.fullName}</p>
|
||||
{entry.notes ? (
|
||||
<p className="text-xs text-foreground/50 truncate">{entry.notes}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-3 text-foreground/70">
|
||||
{entry.phone}
|
||||
</td>
|
||||
<td className="py-3 px-1 text-foreground/30">
|
||||
<ChevronRight className="size-4" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* MODAL: COMPARTIR LINK DE INVITACION */}
|
||||
@@ -880,6 +1156,95 @@ export function GroupDetailView() {
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* MODAL: CUPO ALCANZADO (ALTA INDIVIDUAL) */}
|
||||
<Modal
|
||||
isOpen={isCapacityModalOpen}
|
||||
onClose={() => {
|
||||
setPendingCapacityPayload(null);
|
||||
setIsCapacityModalOpen(false);
|
||||
}}
|
||||
title="Cupo alcanzado"
|
||||
description="El grupo llegó a su cupo máximo de miembros."
|
||||
maxWidth="md"
|
||||
>
|
||||
{pendingCapacityPayload ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-foreground/70">
|
||||
El grupo <strong className="text-primary">{group.name}</strong> ya alcanzó su cupo de{' '}
|
||||
<strong className="text-primary">{group.capacity}</strong> miembros. ¿Qué deseas hacer con{' '}
|
||||
<strong className="text-primary">
|
||||
{`${pendingCapacityPayload.firstName.trim()} ${pendingCapacityPayload.lastName.trim()}`.trim()}
|
||||
</strong>
|
||||
?
|
||||
</p>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => createAttendeeForceMutation.mutate({ ...pendingCapacityPayload })}
|
||||
disabled={createAttendeeForceMutation.isPending || addToWaitlistMutation.isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
{createAttendeeForceMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<UserPlus className="size-4" />
|
||||
)}
|
||||
<span>Agregar de todos modos</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => addToWaitlistMutation.mutate({ ...pendingCapacityPayload })}
|
||||
disabled={createAttendeeForceMutation.isPending || addToWaitlistMutation.isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
{addToWaitlistMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Clock className="size-4" />
|
||||
)}
|
||||
<span>Sumar a la lista de espera</span>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[11px] text-foreground/50 text-center">
|
||||
Si eliges agregarlo de todos modos, el grupo quedará por encima de su cupo.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
{/* MODAL: AVISO DE CUPO EN CARGA MASIVA */}
|
||||
<Modal
|
||||
isOpen={isBulkCapacityModalOpen}
|
||||
onClose={() => setIsBulkCapacityModalOpen(false)}
|
||||
title="Aviso de cupo"
|
||||
description="La carga supera el cupo del grupo."
|
||||
maxWidth="md"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-foreground/70">
|
||||
Estás por importar <strong className="text-primary">{validRowsCount}</strong> miembro(s), pero el grupo ya
|
||||
tiene <strong className="text-primary">{attendeesTotal}</strong> inscrito(s) y su cupo es de{' '}
|
||||
<strong className="text-primary">{group.capacity}</strong>. ¿Deseas importarlos de todos modos?
|
||||
</p>
|
||||
<div className="flex items-center justify-end gap-2.5 pt-2">
|
||||
<Button variant="ghost" onClick={() => setIsBulkCapacityModalOpen(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setIsBulkCapacityModalOpen(false);
|
||||
doBulkImport();
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<Check className="size-4" />
|
||||
Importar de todos modos
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* MODAL: DETALLE DEL PARTICIPANTE */}
|
||||
<Modal
|
||||
isOpen={selectedAttendee !== null}
|
||||
@@ -994,6 +1359,233 @@ export function GroupDetailView() {
|
||||
)}
|
||||
</DetailRow>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setAttendeeToRemove(selectedAttendee)}
|
||||
className="w-full gap-2 text-danger border border-danger/20 hover:bg-danger/10 hover:text-danger"
|
||||
>
|
||||
<UserMinus className="size-4" />
|
||||
Quitar del grupo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
{/* MODAL: CONFIRMAR QUITAR MIEMBRO */}
|
||||
<Modal
|
||||
isOpen={attendeeToRemove !== null}
|
||||
onClose={() => setAttendeeToRemove(null)}
|
||||
title="Quitar del grupo"
|
||||
maxWidth="md"
|
||||
>
|
||||
{attendeeToRemove ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-foreground/70">
|
||||
{attendeeToRemove.fullName} dejará de ser miembro del grupo{' '}
|
||||
<strong className="text-primary">{group.name}</strong>
|
||||
{firstWaitlistEntry ? ' y el cupo quedará libre.' : '.'}
|
||||
{attendeeToRemove.phone ? (
|
||||
<span className="block mt-1 text-xs text-foreground/50">
|
||||
Teléfono: {attendeeToRemove.phone}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
|
||||
{firstWaitlistEntry ? (
|
||||
<div className="rounded-xl border border-accent/20 bg-accent-soft p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-primary">
|
||||
<Clock className="size-4 text-accent" />
|
||||
<span>
|
||||
Hay {waitlistTotal} persona{waitlistTotal === 1 ? '' : 's'} esperando un cupo
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-foreground/80">
|
||||
¿Quieres pasar a{' '}
|
||||
<strong className="text-primary">{firstWaitlistEntry.fullName}</strong>, el primero de
|
||||
la lista de espera, al grupo?
|
||||
</p>
|
||||
<div className="flex flex-col gap-2.5 pt-1">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() =>
|
||||
removeAttendeeMutation.mutate({
|
||||
attendeeId: attendeeToRemove.id,
|
||||
promoteFromWaitlist: true,
|
||||
})
|
||||
}
|
||||
disabled={removeAttendeeMutation.isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
{removeAttendeeMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<UserCheck className="size-4" />
|
||||
)}
|
||||
<span>Quitar y pasar a {firstWaitlistEntry.fullName} al grupo</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
removeAttendeeMutation.mutate({
|
||||
attendeeId: attendeeToRemove.id,
|
||||
promoteFromWaitlist: false,
|
||||
})
|
||||
}
|
||||
disabled={removeAttendeeMutation.isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
{removeAttendeeMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<UserMinus className="size-4" />
|
||||
)}
|
||||
<span>Solo quitar del grupo</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-end gap-2.5 pt-2 border-t border-border">
|
||||
<Button variant="ghost" onClick={() => setAttendeeToRemove(null)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() =>
|
||||
removeAttendeeMutation.mutate({
|
||||
attendeeId: attendeeToRemove.id,
|
||||
promoteFromWaitlist: false,
|
||||
})
|
||||
}
|
||||
disabled={removeAttendeeMutation.isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
{removeAttendeeMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-4" />
|
||||
)}
|
||||
<span>Quitar del grupo</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
{/* MODAL: DETALLE LISTA DE ESPERA */}
|
||||
<Modal
|
||||
isOpen={selectedWaitlistEntry !== null}
|
||||
onClose={() => setSelectedWaitlistEntry(null)}
|
||||
title="Detalle de la lista de espera"
|
||||
maxWidth="sm"
|
||||
>
|
||||
{selectedWaitlistEntry ? (
|
||||
<div className="space-y-5">
|
||||
{/* Header: avatar + name */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-12 rounded-full bg-accent/10 text-accent font-bold flex items-center justify-center text-lg">
|
||||
{selectedWaitlistEntry.fullName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-lg font-semibold text-primary truncate">
|
||||
{selectedWaitlistEntry.fullName}
|
||||
</p>
|
||||
<p className="text-xs text-foreground/50">
|
||||
En espera desde el{' '}
|
||||
{new Date(selectedWaitlistEntry.createdAt).toLocaleDateString('es-ES', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info rows */}
|
||||
<div className="space-y-1 rounded-xl border border-border bg-primary-soft/30 divide-y divide-border">
|
||||
<DetailRow icon={<Phone className="size-4 text-accent" />} label="Teléfono">
|
||||
{selectedWaitlistEntry.phone ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-primary">{selectedWaitlistEntry.phone}</span>
|
||||
<a
|
||||
href={`https://wa.me/${selectedWaitlistEntry.phone.replace(/\D/g, '')}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-success hover:text-success/80 transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MessageCircle className="size-3.5" />
|
||||
<span>WhatsApp</span>
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-foreground/40">Sin teléfono</span>
|
||||
)}
|
||||
</DetailRow>
|
||||
|
||||
{selectedWaitlistEntry.email ? (
|
||||
<DetailRow icon={<Mail className="size-4 text-accent" />} label="Email">
|
||||
<a
|
||||
href={`mailto:${selectedWaitlistEntry.email}`}
|
||||
className="text-primary hover:text-accent transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{selectedWaitlistEntry.email}
|
||||
</a>
|
||||
</DetailRow>
|
||||
) : null}
|
||||
|
||||
<DetailRow icon={<StickyNote className="size-4 text-accent" />} label="Notas">
|
||||
{selectedWaitlistEntry.notes ? (
|
||||
<span className="text-primary text-xs leading-relaxed">
|
||||
{selectedWaitlistEntry.notes}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-foreground/40">Sin notas</span>
|
||||
)}
|
||||
</DetailRow>
|
||||
</div>
|
||||
|
||||
{/* Footer: status + actions */}
|
||||
{!hasFreeCapacity ? (
|
||||
<p className="rounded-xl border border-border bg-primary-soft/30 px-4 py-3 text-xs text-foreground/70">
|
||||
El grupo alcanzó su cupo de miembros. Quita un miembro o aumenta el cupo para poder pasar
|
||||
a esta persona al grupo.
|
||||
</p>
|
||||
) : null}
|
||||
<div className="grid grid-cols-1 gap-2.5 pt-2 border-t border-border">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
const entry = selectedWaitlistEntry;
|
||||
setSelectedWaitlistEntry(null);
|
||||
promoteWaitlistMutation.mutate(entry.id);
|
||||
}}
|
||||
disabled={promoteWaitlistMutation.isPending || !hasFreeCapacity}
|
||||
className="gap-2"
|
||||
>
|
||||
{promoteWaitlistMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<UserCheck className="size-4" />
|
||||
)}
|
||||
<span>Pasar al grupo</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
const entry = selectedWaitlistEntry;
|
||||
setSelectedWaitlistEntry(null);
|
||||
removeWaitlistEntryMutation.mutate(entry);
|
||||
}}
|
||||
disabled={removeWaitlistEntryMutation.isPending}
|
||||
className="gap-2 text-danger border border-danger/20 hover:bg-danger/10 hover:text-danger"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Quitar de la lista de espera
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
@@ -70,7 +70,7 @@ export function GroupsView() {
|
||||
<h1 className="text-2xl font-bold text-primary">Grupos</h1>
|
||||
<p className="mt-2 text-sm text-foreground/60">Tus grupos de cobranza.</p>
|
||||
</div>
|
||||
<Button variant="primary" onClick={() => void navigate({ to: '/onboarding' })}>
|
||||
<Button variant="primary" onClick={() => void navigate({ to: '/groups/new' })}>
|
||||
<Plus className="size-4" />
|
||||
Crear
|
||||
</Button>
|
||||
@@ -100,7 +100,7 @@ export function GroupsView() {
|
||||
<Button
|
||||
variant="primary"
|
||||
className="mt-5"
|
||||
onClick={() => void navigate({ to: '/onboarding' })}
|
||||
onClick={() => void navigate({ to: '/groups/new' })}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Crear tu primer grupo
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useParams, Link } from '@tanstack/react-router';
|
||||
import {
|
||||
CalendarClock,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
GraduationCap,
|
||||
Loader2,
|
||||
Moon,
|
||||
@@ -32,6 +33,9 @@ export function JoinGroupView() {
|
||||
phone: string | null;
|
||||
} | null>(null);
|
||||
|
||||
// Waitlist state (group is full)
|
||||
const [waitlistInfo, setWaitlistInfo] = useState<{ message: string } | null>(null);
|
||||
|
||||
const inviteQuery = useQuery({
|
||||
queryKey: ['public-invite', token],
|
||||
queryFn: () => getInviteInfo(token),
|
||||
@@ -49,9 +53,15 @@ export function JoinGroupView() {
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
setErrorMessage(null);
|
||||
if (data.status === 'waitlisted') {
|
||||
setRegisteredAttendee(null);
|
||||
setWaitlistInfo({ message: data.message });
|
||||
return;
|
||||
}
|
||||
setWaitlistInfo(null);
|
||||
setRegisteredAttendee({
|
||||
fullName: data.attendee.fullName,
|
||||
phone: data.attendee.phone,
|
||||
fullName: data.attendee!.fullName,
|
||||
phone: data.attendee!.phone,
|
||||
});
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
@@ -163,8 +173,32 @@ export function JoinGroupView() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Waitlist Confirmation (group full) */}
|
||||
{waitlistInfo && group ? (
|
||||
<div className="rounded-2xl border border-accent/30 bg-surface p-6 sm:p-8 text-center shadow-xl space-y-5 animate-step-enter">
|
||||
<div className="size-16 rounded-full bg-accent-soft text-accent flex items-center justify-center mx-auto">
|
||||
<Clock className="size-10" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Badge variant="neutral" className="mb-2">
|
||||
Lista de Espera
|
||||
</Badge>
|
||||
<h1 className="text-2xl font-bold text-primary">Cupo completo</h1>
|
||||
<p className="mt-2 text-sm text-foreground/70 leading-relaxed">
|
||||
{waitlistInfo.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-foreground/60">
|
||||
El profesor se pondrá en contacto contigo cuando haya un lugar disponible en{' '}
|
||||
<strong className="text-primary font-semibold">{group.name}</strong>.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Inscription Form */}
|
||||
{!registeredAttendee && group ? (
|
||||
{!registeredAttendee && !waitlistInfo && group ? (
|
||||
<div className="rounded-2xl border border-border bg-surface p-6 sm:p-8 shadow-xl space-y-6">
|
||||
{/* Group Info Header */}
|
||||
<div className="border-b border-border pb-5">
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Building2, Check, Loader2, Plus } from 'lucide-react'
|
||||
import { authClient } from '../lib/auth-client'
|
||||
import { Badge, Button, Input, Label } from '../components/ui'
|
||||
|
||||
const createOrgSchema = z.object({
|
||||
name: z.string().min(2, 'Ingresá el nombre del grupo'),
|
||||
slug: z
|
||||
.string()
|
||||
.min(2, 'Mínimo 2 caracteres')
|
||||
.regex(/^[a-z0-9-]+$/, 'Solo minúsculas, números y guiones'),
|
||||
})
|
||||
|
||||
type CreateOrgValues = z.infer<typeof createOrgSchema>
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4000'
|
||||
|
||||
type OrganizationRow = {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
logo: string | null
|
||||
}
|
||||
|
||||
function OrganizationList({ onRefresh }: { onRefresh: () => void }) {
|
||||
const { data, isPending } = authClient.useListOrganizations()
|
||||
const [syncing, setSyncing] = useState<string | null>(null)
|
||||
const [activeId, setActiveId] = useState<string | null>(null)
|
||||
|
||||
const organizations = (data ?? []) as OrganizationRow[]
|
||||
|
||||
const handleSyncGroup = async (org: OrganizationRow) => {
|
||||
setSyncing(org.id)
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/v1/groups/from-organization`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ organizationId: org.id }),
|
||||
})
|
||||
const body = (await res.json()) as { message?: string }
|
||||
if (!res.ok) {
|
||||
onRefresh()
|
||||
setSyncing(null)
|
||||
return
|
||||
}
|
||||
void body
|
||||
} finally {
|
||||
setSyncing(null)
|
||||
}
|
||||
onRefresh()
|
||||
}
|
||||
|
||||
const handleSetActive = async (organizationId: string) => {
|
||||
setActiveId(organizationId)
|
||||
await authClient.organization.setActive({ organizationId })
|
||||
setActiveId(null)
|
||||
}
|
||||
|
||||
if (isPending) {
|
||||
return <Loader2 className="size-5 animate-spin text-foreground/40" />
|
||||
}
|
||||
|
||||
if (organizations.length === 0) {
|
||||
return <p className="text-sm text-foreground/60">Todavía no pertenecés a ningún grupo.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-border rounded-xl border border-border bg-surface">
|
||||
{organizations.map((org) => (
|
||||
<li key={org.id} className="flex flex-wrap items-center gap-3 px-4 py-3">
|
||||
<span className="rounded-lg bg-accent-soft p-2">
|
||||
<Building2 className="size-4 text-accent" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-primary">{org.name}</p>
|
||||
<p className="text-xs text-foreground/50">/{org.slug}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={syncing === org.id}
|
||||
onClick={() => handleSyncGroup(org)}
|
||||
>
|
||||
{syncing === org.id ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
|
||||
Sincronizar grupo
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={activeId === org.id}
|
||||
onClick={() => handleSetActive(org.id)}
|
||||
>
|
||||
{activeId === org.id ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Check className="size-4" />
|
||||
)}
|
||||
<span className="hidden sm:inline">Usar</span>
|
||||
</Button>
|
||||
<Badge>Owner</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
export function OrganizationsPage() {
|
||||
const [refreshKey, setRefreshKey] = useState(0)
|
||||
const [feedback, setFeedback] = useState<string | null>(null)
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<CreateOrgValues>({ resolver: zodResolver(createOrgSchema) })
|
||||
|
||||
const refreshOrganizations = () => setRefreshKey((k) => k + 1)
|
||||
|
||||
const onCreateOrg = handleSubmit(async ({ name, slug }) => {
|
||||
setFeedback(null)
|
||||
const { error, data } = await authClient.organization.create({ name, slug })
|
||||
if (error) {
|
||||
setError('root', { message: error.message ?? 'No se pudo crear el grupo' })
|
||||
return
|
||||
}
|
||||
reset({ name: '', slug: '' })
|
||||
setFeedback('Grupo creado. Sincronizalo con el cobro grupal.')
|
||||
refreshOrganizations()
|
||||
void data
|
||||
})
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary">Grupos</h1>
|
||||
<p className="mt-1 text-sm text-foreground/60">
|
||||
Creá un grupo para organizar tus cobros. Cada organización se vincula a un grupo.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{feedback ? (
|
||||
<p className="rounded-xl bg-success-soft px-4 py-3 text-sm text-success">{feedback}</p>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-xl border border-border bg-surface p-5">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Plus className="size-4 text-accent" />
|
||||
<h2 className="text-base font-semibold text-primary">Crear grupo</h2>
|
||||
</div>
|
||||
<form onSubmit={onCreateOrg} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Nombre</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Gimnasio, club, kermés…"
|
||||
invalid={!!errors.name}
|
||||
{...register('name')}
|
||||
/>
|
||||
{errors.name ? <p className="mt-1 text-sm text-danger">{errors.name.message}</p> : null}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="slug">Slug</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
placeholder="gimnasio-don-bosco"
|
||||
invalid={!!errors.slug}
|
||||
{...register('slug')}
|
||||
/>
|
||||
{errors.slug ? <p className="mt-1 text-sm text-danger">{errors.slug.message}</p> : null}
|
||||
</div>
|
||||
{errors.root ? <p className="text-sm text-danger">{errors.root.message}</p> : null}
|
||||
<Button type="submit" variant="primary" disabled={isSubmitting}>
|
||||
{isSubmitting ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
Crear grupo
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border bg-surface p-5">
|
||||
<h2 className="mb-4 text-base font-semibold text-primary">Tus grupos</h2>
|
||||
<OrganizationList key={refreshKey} onRefresh={refreshOrganizations} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Building2, Check, Fingerprint, Monitor, Moon, Sun } from 'lucide-react'
|
||||
import { Check, Fingerprint, Monitor, Moon, Sun } from 'lucide-react'
|
||||
import { signOut } from '../lib/auth-client'
|
||||
import { Button } from '../components/ui'
|
||||
import { useTheme, type Theme } from '../context/ThemeProvider'
|
||||
@@ -76,19 +76,6 @@ export function SettingsView() {
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border rounded-xl border border-border bg-surface">
|
||||
<Link
|
||||
to="/settings/organizations"
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-primary-soft"
|
||||
>
|
||||
<span className="rounded-lg bg-accent-soft p-2">
|
||||
<Building2 className="size-4 text-accent-fg" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-primary">Grupos</p>
|
||||
<p className="text-xs text-foreground/50">Crear y gestionar tus organizaciones</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link to="/seguridad" className="flex items-center gap-3 px-4 py-3 hover:bg-primary-soft">
|
||||
<span className="rounded-lg bg-accent-soft p-2">
|
||||
<Fingerprint className="size-4 text-accent-fg" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
import { createPageSchema, createPageSizeSchema, createPaginationSchema } from './pagination.js';
|
||||
import { GroupWaitlistEntryDtoSchema } from './waitlist.js';
|
||||
|
||||
const isoDateTimeSchema = z.string().datetime();
|
||||
const pageSchema = createPageSchema();
|
||||
@@ -42,6 +43,18 @@ export const CreateAttendeeSchema = z.object({
|
||||
});
|
||||
export type CreateAttendee = z.output<typeof CreateAttendeeSchema>;
|
||||
|
||||
export const CreateAttendeeResultSchema = z.discriminatedUnion('outcome', [
|
||||
z.object({
|
||||
outcome: z.literal('created'),
|
||||
attendee: AttendeeDtoSchema,
|
||||
}),
|
||||
z.object({
|
||||
outcome: z.literal('waitlisted'),
|
||||
message: z.string(),
|
||||
}),
|
||||
]);
|
||||
export type CreateAttendeeResult = z.output<typeof CreateAttendeeResultSchema>;
|
||||
|
||||
export const JoinGroupViaInviteSchema = z.object({
|
||||
firstName: z.string().trim().min(1, 'El nombre es obligatorio'),
|
||||
lastName: z.string().trim().min(1, 'El apellido es obligatorio'),
|
||||
@@ -50,6 +63,13 @@ export const JoinGroupViaInviteSchema = z.object({
|
||||
});
|
||||
export type JoinGroupViaInvite = z.output<typeof JoinGroupViaInviteSchema>;
|
||||
|
||||
export const JoinGroupViaInviteResultSchema = z.object({
|
||||
status: z.enum(['registered', 'waitlisted']),
|
||||
message: z.string(),
|
||||
attendee: AttendeeDtoSchema.nullable(),
|
||||
});
|
||||
export type JoinGroupViaInviteResult = z.output<typeof JoinGroupViaInviteResultSchema>;
|
||||
|
||||
export const BulkAttendeeItemSchema = z.object({
|
||||
firstName: z.string().trim().min(1, 'El nombre es obligatorio'),
|
||||
lastName: z.string().trim().optional().default(''),
|
||||
@@ -73,4 +93,20 @@ export const BulkCreateAttendeesResultSchema = z.object({
|
||||
message: z.string(),
|
||||
created: z.array(AttendeeDtoSchema),
|
||||
});
|
||||
export type BulkCreateAttendeesResult = z.output<typeof BulkCreateAttendeesResultSchema>;
|
||||
export type BulkCreateAttendeesResult = z.output<typeof BulkCreateAttendeesResultSchema>;
|
||||
|
||||
export const PromoteGroupWaitlistEntryResultSchema = z.object({
|
||||
attendee: AttendeeDtoSchema,
|
||||
});
|
||||
export type PromoteGroupWaitlistEntryResult = z.output<typeof PromoteGroupWaitlistEntryResultSchema>;
|
||||
|
||||
export const RemoveAttendeeQuerySchema = z.object({
|
||||
promoteFromWaitlist: z.coerce.boolean().optional(),
|
||||
});
|
||||
export type RemoveAttendeeQuery = z.output<typeof RemoveAttendeeQuerySchema>;
|
||||
|
||||
export const RemoveAttendeeResultSchema = z.object({
|
||||
removedAttendeeId: z.string(),
|
||||
promoted: GroupWaitlistEntryDtoSchema.nullable(),
|
||||
});
|
||||
export type RemoveAttendeeResult = z.output<typeof RemoveAttendeeResultSchema>;
|
||||
@@ -56,15 +56,7 @@ export const GroupListSchema = z.object({
|
||||
});
|
||||
export type GroupList = z.output<typeof GroupListSchema>;
|
||||
|
||||
export const CreateGroupFromOrganizationSchema = z.strictObject({
|
||||
organizationId: z.string().min(1),
|
||||
});
|
||||
export type CreateGroupFromOrganization = z.output<typeof CreateGroupFromOrganizationSchema>;
|
||||
|
||||
export const CreateGroupFromOrganizationResultSchema = z.object({
|
||||
export const CreateGroupResultSchema = z.object({
|
||||
group: GroupDtoSchema,
|
||||
alreadyExists: z.boolean(),
|
||||
});
|
||||
export type CreateGroupFromOrganizationResult = z.output<
|
||||
typeof CreateGroupFromOrganizationResultSchema
|
||||
>;
|
||||
export type CreateGroupResult = z.output<typeof CreateGroupResultSchema>;
|
||||
@@ -28,4 +28,43 @@ export const WaitlistListSchema = z.object({
|
||||
data: z.array(WaitlistEntryDtoSchema),
|
||||
pagination: paginationSchema,
|
||||
});
|
||||
export type WaitlistList = z.output<typeof WaitlistListSchema>;
|
||||
export type WaitlistList = z.output<typeof WaitlistListSchema>;
|
||||
|
||||
export const GroupWaitlistEntryDtoSchema = z.object({
|
||||
id: z.string(),
|
||||
groupId: z.string(),
|
||||
fullName: z.string(),
|
||||
phone: z.string(),
|
||||
email: z.string().nullable(),
|
||||
notes: z.string().nullable(),
|
||||
status: waitlistStatusSchema,
|
||||
createdAt: isoDateTimeSchema,
|
||||
updatedAt: isoDateTimeSchema,
|
||||
});
|
||||
export type GroupWaitlistEntryDto = z.output<typeof GroupWaitlistEntryDtoSchema>;
|
||||
|
||||
export const CreateGroupWaitlistEntrySchema = z.object({
|
||||
firstName: z.string().trim().min(1, 'El nombre es obligatorio'),
|
||||
lastName: z.string().trim().optional().default(''),
|
||||
phone: z.string().trim().min(6, 'Ingresa un número de teléfono válido'),
|
||||
email: z.string().trim().email('Email inválido').optional().or(z.literal('')),
|
||||
notes: z.string().trim().optional(),
|
||||
});
|
||||
export type CreateGroupWaitlistEntry = z.output<typeof CreateGroupWaitlistEntrySchema>;
|
||||
|
||||
export const GroupWaitlistQuerySchema = z.object({
|
||||
page: pageSchema,
|
||||
pageSize: pageSizeSchema,
|
||||
});
|
||||
export type GroupWaitlistQuery = z.output<typeof GroupWaitlistQuerySchema>;
|
||||
|
||||
export const GroupWaitlistListSchema = z.object({
|
||||
data: z.array(GroupWaitlistEntryDtoSchema),
|
||||
pagination: paginationSchema,
|
||||
});
|
||||
export type GroupWaitlistList = z.output<typeof GroupWaitlistListSchema>;
|
||||
|
||||
export const RemoveGroupWaitlistEntryResultSchema = z.object({
|
||||
deleted: z.boolean(),
|
||||
});
|
||||
export type RemoveGroupWaitlistEntryResult = z.output<typeof RemoveGroupWaitlistEntryResultSchema>;
|
||||
Reference in New Issue
Block a user