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:
@@ -1,35 +1,35 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { type Court, type CreateCourtInput } from '@repo/api-contract'
|
||||
import { apiClient } from '@/lib/api-client'
|
||||
import { setCurrentComplexSlug } from '@/lib/current-complex'
|
||||
import { CourtFormSection } from './components/court-form-section'
|
||||
import { CourtListSection } from './components/court-list-section'
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { setCurrentComplexSlug } from '@/lib/current-complex';
|
||||
import { type Court, type CreateCourtInput } from '@repo/api-contract';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CourtFormSection } from './components/court-form-section';
|
||||
import { CourtListSection } from './components/court-list-section';
|
||||
|
||||
type ComplexCourtsPageProps = {
|
||||
complexSlug: string
|
||||
}
|
||||
complexSlug: string;
|
||||
};
|
||||
|
||||
export function ComplexCourtsPage({ complexSlug }: ComplexCourtsPageProps) {
|
||||
const [editingCourt, setEditingCourt] = useState<Court | null>(null)
|
||||
const [initialDraft, setInitialDraft] = useState<CreateCourtInput | null>(null)
|
||||
const [editingCourt, setEditingCourt] = useState<Court | null>(null);
|
||||
const [initialDraft, setInitialDraft] = useState<CreateCourtInput | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentComplexSlug(complexSlug)
|
||||
}, [complexSlug])
|
||||
setCurrentComplexSlug(complexSlug);
|
||||
}, [complexSlug]);
|
||||
|
||||
const complexQuery = useQuery({
|
||||
queryKey: ['complex-by-slug', complexSlug],
|
||||
queryFn: () => apiClient.complexes.getBySlug(complexSlug),
|
||||
})
|
||||
});
|
||||
|
||||
const complexId = complexQuery.data?.id ?? null
|
||||
const complexId = complexQuery.data?.id ?? null;
|
||||
|
||||
const courtsQuery = useQuery({
|
||||
queryKey: ['courts', complexId],
|
||||
enabled: Boolean(complexId),
|
||||
queryFn: () => apiClient.courts.listByComplex(complexId as string),
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-6xl space-y-6 px-4 py-4 sm:px-6">
|
||||
@@ -47,7 +47,7 @@ export function ComplexCourtsPage({ complexSlug }: ComplexCourtsPageProps) {
|
||||
editingCourt={editingCourt}
|
||||
initialDraft={initialDraft}
|
||||
onCancelEdit={() => {
|
||||
setEditingCourt(null)
|
||||
setEditingCourt(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -56,7 +56,7 @@ export function ComplexCourtsPage({ complexSlug }: ComplexCourtsPageProps) {
|
||||
isLoading={courtsQuery.isLoading}
|
||||
isError={courtsQuery.isError}
|
||||
onDuplicateCourt={(court) => {
|
||||
setEditingCourt(null)
|
||||
setEditingCourt(null);
|
||||
setInitialDraft({
|
||||
name: `${court.name} - Copy`,
|
||||
sportId: court.sportId,
|
||||
@@ -67,15 +67,15 @@ export function ComplexCourtsPage({ complexSlug }: ComplexCourtsPageProps) {
|
||||
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' });
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Building2, MapPin, Settings } from 'lucide-react'
|
||||
import { apiClient } from '@/lib/api-client'
|
||||
import { setCurrentComplexSlug } from '@/lib/current-complex'
|
||||
import { ComplexDetailsSection } from './components/complex-details-section'
|
||||
import { ComplexCourtsSection } from './components/complex-courts-section'
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { setCurrentComplexSlug } from '@/lib/current-complex';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Building2, MapPin, Settings } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ComplexCourtsSection } from './components/complex-courts-section';
|
||||
import { ComplexDetailsSection } from './components/complex-details-section';
|
||||
|
||||
type ComplexSettingsPageProps = {
|
||||
complexSlug: string
|
||||
}
|
||||
complexSlug: string;
|
||||
};
|
||||
|
||||
type TabOption = 'details' | 'courts'
|
||||
type TabOption = 'details' | 'courts';
|
||||
|
||||
export function ComplexSettingsPage({ complexSlug }: ComplexSettingsPageProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabOption>('details')
|
||||
const [activeTab, setActiveTab] = useState<TabOption>('details');
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentComplexSlug(complexSlug)
|
||||
}, [complexSlug])
|
||||
setCurrentComplexSlug(complexSlug);
|
||||
}, [complexSlug]);
|
||||
|
||||
const complexQuery = useQuery({
|
||||
queryKey: ['complex-by-slug', complexSlug],
|
||||
queryFn: () => apiClient.complexes.getBySlug(complexSlug),
|
||||
})
|
||||
});
|
||||
|
||||
const complexId = complexQuery.data?.id ?? null
|
||||
const complexId = complexQuery.data?.id ?? null;
|
||||
|
||||
const tabs = [
|
||||
{ id: 'details' as const, label: 'Datos del complejo', icon: Building2 },
|
||||
{ id: 'courts' as const, label: 'Canchas', icon: MapPin },
|
||||
]
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl px-4 py-4 sm:px-6">
|
||||
@@ -46,8 +46,8 @@ export function ComplexSettingsPage({ complexSlug }: ComplexSettingsPageProps) {
|
||||
<div className="mt-6 grid gap-6 lg:grid-cols-[200px_1fr]">
|
||||
<nav className="flex flex-row gap-1 overflow-x-auto lg:flex-col lg:overflow-visible">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
const isActive = activeTab === tab.id
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
@@ -62,7 +62,7 @@ export function ComplexSettingsPage({ complexSlug }: ComplexSettingsPageProps) {
|
||||
<Icon className="size-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
@@ -75,13 +75,9 @@ export function ComplexSettingsPage({ complexSlug }: ComplexSettingsPageProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'courts' && (
|
||||
<ComplexCourtsSection
|
||||
complexId={complexId}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'courts' && <ComplexCourtsSection complexId={complexId} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DayOfWeek } from '@repo/api-contract'
|
||||
import type { DayOfWeek } from '@repo/api-contract';
|
||||
|
||||
export const DAY_OPTIONS: Array<{ value: DayOfWeek; label: string }> = [
|
||||
{ value: 'MONDAY', label: 'Lunes' },
|
||||
@@ -8,10 +8,10 @@ export const DAY_OPTIONS: Array<{ value: DayOfWeek; label: string }> = [
|
||||
{ value: 'FRIDAY', label: 'Viernes' },
|
||||
{ value: 'SATURDAY', label: 'Sábado' },
|
||||
{ value: 'SUNDAY', label: 'Domingo' },
|
||||
]
|
||||
];
|
||||
|
||||
export const DAY_ORDER: DayOfWeek[] = DAY_OPTIONS.map((option) => option.value)
|
||||
export const DAY_ORDER: DayOfWeek[] = DAY_OPTIONS.map((option) => option.value);
|
||||
|
||||
export const DAY_LABEL_BY_VALUE = new Map(
|
||||
DAY_OPTIONS.map((option) => [option.value, option.label]),
|
||||
)
|
||||
DAY_OPTIONS.map((option) => [option.value, option.label])
|
||||
);
|
||||
|
||||
@@ -1,76 +1,76 @@
|
||||
import type { CreateCourtInput, DayOfWeek } from '@repo/api-contract'
|
||||
import { DAY_LABEL_BY_VALUE, DAY_ORDER } from './court.constants'
|
||||
import type { CreateCourtInput, DayOfWeek } from '@repo/api-contract';
|
||||
import { DAY_LABEL_BY_VALUE, DAY_ORDER } from './court.constants';
|
||||
|
||||
function formatTimeCompact(value: string): string {
|
||||
const [rawHours = '00', rawMinutes = '00'] = value.split(':')
|
||||
const hours = Number(rawHours)
|
||||
return `${hours}:${rawMinutes}`
|
||||
const [rawHours = '00', rawMinutes = '00'] = value.split(':');
|
||||
const hours = Number(rawHours);
|
||||
return `${hours}:${rawMinutes}`;
|
||||
}
|
||||
|
||||
function formatDayRange(dayIndexes: number[]): string {
|
||||
if (dayIndexes.length === 0) return ''
|
||||
if (dayIndexes.length === 0) return '';
|
||||
|
||||
const sorted = [...dayIndexes].sort((a, b) => a - b)
|
||||
const chunks: Array<{ start: number; end: number }> = []
|
||||
let start = sorted[0]
|
||||
let previous = sorted[0]
|
||||
const sorted = [...dayIndexes].sort((a, b) => a - b);
|
||||
const chunks: Array<{ start: number; end: number }> = [];
|
||||
let start = sorted[0];
|
||||
let previous = sorted[0];
|
||||
|
||||
for (let index = 1; index < sorted.length; index += 1) {
|
||||
const current = sorted[index]
|
||||
const current = sorted[index];
|
||||
|
||||
if (current === previous + 1) {
|
||||
previous = current
|
||||
continue
|
||||
previous = current;
|
||||
continue;
|
||||
}
|
||||
|
||||
chunks.push({ start, end: previous })
|
||||
start = current
|
||||
previous = current
|
||||
chunks.push({ start, end: previous });
|
||||
start = current;
|
||||
previous = current;
|
||||
}
|
||||
|
||||
chunks.push({ start, end: previous })
|
||||
chunks.push({ start, end: previous });
|
||||
|
||||
return chunks
|
||||
.map((chunk) => {
|
||||
const startDay = DAY_ORDER[chunk.start]
|
||||
const endDay = DAY_ORDER[chunk.end]
|
||||
const startLabel = startDay ? DAY_LABEL_BY_VALUE.get(startDay) : undefined
|
||||
const endLabel = endDay ? DAY_LABEL_BY_VALUE.get(endDay) : undefined
|
||||
const startDay = DAY_ORDER[chunk.start];
|
||||
const endDay = DAY_ORDER[chunk.end];
|
||||
const startLabel = startDay ? DAY_LABEL_BY_VALUE.get(startDay) : undefined;
|
||||
const endLabel = endDay ? DAY_LABEL_BY_VALUE.get(endDay) : undefined;
|
||||
|
||||
if (!startLabel || !endLabel) return ''
|
||||
if (chunk.start === chunk.end) return startLabel
|
||||
return `${startLabel} a ${endLabel}`
|
||||
if (!startLabel || !endLabel) return '';
|
||||
if (chunk.start === chunk.end) return startLabel;
|
||||
return `${startLabel} a ${endLabel}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
export function summarizeAvailability(
|
||||
availability: Array<{
|
||||
dayOfWeek: DayOfWeek
|
||||
startTime: string
|
||||
endTime: string
|
||||
}>,
|
||||
dayOfWeek: DayOfWeek;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}>
|
||||
) {
|
||||
const grouped = new Map<string, number[]>()
|
||||
const grouped = new Map<string, number[]>();
|
||||
|
||||
for (const slot of availability) {
|
||||
const dayIndex = DAY_ORDER.indexOf(slot.dayOfWeek)
|
||||
if (dayIndex < 0) continue
|
||||
const dayIndex = DAY_ORDER.indexOf(slot.dayOfWeek);
|
||||
if (dayIndex < 0) continue;
|
||||
|
||||
const key = `${slot.startTime}-${slot.endTime}`
|
||||
const current = grouped.get(key) ?? []
|
||||
current.push(dayIndex)
|
||||
grouped.set(key, current)
|
||||
const key = `${slot.startTime}-${slot.endTime}`;
|
||||
const current = grouped.get(key) ?? [];
|
||||
current.push(dayIndex);
|
||||
grouped.set(key, current);
|
||||
}
|
||||
|
||||
return [...grouped.entries()]
|
||||
.map(([timeRange, dayIndexes]) => {
|
||||
const [startTime = '00:00', endTime = '00:00'] = timeRange.split('-')
|
||||
const dayLabel = formatDayRange(dayIndexes)
|
||||
return `${dayLabel} · ${formatTimeCompact(startTime)} a ${formatTimeCompact(endTime)}`
|
||||
const [startTime = '00:00', endTime = '00:00'] = timeRange.split('-');
|
||||
const dayLabel = formatDayRange(dayIndexes);
|
||||
return `${dayLabel} · ${formatTimeCompact(startTime)} a ${formatTimeCompact(endTime)}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function formatCurrency(amount: number): string {
|
||||
@@ -78,7 +78,7 @@ export function formatCurrency(amount: number): string {
|
||||
style: 'currency',
|
||||
currency: 'ARS',
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount)
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
export function createDefaultCourtValues(): CreateCourtInput {
|
||||
@@ -94,5 +94,5 @@ export function createDefaultCourtValues(): CreateCourtInput {
|
||||
endTime: '22:00',
|
||||
},
|
||||
],
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user