From 5572ef086953cb518352517266a3abb302c98232 Mon Sep 17 00:00:00 2001 From: Jose Selesan Date: Wed, 23 Sep 2026 09:19:52 -0300 Subject: [PATCH] feat(groups): implement group creation functionality and refactor related components - Removed organization dependency from group creation logic. - Introduced new route and use-case for creating groups. - Refactored form handling for group creation into a reusable GroupForm component. - Updated API calls to support new group creation endpoint. - Adjusted tests to reflect changes in group creation logic and validation. - Removed obsolete organizations route and related components. - Updated breadcrumb navigation and routing for group management. --- AGENTS.md | 2 +- apps/backend/src/http/problem-builders.ts | 17 -- .../create-from-organization/route.ts | 27 -- .../create-from-organization/use-case.ts | 83 ------- .../modules/groups/features/create/route.ts | 22 ++ .../groups/features/create/use-case.ts | 68 +++++ .../backend/src/modules/groups/lib/helpers.ts | 2 +- apps/backend/src/modules/groups/routes.ts | 4 +- apps/backend/test/groups.test.ts | 123 +++++----- apps/web/src/components/groups/GroupForm.tsx | 231 +++++++++++++++++ apps/web/src/components/layout/Breadcrumb.tsx | 5 - .../components/onboarding/FirstGroupStep.tsx | 232 +----------------- apps/web/src/lib/api.ts | 7 + apps/web/src/router.tsx | 16 +- apps/web/src/routes/create-group.tsx | 39 +++ apps/web/src/routes/groups.tsx | 4 +- apps/web/src/routes/organizations.tsx | 191 -------------- apps/web/src/routes/settings.tsx | 15 +- packages/shared/src/schemas/groups.ts | 12 +- 19 files changed, 456 insertions(+), 644 deletions(-) delete mode 100644 apps/backend/src/modules/groups/features/create-from-organization/route.ts delete mode 100644 apps/backend/src/modules/groups/features/create-from-organization/use-case.ts create mode 100644 apps/backend/src/modules/groups/features/create/route.ts create mode 100644 apps/backend/src/modules/groups/features/create/use-case.ts create mode 100644 apps/web/src/components/groups/GroupForm.tsx create mode 100644 apps/web/src/routes/create-group.tsx delete mode 100644 apps/web/src/routes/organizations.tsx diff --git a/AGENTS.md b/AGENTS.md index 353cc9e..df57821 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). diff --git a/apps/backend/src/http/problem-builders.ts b/apps/backend/src/http/problem-builders.ts index ece493e..b94f2c0 100644 --- a/apps/backend/src/http/problem-builders.ts +++ b/apps/backend/src/http/problem-builders.ts @@ -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 { diff --git a/apps/backend/src/modules/groups/features/create-from-organization/route.ts b/apps/backend/src/modules/groups/features/create-from-organization/route.ts deleted file mode 100644 index 5b7fc90..0000000 --- a/apps/backend/src/modules/groups/features/create-from-organization/route.ts +++ /dev/null @@ -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; \ No newline at end of file diff --git a/apps/backend/src/modules/groups/features/create-from-organization/use-case.ts b/apps/backend/src/modules/groups/features/create-from-organization/use-case.ts deleted file mode 100644 index c3dceba..0000000 --- a/apps/backend/src/modules/groups/features/create-from-organization/use-case.ts +++ /dev/null @@ -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; - unitOfWork?: UnitOfWork; -}; - -export class CreateGroupFromOrganization { - constructor(private readonly deps: CreateGroupFromOrganizationDeps = {}) {} - - async execute( - data: CreateGroupFromOrganizationInput, - userId: string, - ): Promise> { - 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, - }); - } -} \ No newline at end of file diff --git a/apps/backend/src/modules/groups/features/create/route.ts b/apps/backend/src/modules/groups/features/create/route.ts new file mode 100644 index 0000000..39c8631 --- /dev/null +++ b/apps/backend/src/modules/groups/features/create/route.ts @@ -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; \ No newline at end of file diff --git a/apps/backend/src/modules/groups/features/create/use-case.ts b/apps/backend/src/modules/groups/features/create/use-case.ts new file mode 100644 index 0000000..d731a85 --- /dev/null +++ b/apps/backend/src/modules/groups/features/create/use-case.ts @@ -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; + unitOfWork?: UnitOfWork; +}; + +export class CreateGroup { + constructor(private readonly deps: CreateGroupDeps = {}) {} + + async execute( + data: CreateGroupInput, + userId: string, + ): Promise> { + 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) }); + } +} \ No newline at end of file diff --git a/apps/backend/src/modules/groups/lib/helpers.ts b/apps/backend/src/modules/groups/lib/helpers.ts index 3db8c8c..958e7ad 100644 --- a/apps/backend/src/modules/groups/lib/helpers.ts +++ b/apps/backend/src/modules/groups/lib/helpers.ts @@ -1,7 +1,7 @@ import type { PrismaClient } from '@generated/prisma/client'; import type { BillingType, GroupDto, WeekDay } from '@gruperly/shared'; -export type GroupDb = Pick; +export type GroupDb = Pick; type PriceLike = { toString(): string }; diff --git a/apps/backend/src/modules/groups/routes.ts b/apps/backend/src/modules/groups/routes.ts index bfa9916..bfbc4d6 100644 --- a/apps/backend/src/modules/groups/routes.ts +++ b/apps/backend/src/modules/groups/routes.ts @@ -4,7 +4,7 @@ import bulkCreateAttendeesRoute from '../attendees/features/bulk-create/route'; import createAttendeeRoute from '../attendees/features/create/route'; import removeAttendeeRoute from '../attendees/features/remove/route'; import groupWaitlistRoutes from '../group-waitlist/routes'; -import createFromOrganizationRoute from './features/create-from-organization/route'; +import 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'; @@ -13,7 +13,7 @@ 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); diff --git a/apps/backend/test/groups.test.ts b/apps/backend/test/groups.test.ts index 3a55ced..eaa1a53 100644 --- a/apps/backend/test/groups.test.ts +++ b/apps/backend/test/groups.test.ts @@ -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); - }); }); \ No newline at end of file diff --git a/apps/web/src/components/groups/GroupForm.tsx b/apps/web/src/components/groups/GroupForm.tsx new file mode 100644 index 0000000..a9548c4 --- /dev/null +++ b/apps/web/src/components/groups/GroupForm.tsx @@ -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({ + 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 ( +
+
+

