feat: add city/state/country to complex, new settings page with sidebar, and Biome linting

- 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
This commit is contained in:
Jose Selesan
2026-04-10 15:36:15 -03:00
parent 77004b14b0
commit 9ee98a4cb4
129 changed files with 2929 additions and 3186 deletions

View File

@@ -1,27 +1,25 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { type Court, type CreateCourtInput } from '@repo/api-contract'
import { MapPin } from 'lucide-react'
import { apiClient } from '@/lib/api-client'
import { CourtFormSection } from './court-form-section'
import { CourtListSection } from './court-list-section'
import { Separator } from '@/components/ui/separator'
import { Separator } from '@/components/ui/separator';
import { apiClient } from '@/lib/api-client';
import { type Court, type CreateCourtInput } from '@repo/api-contract';
import { useQuery } from '@tanstack/react-query';
import { MapPin } from 'lucide-react';
import { useState } from 'react';
import { CourtFormSection } from './court-form-section';
import { CourtListSection } from './court-list-section';
type ComplexCourtsSectionProps = {
complexId: string | null
}
complexId: string | null;
};
export function ComplexCourtsSection({
complexId,
}: ComplexCourtsSectionProps) {
const [editingCourt, setEditingCourt] = useState<Court | null>(null)
const [initialDraft, setInitialDraft] = useState<CreateCourtInput | null>(null)
export function ComplexCourtsSection({ complexId }: ComplexCourtsSectionProps) {
const [editingCourt, setEditingCourt] = useState<Court | null>(null);
const [initialDraft, setInitialDraft] = useState<CreateCourtInput | null>(null);
const courtsQuery = useQuery({
queryKey: ['courts', complexId],
enabled: Boolean(complexId),
queryFn: () => apiClient.courts.listByComplex(complexId as string),
})
});
return (
<section className="mt-6 rounded-xl border bg-card p-5">
@@ -30,16 +28,14 @@ export function ComplexCourtsSection({
<h3 className="text-lg font-medium">Canchas</h3>
</div>
<Separator className="my-4" />
<p className="mb-4 text-sm text-muted-foreground">
Alta y edición de canchas del complejo.
</p>
<p className="mb-4 text-sm text-muted-foreground">Alta y edición de canchas del complejo.</p>
<CourtFormSection
complexId={complexId}
editingCourt={editingCourt}
initialDraft={initialDraft}
onCancelEdit={() => {
setEditingCourt(null)
setEditingCourt(null);
}}
/>
@@ -48,7 +44,7 @@ export function ComplexCourtsSection({
isLoading={courtsQuery.isLoading}
isError={courtsQuery.isError}
onDuplicateCourt={(court) => {
setEditingCourt(null)
setEditingCourt(null);
setInitialDraft({
name: `${court.name} - Copy`,
sportId: court.sportId,
@@ -59,15 +55,15 @@ export function ComplexCourtsSection({
startTime: slot.startTime,
endTime: slot.endTime,
})),
})
window.scrollTo({ top: 0, behavior: 'smooth' })
});
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
onEditCourt={(court) => {
setInitialDraft(null)
setEditingCourt(court)
window.scrollTo({ top: 0, behavior: 'smooth' })
setInitialDraft(null);
setEditingCourt(court);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
/>
</section>
)
);
}

View File

@@ -1,43 +1,39 @@
import { useEffect, useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import type { Complex, UpdateComplexInput } from '@repo/api-contract'
import { updateComplexSchema } from '@repo/api-contract'
import { Building2, Loader2 } from 'lucide-react'
import { apiClient } from '@/lib/api-client'
import { Button } from '@/components/ui/button'
import { Field, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator'
import { Button } from '@/components/ui/button';
import { Field, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import { apiClient } from '@/lib/api-client';
import type { Complex, UpdateComplexInput } from '@repo/api-contract';
import { updateComplexSchema } from '@repo/api-contract';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Building2, Loader2 } from 'lucide-react';
import { useEffect, useState } from 'react';
type ComplexDetailsSectionProps = {
complex: Complex | null
isLoading: boolean
isError: boolean
}
complex: Complex | null;
isLoading: boolean;
isError: boolean;
};
type FormValues = {
complexName: string
physicalAddress: string
city: string
state: string
country: string
}
complexName: string;
physicalAddress: string;
city: string;
state: string;
country: string;
};
export function ComplexDetailsSection({
complex,
isLoading,
isError,
}: ComplexDetailsSectionProps) {
const queryClient = useQueryClient()
export function ComplexDetailsSection({ complex, isLoading, isError }: ComplexDetailsSectionProps) {
const queryClient = useQueryClient();
const [formValues, setFormValues] = useState<FormValues>({
complexName: '',
physicalAddress: '',
city: '',
state: '',
country: '',
})
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [successMessage, setSuccessMessage] = useState<string | null>(null)
});
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
useEffect(() => {
if (complex) {
@@ -47,28 +43,27 @@ export function ComplexDetailsSection({
city: complex.city ?? '',
state: complex.state ?? '',
country: complex.country ?? '',
})
});
}
}, [complex])
}, [complex]);
const updateMutation = useMutation({
mutationFn: (data: UpdateComplexInput) =>
apiClient.complexes.update(complex!.id, data),
mutationFn: (data: UpdateComplexInput) => apiClient.complexes.update(complex!.id, data),
onSuccess: (updatedComplex) => {
setSuccessMessage('Datos actualizados correctamente.')
setErrorMessage(null)
queryClient.setQueryData(['complex-by-slug', complex?.complexSlug], updatedComplex)
setSuccessMessage('Datos actualizados correctamente.');
setErrorMessage(null);
queryClient.setQueryData(['complex-by-slug', complex?.complexSlug], updatedComplex);
},
onError: (error: Error) => {
setErrorMessage(error.message || 'Error al actualizar los datos.')
setSuccessMessage(null)
setErrorMessage(error.message || 'Error al actualizar los datos.');
setSuccessMessage(null);
},
})
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
setErrorMessage(null)
setSuccessMessage(null)
e.preventDefault();
setErrorMessage(null);
setSuccessMessage(null);
const result = updateComplexSchema.safeParse({
complexName: formValues.complexName,
@@ -76,15 +71,15 @@ export function ComplexDetailsSection({
city: formValues.city || null,
state: formValues.state || null,
country: formValues.country || null,
})
});
if (!result.success) {
setErrorMessage(result.error.issues[0]?.message || 'Datos inválidos.')
return
setErrorMessage(result.error.issues[0]?.message || 'Datos inválidos.');
return;
}
updateMutation.mutate(result.data)
}
updateMutation.mutate(result.data);
};
if (isLoading) {
return (
@@ -96,7 +91,7 @@ export function ComplexDetailsSection({
<Separator className="my-4" />
<p className="text-sm text-muted-foreground">Cargando...</p>
</section>
)
);
}
if (isError || !complex) {
@@ -107,11 +102,9 @@ export function ComplexDetailsSection({
<h3 className="text-lg font-medium">Datos del complejo</h3>
</div>
<Separator className="my-4" />
<p className="text-sm text-destructive">
No se pudieron cargar los datos del complejo.
</p>
<p className="text-sm text-destructive">No se pudieron cargar los datos del complejo.</p>
</section>
)
);
}
return (
@@ -129,9 +122,7 @@ export function ComplexDetailsSection({
id="complex-name"
type="text"
value={formValues.complexName}
onChange={(e) =>
setFormValues((prev) => ({ ...prev, complexName: e.target.value }))
}
onChange={(e) => setFormValues((prev) => ({ ...prev, complexName: e.target.value }))}
/>
</Field>
@@ -156,9 +147,7 @@ export function ComplexDetailsSection({
type="text"
placeholder="Ej: Salta"
value={formValues.city}
onChange={(e) =>
setFormValues((prev) => ({ ...prev, city: e.target.value }))
}
onChange={(e) => setFormValues((prev) => ({ ...prev, city: e.target.value }))}
/>
</Field>
@@ -169,9 +158,7 @@ export function ComplexDetailsSection({
type="text"
placeholder="Ej: Salta"
value={formValues.state}
onChange={(e) =>
setFormValues((prev) => ({ ...prev, state: e.target.value }))
}
onChange={(e) => setFormValues((prev) => ({ ...prev, state: e.target.value }))}
/>
</Field>
@@ -182,32 +169,23 @@ export function ComplexDetailsSection({
type="text"
placeholder="Ej: Argentina"
value={formValues.country}
onChange={(e) =>
setFormValues((prev) => ({ ...prev, country: e.target.value }))
}
onChange={(e) => setFormValues((prev) => ({ ...prev, country: e.target.value }))}
/>
</Field>
</div>
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
{successMessage && (
<p className="text-sm text-green-600 dark:text-green-400">
{successMessage}
</p>
<p className="text-sm text-green-600 dark:text-green-400">{successMessage}</p>
)}
<div className="flex justify-end">
<Button
type="submit"
disabled={updateMutation.isPending}
>
{updateMutation.isPending && (
<Loader2 className="mr-2 size-4 animate-spin" />
)}
<Button type="submit" disabled={updateMutation.isPending}>
{updateMutation.isPending && <Loader2 className="mr-2 size-4 animate-spin" />}
Guardar cambios
</Button>
</div>
</form>
</section>
)
);
}

View File

@@ -1,9 +1,4 @@
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 { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
@@ -11,26 +6,31 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
} 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'
} 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
}
complexId: string | null;
editingCourt: Court | null;
initialDraft: CreateCourtInput | null;
onCancelEdit: () => void;
};
export function CourtFormSection({
complexId,
@@ -38,67 +38,67 @@ export function CourtFormSection({
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 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)
return apiClient.courts.update(editingCourt.id, payload);
}
if (!complexId) {
throw new ApiClientError('No se pudo resolver el complejo para guardar la cancha.')
throw new ApiClientError('No se pudo resolver el complejo para guardar la cancha.');
}
return apiClient.courts.create(complexId, payload)
return apiClient.courts.create(complexId, payload);
},
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ['courts', complexId],
})
setFormError(null)
});
setFormError(null);
if (editingCourt) {
onCancelEdit()
onCancelEdit();
}
form.reset(createDefaultCourtValues())
form.reset(createDefaultCourtValues());
},
onError: (error) => {
const message =
error instanceof ApiClientError
? error.message
: 'No se pudo guardar la cancha. Intenta nuevamente.'
setFormError(message)
: 'No se pudo guardar la cancha. Intenta nuevamente.';
setFormError(message);
},
})
});
const activeSports = useMemo(
() => (sportsQuery.data ?? []).filter((sport) => sport.isActive),
[sportsQuery.data],
)
[sportsQuery.data]
);
const onSubmit = async (values: CreateCourtInput) => {
setFormError(null)
await saveMutation.mutateAsync(values)
}
setFormError(null);
await saveMutation.mutateAsync(values);
};
useEffect(() => {
if (editingCourt) {
@@ -112,33 +112,33 @@ export function CourtFormSection({
startTime: slot.startTime,
endTime: slot.endTime,
})),
})
return
});
return;
}
if (initialDraft) {
form.reset(initialDraft)
return
form.reset(initialDraft);
return;
}
form.reset(createDefaultCourtValues())
}, [editingCourt, form, initialDraft])
form.reset(createDefaultCourtValues());
}, [editingCourt, form, initialDraft]);
const title = editingCourt ? 'Editar cancha' : 'Nueva cancha'
const buttonText = editingCourt ? 'Guardar cambios' : 'Agregar cancha'
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.
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)
void onSubmit(values);
})}
>
<Field data-invalid={Boolean(form.formState.errors.name)}>
@@ -211,9 +211,7 @@ export function CourtFormSection({
<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>
<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
@@ -221,11 +219,11 @@ export function CourtFormSection({
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)
const availability = form.getValues('availability');
setWeekDefaultStartTime(availability[0]?.startTime ?? '08:00');
setWeekDefaultEndTime(availability[0]?.endTime ?? '22:00');
setWeekModalError(null);
setIsWeekModalOpen(true);
}}
>
Agregar semana completa
@@ -239,7 +237,7 @@ export function CourtFormSection({
dayOfWeek: 'MONDAY',
startTime: '08:00',
endTime: '22:00',
})
});
}}
>
Agregar rango
@@ -307,7 +305,7 @@ export function CourtFormSection({
className="w-full md:w-auto"
disabled={availabilityFieldArray.fields.length === 1}
onClick={() => {
availabilityFieldArray.remove(index)
availabilityFieldArray.remove(index);
}}
>
Quitar
@@ -338,9 +336,9 @@ export function CourtFormSection({
<Dialog
open={isWeekModalOpen}
onOpenChange={(nextOpen) => {
setIsWeekModalOpen(nextOpen)
setIsWeekModalOpen(nextOpen);
if (!nextOpen) {
setWeekModalError(null)
setWeekModalError(null);
}
}}
>
@@ -360,8 +358,8 @@ export function CourtFormSection({
type="time"
value={weekDefaultStartTime}
onChange={(event) => {
setWeekDefaultStartTime(event.target.value)
setWeekModalError(null)
setWeekDefaultStartTime(event.target.value);
setWeekModalError(null);
}}
/>
</Field>
@@ -373,8 +371,8 @@ export function CourtFormSection({
type="time"
value={weekDefaultEndTime}
onChange={(event) => {
setWeekDefaultEndTime(event.target.value)
setWeekModalError(null)
setWeekDefaultEndTime(event.target.value);
setWeekModalError(null);
}}
/>
</Field>
@@ -387,8 +385,8 @@ export function CourtFormSection({
type="button"
variant="outline"
onClick={() => {
setIsWeekModalOpen(false)
setWeekModalError(null)
setIsWeekModalOpen(false);
setWeekModalError(null);
}}
>
Cancelar
@@ -397,29 +395,27 @@ export function CourtFormSection({
type="button"
onClick={() => {
if (weekDefaultStartTime >= weekDefaultEndTime) {
setWeekModalError(
'El horario de inicio debe ser menor al horario de fin.',
)
return
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 availability = form.getValues('availability');
const existingDays = new Set(availability.map((item) => item.dayOfWeek));
const missingDays = DAY_OPTIONS.filter(
(option) => !existingDays.has(option.value),
(option) => !existingDays.has(option.value)
).map((option) => ({
dayOfWeek: option.value,
startTime: weekDefaultStartTime,
endTime: weekDefaultEndTime,
}))
}));
if (missingDays.length > 0) {
availabilityFieldArray.append(missingDays)
availabilityFieldArray.append(missingDays);
}
setIsWeekModalOpen(false)
setWeekModalError(null)
setIsWeekModalOpen(false);
setWeekModalError(null);
}}
>
Agregar días faltantes
@@ -428,5 +424,5 @@ export function CourtFormSection({
</DialogContent>
</Dialog>
</section>
)
);
}

View File

@@ -1,14 +1,14 @@
import type { Court } from '@repo/api-contract'
import { Button } from '@/components/ui/button'
import { formatCurrency, summarizeAvailability } from '../lib/court.utils'
import { Button } from '@/components/ui/button';
import type { Court } from '@repo/api-contract';
import { formatCurrency, summarizeAvailability } from '../lib/court.utils';
type CourtListSectionProps = {
courts: Court[]
isLoading: boolean
isError: boolean
onDuplicateCourt: (court: Court) => void
onEditCourt: (court: Court) => void
}
courts: Court[];
isLoading: boolean;
isError: boolean;
onDuplicateCourt: (court: Court) => void;
onEditCourt: (court: Court) => void;
};
export function CourtListSection({
courts,
@@ -52,7 +52,7 @@ export function CourtListSection({
variant="outline"
className="w-full sm:w-auto"
onClick={() => {
onDuplicateCourt(court)
onDuplicateCourt(court);
}}
>
Duplicar cancha
@@ -62,7 +62,7 @@ export function CourtListSection({
variant="outline"
className="w-full sm:w-auto"
onClick={() => {
onEditCourt(court)
onEditCourt(court);
}}
>
Editar
@@ -84,7 +84,7 @@ export function CourtListSection({
variant="outline"
className="w-full"
onClick={() => {
onDuplicateCourt(court)
onDuplicateCourt(court);
}}
>
Duplicar cancha
@@ -94,7 +94,7 @@ export function CourtListSection({
variant="outline"
className="w-full"
onClick={() => {
onEditCourt(court)
onEditCourt(court);
}}
>
Editar
@@ -104,5 +104,5 @@ export function CourtListSection({
))}
</div>
</section>
)
);
}