- Added city, state, and country optional fields to Complex model - Updated onboarding to include optional location fields - Created new settings page with sidebar navigation (Datos del Complejo, Canchas) - Replaced ESLint with Biome for frontend and backend linting - Added parallel dev script with concurrently - Migrated register-routes to use direct app.route() pattern
429 lines
15 KiB
TypeScript
429 lines
15 KiB
TypeScript
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 { zodResolver } from '@hookform/resolvers/zod';
|
|
import { type Court, type CreateCourtInput, createCourtSchema } from '@repo/api-contract';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
|
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 mb-4">
|
|
<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>
|
|
);
|
|
}
|