{heading}

+

{description}

+
+ +
+ + + {errors.name ?

{errors.name.message}

: null} +
+ +
+ +
+ {WEEK_DAY_CHIPS.map(({ value, label }) => { + const isActive = days.includes(value) + return ( + + ) + })} +
+ {errors.days ?

{errors.days.message}

: null} +
+ +
+ + + {errors.time ?

{errors.time.message}

: null} +
+ +
+
+ + + {errors.capacity ? ( +

{errors.capacity.message}

+ ) : null} +
+ +
+ +
+ + $ + + +
+ {errors.price ? ( +

{errors.price.message}

+ ) : null} +
+
+ +
+ +
+ {BILLING_TYPES.map(({ value, label, hint }) => { + const isActive = billingType === value + return ( + + ) + })} +
+
+ +
+ + +

+ Los cobros vencerán el día {isFinite(dueDay) && dueDay ? dueDay : '1'} de cada mes. +

+ {errors.dueDay ? ( +

{errors.dueDay.message}

+ ) : null} +
+ + {errorMessage ? ( +

{errorMessage}

+ ) : null} + +
+ {onBack ? ( + + ) : null} + +
+
+ ) +} \ No newline at end of file diff --git a/apps/web/src/components/layout/Breadcrumb.tsx b/apps/web/src/components/layout/Breadcrumb.tsx index 73d3a9b..0b86caa 100644 --- a/apps/web/src/components/layout/Breadcrumb.tsx +++ b/apps/web/src/components/layout/Breadcrumb.tsx @@ -22,11 +22,6 @@ const BREADCRUMBS: Record = { { label: 'Ajustes', to: '/settings' }, { label: 'Seguridad' }, ], - '/settings/organizations': [ - { label: 'Inicio', to: '/' }, - { label: 'Ajustes', to: '/settings' }, - { label: 'Organizaciones' }, - ], '/profile': [ { label: 'Inicio', to: '/' }, { label: 'Mi perfil' }, diff --git a/apps/web/src/components/onboarding/FirstGroupStep.tsx b/apps/web/src/components/onboarding/FirstGroupStep.tsx index 5cf7d6b..32da7bd 100644 --- a/apps/web/src/components/onboarding/FirstGroupStep.tsx +++ b/apps/web/src/components/onboarding/FirstGroupStep.tsx @@ -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({ - 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 ( -
create.mutate(values))} noValidate className="space-y-5"> -
-

Tu primer grupo

-

- Definí los datos de la clase que vas a cobrar. Después siempre podés editarlos. -

-
- -
- - - {errors.name ?

{errors.name.message}

: null} -
- -
- -
- {WEEK_DAY_CHIPS.map(({ value, label }) => { - const isActive = days.includes(value) - return ( - - ) - })} -
- {errors.days ?

{errors.days.message}

: null} -
- -
- - - {errors.time ?

{errors.time.message}

: null} -
- -
-
- - - {errors.capacity ? ( -

{errors.capacity.message}

- ) : null} -
- -
- -
- - $ - - -
- {errors.price ? ( -

{errors.price.message}

- ) : null} -
-
- -
- -
- {BILLING_TYPES.map(({ value, label, hint }) => { - const isActive = billingType === value - return ( - - ) - })} -
-
- -
- - -

- Los cobros vencerán el día {isFinite(dueDay) && dueDay ? dueDay : '1'} de cada mes. -

- {errors.dueDay ? ( -

{errors.dueDay.message}

- ) : null} -
- - {create.isError ? ( -

- No pudimos crear el grupo. Revisá los datos e intentá de nuevo. -

- ) : null} - -
- - -
-
+ create.mutate(values)} + onBack={onBack} + /> ) } \ No newline at end of file diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 61d962f..14601f5 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -9,6 +9,7 @@ import type { CreateAttendeeResult, CreateFirstGroup, CreateFirstGroupResult, + CreateGroupResult, CreateGroupWaitlistEntry, GroupDto, GroupInviteInfoDto, @@ -79,6 +80,12 @@ export const createFirstGroup = (payload: CreateFirstGroup) => body: JSON.stringify(payload), }) +export const createGroup = (payload: CreateFirstGroup) => + apiFetch('/api/v1/groups', { + method: 'POST', + body: JSON.stringify(payload), + }) + export const getGroups = () => apiFetch('/api/v1/groups') export const getGroup = (groupId: string) => diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index ff61e75..7865cb8 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -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, ]), ]) diff --git a/apps/web/src/routes/create-group.tsx b/apps/web/src/routes/create-group.tsx new file mode 100644 index 0000000..5d9d6ca --- /dev/null +++ b/apps/web/src/routes/create-group.tsx @@ -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 ( +
+ + + Grupos + + + create.mutate(values)} + /> +
+ ) +} \ No newline at end of file diff --git a/apps/web/src/routes/groups.tsx b/apps/web/src/routes/groups.tsx index 4b97be2..ded2901 100644 --- a/apps/web/src/routes/groups.tsx +++ b/apps/web/src/routes/groups.tsx @@ -70,7 +70,7 @@ export function GroupsView() {

Grupos

Tus grupos de cobranza.

- @@ -100,7 +100,7 @@ export function GroupsView() { - - Owner - - ))} - - ) -} - -export function OrganizationsPage() { - const [refreshKey, setRefreshKey] = useState(0) - const [feedback, setFeedback] = useState(null) - - const { - register, - handleSubmit, - reset, - setError, - formState: { errors, isSubmitting }, - } = useForm({ 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 ( -
-
-

Grupos

-

- Creá un grupo para organizar tus cobros. Cada organización se vincula a un grupo. -

-
- - {feedback ? ( -

{feedback}

- ) : null} - -
-
- -

Crear grupo

-
-
-
- - - {errors.name ?

{errors.name.message}

: null} -
-
- - - {errors.slug ?

{errors.slug.message}

: null} -
- {errors.root ?

{errors.root.message}

: null} - -
-
- -
-

Tus grupos

- -
-
- ) -} \ No newline at end of file diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 665b901..8777eca 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -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() {
- - - - -
-

Grupos

-

Crear y gestionar tus organizaciones

-
- - diff --git a/packages/shared/src/schemas/groups.ts b/packages/shared/src/schemas/groups.ts index 10f04b9..6d933f4 100644 --- a/packages/shared/src/schemas/groups.ts +++ b/packages/shared/src/schemas/groups.ts @@ -56,15 +56,7 @@ export const GroupListSchema = z.object({ }); export type GroupList = z.output; -export const CreateGroupFromOrganizationSchema = z.strictObject({ - organizationId: z.string().min(1), -}); -export type CreateGroupFromOrganization = z.output; - -export const CreateGroupFromOrganizationResultSchema = z.object({ +export const CreateGroupResultSchema = z.object({ group: GroupDtoSchema, - alreadyExists: z.boolean(), }); -export type CreateGroupFromOrganizationResult = z.output< - typeof CreateGroupFromOrganizationResultSchema ->; \ No newline at end of file +export type CreateGroupResult = z.output; \ No newline at end of file