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.
This commit is contained in:
@@ -31,7 +31,7 @@ bun --filter @gruperly/backend db:* # db:generate / db:migrate / db:push
|
||||
- `src/lib/` — `prisma.ts` (`getPrismaClient`, proxy `default` y clase `UnitOfWork`), `pagination.ts` (offset/metadata), `email.ts`, `error-message.ts`.
|
||||
- `src/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).
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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<CreateGroupResult>('/api/v1/groups', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
export const getGroups = () => apiFetch<GroupList>('/api/v1/groups')
|
||||
|
||||
export const getGroup = (groupId: string) =>
|
||||
|
||||
@@ -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="mb-6 inline-flex items-center gap-1 text-sm font-medium text-foreground/70 transition-colors hover:text-primary">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>;
|
||||
Reference in New Issue
Block a user