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:
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user