Code refactoring

This commit is contained in:
Jose Selesan
2026-04-09 10:01:54 -03:00
parent 30ac0d569a
commit 58c5ce339b
5 changed files with 663 additions and 626 deletions

View File

@@ -0,0 +1,432 @@
import { useEffect, useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { zodResolver } from '@hookform/resolvers/zod'
import { createCourtSchema, type Court, type CreateCourtInput } from '@repo/api-contract'
import { Controller, useFieldArray, useForm } from 'react-hook-form'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { ApiClientError, apiClient } from '@/lib/api-client'
import { DAY_OPTIONS } from '../lib/court.constants'
import { createDefaultCourtValues } from '../lib/court.utils'
type CourtFormSectionProps = {
complexId: string | null
editingCourt: Court | null
initialDraft: CreateCourtInput | null
onCancelEdit: () => void
}
export function CourtFormSection({
complexId,
editingCourt,
initialDraft,
onCancelEdit,
}: CourtFormSectionProps) {
const queryClient = useQueryClient()
const [formError, setFormError] = useState<string | null>(null)
const [isWeekModalOpen, setIsWeekModalOpen] = useState(false)
const [weekDefaultStartTime, setWeekDefaultStartTime] = useState('08:00')
const [weekDefaultEndTime, setWeekDefaultEndTime] = useState('22:00')
const [weekModalError, setWeekModalError] = useState<string | null>(null)
const sportsQuery = useQuery({
queryKey: ['sports'],
queryFn: () => apiClient.sports.list(),
})
const form = useForm<CreateCourtInput>({
resolver: zodResolver(createCourtSchema),
mode: 'onBlur',
defaultValues: createDefaultCourtValues(),
})
const availabilityFieldArray = useFieldArray({
control: form.control,
name: 'availability',
})
const saveMutation = useMutation({
mutationFn: async (payload: CreateCourtInput) => {
if (editingCourt) {
return apiClient.courts.update(editingCourt.id, payload)
}
if (!complexId) {
throw new ApiClientError('No se pudo resolver el complejo para guardar la cancha.')
}
return apiClient.courts.create(complexId, payload)
},
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ['courts', complexId],
})
setFormError(null)
if (editingCourt) {
onCancelEdit()
}
form.reset(createDefaultCourtValues())
},
onError: (error) => {
const message =
error instanceof ApiClientError
? error.message
: 'No se pudo guardar la cancha. Intenta nuevamente.'
setFormError(message)
},
})
const activeSports = useMemo(
() => (sportsQuery.data ?? []).filter((sport) => sport.isActive),
[sportsQuery.data],
)
const onSubmit = async (values: CreateCourtInput) => {
setFormError(null)
await saveMutation.mutateAsync(values)
}
useEffect(() => {
if (editingCourt) {
form.reset({
name: editingCourt.name,
sportId: editingCourt.sportId,
slotDurationMinutes: editingCourt.slotDurationMinutes,
basePrice: editingCourt.basePrice,
availability: editingCourt.availability.map((slot) => ({
dayOfWeek: slot.dayOfWeek,
startTime: slot.startTime,
endTime: slot.endTime,
})),
})
return
}
if (initialDraft) {
form.reset(initialDraft)
return
}
form.reset(createDefaultCourtValues())
}, [editingCourt, form, initialDraft])
const title = editingCourt ? 'Editar cancha' : 'Nueva cancha'
const buttonText = editingCourt ? 'Guardar cambios' : 'Agregar cancha'
return (
<section className="rounded-xl border bg-card p-4 text-card-foreground shadow-sm sm:p-5">
<h3 className="text-lg font-semibold">{title}</h3>
<p className="mt-1 text-sm text-muted-foreground">
Configura nombre, deporte, duración, precio base y horarios. El backend ya
contempla precios por franja horaria para la siguiente etapa.
</p>
<form
className="mt-4 space-y-4"
onSubmit={form.handleSubmit((values) => {
void onSubmit(values)
})}
>
<Field data-invalid={Boolean(form.formState.errors.name)}>
<FieldLabel htmlFor="court-name">Nombre o descripción</FieldLabel>
<Input
id="court-name"
placeholder="Ej: Futbol 5 - Cancha 1"
aria-invalid={Boolean(form.formState.errors.name)}
{...form.register('name')}
/>
<FieldError errors={[form.formState.errors.name]} />
</Field>
<div className="grid gap-4 md:grid-cols-3">
<Field data-invalid={Boolean(form.formState.errors.sportId)}>
<FieldLabel>Deporte</FieldLabel>
<Controller
control={form.control}
name="sportId"
render={({ field }) => (
<Select
value={field.value}
onValueChange={field.onChange}
disabled={sportsQuery.isLoading}
>
<SelectTrigger className="h-10 w-full">
<SelectValue placeholder="Selecciona un deporte" />
</SelectTrigger>
<SelectContent>
{activeSports.map((sport) => (
<SelectItem key={sport.id} value={sport.id}>
{sport.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FieldError errors={[form.formState.errors.sportId]} />
</Field>
<Field data-invalid={Boolean(form.formState.errors.slotDurationMinutes)}>
<FieldLabel htmlFor="slot-duration">Duración por turno (min)</FieldLabel>
<Input
id="slot-duration"
type="number"
min={15}
step={5}
aria-invalid={Boolean(form.formState.errors.slotDurationMinutes)}
{...form.register('slotDurationMinutes', { valueAsNumber: true })}
/>
<FieldError errors={[form.formState.errors.slotDurationMinutes]} />
</Field>
<Field data-invalid={Boolean(form.formState.errors.basePrice)}>
<FieldLabel htmlFor="base-price">Precio base</FieldLabel>
<Input
id="base-price"
type="number"
min={0}
step="0.01"
aria-invalid={Boolean(form.formState.errors.basePrice)}
{...form.register('basePrice', { valueAsNumber: true })}
/>
<FieldError errors={[form.formState.errors.basePrice]} />
</Field>
</div>
<div className="space-y-3 rounded-lg border p-3 sm:p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h4 className="text-sm font-semibold">Rangos horarios</h4>
<p className="text-xs text-muted-foreground">
Define uno o más rangos por día.
</p>
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
<Button
type="button"
variant="outline"
className="w-full sm:w-auto"
onClick={() => {
const availability = form.getValues('availability')
setWeekDefaultStartTime(availability[0]?.startTime ?? '08:00')
setWeekDefaultEndTime(availability[0]?.endTime ?? '22:00')
setWeekModalError(null)
setIsWeekModalOpen(true)
}}
>
Agregar semana completa
</Button>
<Button
type="button"
variant="outline"
className="w-full sm:w-auto"
onClick={() => {
availabilityFieldArray.append({
dayOfWeek: 'MONDAY',
startTime: '08:00',
endTime: '22:00',
})
}}
>
Agregar rango
</Button>
</div>
</div>
<div className="space-y-2">
{availabilityFieldArray.fields.map((field, index) => (
<div
key={field.id}
className="grid gap-3 border-b pb-2 md:grid-cols-[1fr_1fr_1fr_auto]"
>
<Field
data-invalid={Boolean(form.formState.errors.availability?.[index]?.dayOfWeek)}
>
<FieldLabel>Día</FieldLabel>
<Controller
control={form.control}
name={`availability.${index}.dayOfWeek`}
render={({ field: dayField }) => (
<Select value={dayField.value} onValueChange={dayField.onChange}>
<SelectTrigger className="h-10 w-full">
<SelectValue placeholder="Día" />
</SelectTrigger>
<SelectContent>
{DAY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FieldError errors={[form.formState.errors.availability?.[index]?.dayOfWeek]} />
</Field>
<Field
data-invalid={Boolean(form.formState.errors.availability?.[index]?.startTime)}
>
<FieldLabel>Desde</FieldLabel>
<Input
type="time"
aria-invalid={Boolean(form.formState.errors.availability?.[index]?.startTime)}
{...form.register(`availability.${index}.startTime`)}
/>
<FieldError errors={[form.formState.errors.availability?.[index]?.startTime]} />
</Field>
<Field data-invalid={Boolean(form.formState.errors.availability?.[index]?.endTime)}>
<FieldLabel>Hasta</FieldLabel>
<Input
type="time"
aria-invalid={Boolean(form.formState.errors.availability?.[index]?.endTime)}
{...form.register(`availability.${index}.endTime`)}
/>
<FieldError errors={[form.formState.errors.availability?.[index]?.endTime]} />
</Field>
<div className="flex items-end">
<Button
type="button"
variant="ghost"
className="w-full md:w-auto"
disabled={availabilityFieldArray.fields.length === 1}
onClick={() => {
availabilityFieldArray.remove(index)
}}
>
Quitar
</Button>
</div>
</div>
))}
</div>
</div>
{formError && <p className="text-sm text-destructive">{formError}</p>}
<div className="flex flex-wrap items-center gap-2">
<Button
type="submit"
disabled={saveMutation.isPending || sportsQuery.isLoading || !complexId}
>
{saveMutation.isPending ? 'Guardando...' : buttonText}
</Button>
{editingCourt && (
<Button type="button" variant="outline" onClick={onCancelEdit}>
Cancelar edición
</Button>
)}
</div>
</form>
<Dialog
open={isWeekModalOpen}
onOpenChange={(nextOpen) => {
setIsWeekModalOpen(nextOpen)
if (!nextOpen) {
setWeekModalError(null)
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Horario por defecto</DialogTitle>
<DialogDescription>
Ingresa el horario para completar los días faltantes de la semana.
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="week-start-time">Desde</FieldLabel>
<Input
id="week-start-time"
type="time"
value={weekDefaultStartTime}
onChange={(event) => {
setWeekDefaultStartTime(event.target.value)
setWeekModalError(null)
}}
/>
</Field>
<Field>
<FieldLabel htmlFor="week-end-time">Hasta</FieldLabel>
<Input
id="week-end-time"
type="time"
value={weekDefaultEndTime}
onChange={(event) => {
setWeekDefaultEndTime(event.target.value)
setWeekModalError(null)
}}
/>
</Field>
</div>
{weekModalError && <p className="text-sm text-destructive">{weekModalError}</p>}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
setIsWeekModalOpen(false)
setWeekModalError(null)
}}
>
Cancelar
</Button>
<Button
type="button"
onClick={() => {
if (weekDefaultStartTime >= weekDefaultEndTime) {
setWeekModalError(
'El horario de inicio debe ser menor al horario de fin.',
)
return
}
const availability = form.getValues('availability')
const existingDays = new Set(availability.map((item) => item.dayOfWeek))
const missingDays = DAY_OPTIONS.filter(
(option) => !existingDays.has(option.value),
).map((option) => ({
dayOfWeek: option.value,
startTime: weekDefaultStartTime,
endTime: weekDefaultEndTime,
}))
if (missingDays.length > 0) {
availabilityFieldArray.append(missingDays)
}
setIsWeekModalOpen(false)
setWeekModalError(null)
}}
>
Agregar días faltantes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
)
}

View File

@@ -0,0 +1,85 @@
import type { Court } from '@repo/api-contract'
import { Button } from '@/components/ui/button'
import { formatCurrency, summarizeAvailability } from '../lib/court.utils'
type CourtListSectionProps = {
courts: Court[]
isLoading: boolean
isError: boolean
onDuplicateCourt: (court: Court) => void
onEditCourt: (court: Court) => void
}
export function CourtListSection({
courts,
isLoading,
isError,
onDuplicateCourt,
onEditCourt,
}: CourtListSectionProps) {
return (
<section className="rounded-xl border bg-card p-4 text-card-foreground shadow-sm sm:p-5">
<h3 className="text-lg font-semibold">Canchas configuradas</h3>
{isLoading && <p className="mt-3 text-sm text-muted-foreground">Cargando canchas...</p>}
{isError && (
<p className="mt-3 text-sm text-destructive">
No se pudieron cargar las canchas del complejo.
</p>
)}
{!isLoading && courts.length === 0 && (
<p className="mt-3 text-sm text-muted-foreground">
Aún no hay canchas. Crea la primera usando el formulario.
</p>
)}
<div className="mt-4 space-y-3">
{courts.map((court) => (
<article key={court.id} className="rounded-lg border p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
<div>
<p className="font-medium">{court.name}</p>
<p className="text-sm text-muted-foreground">
{court.sport.name} · {court.slotDurationMinutes} min ·{' '}
{formatCurrency(court.basePrice)}
</p>
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
<Button
type="button"
variant="outline"
className="w-full sm:w-auto"
onClick={() => {
onDuplicateCourt(court)
}}
>
Duplicar cancha
</Button>
<Button
type="button"
variant="outline"
className="w-full sm:w-auto"
onClick={() => {
onEditCourt(court)
}}
>
Editar
</Button>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2 text-xs text-muted-foreground">
{summarizeAvailability(court.availability).map((summary) => (
<span key={`${court.id}-${summary}`} className="rounded-md border px-2 py-1">
{summary}
</span>
))}
</div>
</article>
))}
</div>
</section>
)
}