From 58c5ce339bf2a20bddc5b30713c2d410473ca151 Mon Sep 17 00:00:00 2001 From: Jose Selesan Date: Thu, 9 Apr 2026 10:01:54 -0300 Subject: [PATCH] Code refactoring --- .../features/complex/complex-courts-page.tsx | 657 +----------------- .../complex/components/court-form-section.tsx | 432 ++++++++++++ .../complex/components/court-list-section.tsx | 85 +++ .../features/complex/lib/court.constants.ts | 17 + .../src/features/complex/lib/court.utils.ts | 98 +++ 5 files changed, 663 insertions(+), 626 deletions(-) create mode 100644 apps/frontend/src/features/complex/components/court-form-section.tsx create mode 100644 apps/frontend/src/features/complex/components/court-list-section.tsx create mode 100644 apps/frontend/src/features/complex/lib/court.constants.ts create mode 100644 apps/frontend/src/features/complex/lib/court.utils.ts diff --git a/apps/frontend/src/features/complex/complex-courts-page.tsx b/apps/frontend/src/features/complex/complex-courts-page.tsx index 9bfad5b..88e61ec 100644 --- a/apps/frontend/src/features/complex/complex-courts-page.tsx +++ b/apps/frontend/src/features/complex/complex-courts-page.tsx @@ -1,551 +1,10 @@ -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, - type DayOfWeek, -} 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 { 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' - -const DAY_OPTIONS: Array<{ value: DayOfWeek; label: string }> = [ - { value: 'MONDAY', label: 'Lunes' }, - { value: 'TUESDAY', label: 'Martes' }, - { value: 'WEDNESDAY', label: 'Miércoles' }, - { value: 'THURSDAY', label: 'Jueves' }, - { value: 'FRIDAY', label: 'Viernes' }, - { value: 'SATURDAY', label: 'Sábado' }, - { value: 'SUNDAY', label: 'Domingo' }, -] -const DAY_ORDER: DayOfWeek[] = DAY_OPTIONS.map((option) => option.value) -const DAY_LABEL_BY_VALUE = new Map( - DAY_OPTIONS.map((option) => [option.value, option.label]), -) - -function formatTimeCompact(value: string): string { - const [rawHours = '00', rawMinutes = '00'] = value.split(':') - const hours = Number(rawHours) - return `${hours}:${rawMinutes}` -} - -function formatDayRange(dayIndexes: number[]): string { - 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] - - for (let index = 1; index < sorted.length; index += 1) { - const current = sorted[index] - - if (current === previous + 1) { - previous = current - continue - } - - chunks.push({ start, end: previous }) - start = current - previous = current - } - - 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 - - if (!startLabel || !endLabel) return '' - if (chunk.start === chunk.end) return startLabel - return `${startLabel} a ${endLabel}` - }) - .filter(Boolean) - .join(', ') -} - -function summarizeAvailability( - availability: Array<{ - dayOfWeek: DayOfWeek - startTime: string - endTime: string - }>, -) { - const grouped = new Map() - - for (const slot of availability) { - 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) - } - - 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)}` - }) - .filter(Boolean) -} - -function formatCurrency(amount: number): string { - return new Intl.NumberFormat('es-AR', { - style: 'currency', - currency: 'ARS', - maximumFractionDigits: 2, - }).format(amount) -} - -function createDefaultValues(): CreateCourtInput { - return { - name: '', - sportId: '', - slotDurationMinutes: 60, - basePrice: 0, - availability: [ - { - dayOfWeek: 'MONDAY', - startTime: '08:00', - endTime: '22:00', - }, - ], - } -} - -type CourtFormSectionProps = { - complexId: string | null - editingCourt: Court | null - initialDraft: CreateCourtInput | null - onCancelEdit: () => void -} - -function CourtFormSection({ - complexId, - editingCourt, - initialDraft, - onCancelEdit, -}: CourtFormSectionProps) { - const queryClient = useQueryClient() - const [formError, setFormError] = useState(null) - const [isWeekModalOpen, setIsWeekModalOpen] = useState(false) - const [weekDefaultStartTime, setWeekDefaultStartTime] = useState('08:00') - const [weekDefaultEndTime, setWeekDefaultEndTime] = useState('22:00') - const [weekModalError, setWeekModalError] = useState(null) - - const sportsQuery = useQuery({ - queryKey: ['sports'], - queryFn: () => apiClient.sports.list(), - }) - - const form = useForm({ - resolver: zodResolver(createCourtSchema), - mode: 'onBlur', - defaultValues: createDefaultValues(), - }) - - 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(createDefaultValues()) - }, - 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(createDefaultValues()) - }, [editingCourt, form, initialDraft]) - - const title = editingCourt ? 'Editar cancha' : 'Nueva cancha' - const buttonText = editingCourt ? 'Guardar cambios' : 'Agregar cancha' - - return ( -
-

{title}

-

- Configura nombre, deporte, duración, precio base y horarios. El backend ya - contempla precios por franja horaria para la siguiente etapa. -

- -
{ - void onSubmit(values) - })} - > - - Nombre o descripción - - - - -
- - Deporte - ( - - )} - /> - - - - - Duración por turno (min) - - - - - - Precio base - - - -
- -
-
-
-

Rangos horarios

-

- Define uno o más rangos por día. -

-
-
- - -
-
- -
- {availabilityFieldArray.fields.map((field, index) => ( -
- - Día - ( - - )} - /> - - - - - Desde - - - - - - Hasta - - - - -
- -
-
- ))} -
-
- - {formError &&

{formError}

} - -
- - {editingCourt && ( - - )} -
-
- - { - setIsWeekModalOpen(nextOpen) - if (!nextOpen) { - setWeekModalError(null) - } - }} - > - - - Horario por defecto - - Ingresa el horario para completar los días faltantes de la semana. - - - -
- - Desde - { - setWeekDefaultStartTime(event.target.value) - setWeekModalError(null) - }} - /> - - - - Hasta - { - setWeekDefaultEndTime(event.target.value) - setWeekModalError(null) - }} - /> - -
- - {weekModalError &&

{weekModalError}

} - - - - - -
-
-
- ) -} +import { CourtFormSection } from './components/court-form-section' +import { CourtListSection } from './components/court-list-section' type ComplexCourtsPageProps = { complexSlug: string @@ -592,85 +51,31 @@ export function ComplexCourtsPage({ complexSlug }: ComplexCourtsPageProps) { }} /> -
-

Canchas configuradas

- - {courtsQuery.isLoading && ( -

Cargando canchas...

- )} - - {courtsQuery.isError && ( -

- No se pudieron cargar las canchas del complejo. -

- )} - - {!courtsQuery.isLoading && (courtsQuery.data?.length ?? 0) === 0 && ( -

- Aún no hay canchas. Crea la primera usando el formulario. -

- )} - -
- {(courtsQuery.data ?? []).map((court) => ( -
-
-
-

{court.name}

-

- {court.sport.name} · {court.slotDurationMinutes} min ·{' '} - {formatCurrency(court.basePrice)} -

-
-
- - -
-
- -
- {summarizeAvailability(court.availability).map((summary) => ( - - {summary} - - ))} -
-
- ))} -
-
+ { + setEditingCourt(null) + setInitialDraft({ + name: `${court.name} - Copy`, + sportId: court.sportId, + slotDurationMinutes: court.slotDurationMinutes, + basePrice: court.basePrice, + availability: court.availability.map((slot) => ({ + dayOfWeek: slot.dayOfWeek, + startTime: slot.startTime, + endTime: slot.endTime, + })), + }) + window.scrollTo({ top: 0, behavior: 'smooth' }) + }} + onEditCourt={(court) => { + setInitialDraft(null) + setEditingCourt(court) + window.scrollTo({ top: 0, behavior: 'smooth' }) + }} + /> ) } diff --git a/apps/frontend/src/features/complex/components/court-form-section.tsx b/apps/frontend/src/features/complex/components/court-form-section.tsx new file mode 100644 index 0000000..81ca125 --- /dev/null +++ b/apps/frontend/src/features/complex/components/court-form-section.tsx @@ -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(null) + const [isWeekModalOpen, setIsWeekModalOpen] = useState(false) + const [weekDefaultStartTime, setWeekDefaultStartTime] = useState('08:00') + const [weekDefaultEndTime, setWeekDefaultEndTime] = useState('22:00') + const [weekModalError, setWeekModalError] = useState(null) + + const sportsQuery = useQuery({ + queryKey: ['sports'], + queryFn: () => apiClient.sports.list(), + }) + + const form = useForm({ + 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 ( +
+

{title}

+

+ Configura nombre, deporte, duración, precio base y horarios. El backend ya + contempla precios por franja horaria para la siguiente etapa. +

+ +
{ + void onSubmit(values) + })} + > + + Nombre o descripción + + + + +
+ + Deporte + ( + + )} + /> + + + + + Duración por turno (min) + + + + + + Precio base + + + +
+ +
+
+
+

Rangos horarios

+

+ Define uno o más rangos por día. +

+
+
+ + +
+
+ +
+ {availabilityFieldArray.fields.map((field, index) => ( +
+ + Día + ( + + )} + /> + + + + + Desde + + + + + + Hasta + + + + +
+ +
+
+ ))} +
+
+ + {formError &&

{formError}

} + +
+ + {editingCourt && ( + + )} +
+
+ + { + setIsWeekModalOpen(nextOpen) + if (!nextOpen) { + setWeekModalError(null) + } + }} + > + + + Horario por defecto + + Ingresa el horario para completar los días faltantes de la semana. + + + +
+ + Desde + { + setWeekDefaultStartTime(event.target.value) + setWeekModalError(null) + }} + /> + + + + Hasta + { + setWeekDefaultEndTime(event.target.value) + setWeekModalError(null) + }} + /> + +
+ + {weekModalError &&

{weekModalError}

} + + + + + +
+
+
+ ) +} diff --git a/apps/frontend/src/features/complex/components/court-list-section.tsx b/apps/frontend/src/features/complex/components/court-list-section.tsx new file mode 100644 index 0000000..653ed4e --- /dev/null +++ b/apps/frontend/src/features/complex/components/court-list-section.tsx @@ -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 ( +
+

Canchas configuradas

+ + {isLoading &&

Cargando canchas...

} + + {isError && ( +

+ No se pudieron cargar las canchas del complejo. +

+ )} + + {!isLoading && courts.length === 0 && ( +

+ Aún no hay canchas. Crea la primera usando el formulario. +

+ )} + +
+ {courts.map((court) => ( +
+
+
+

{court.name}

+

+ {court.sport.name} · {court.slotDurationMinutes} min ·{' '} + {formatCurrency(court.basePrice)} +

+
+
+ + +
+
+ +
+ {summarizeAvailability(court.availability).map((summary) => ( + + {summary} + + ))} +
+
+ ))} +
+
+ ) +} diff --git a/apps/frontend/src/features/complex/lib/court.constants.ts b/apps/frontend/src/features/complex/lib/court.constants.ts new file mode 100644 index 0000000..88d5468 --- /dev/null +++ b/apps/frontend/src/features/complex/lib/court.constants.ts @@ -0,0 +1,17 @@ +import type { DayOfWeek } from '@repo/api-contract' + +export const DAY_OPTIONS: Array<{ value: DayOfWeek; label: string }> = [ + { value: 'MONDAY', label: 'Lunes' }, + { value: 'TUESDAY', label: 'Martes' }, + { value: 'WEDNESDAY', label: 'Miércoles' }, + { value: 'THURSDAY', label: 'Jueves' }, + { 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_LABEL_BY_VALUE = new Map( + DAY_OPTIONS.map((option) => [option.value, option.label]), +) diff --git a/apps/frontend/src/features/complex/lib/court.utils.ts b/apps/frontend/src/features/complex/lib/court.utils.ts new file mode 100644 index 0000000..3ab27cb --- /dev/null +++ b/apps/frontend/src/features/complex/lib/court.utils.ts @@ -0,0 +1,98 @@ +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}` +} + +function formatDayRange(dayIndexes: number[]): string { + 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] + + for (let index = 1; index < sorted.length; index += 1) { + const current = sorted[index] + + if (current === previous + 1) { + previous = current + continue + } + + chunks.push({ start, end: previous }) + start = current + previous = current + } + + 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 + + if (!startLabel || !endLabel) return '' + if (chunk.start === chunk.end) return startLabel + return `${startLabel} a ${endLabel}` + }) + .filter(Boolean) + .join(', ') +} + +export function summarizeAvailability( + availability: Array<{ + dayOfWeek: DayOfWeek + startTime: string + endTime: string + }>, +) { + const grouped = new Map() + + for (const slot of availability) { + 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) + } + + 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)}` + }) + .filter(Boolean) +} + +export function formatCurrency(amount: number): string { + return new Intl.NumberFormat('es-AR', { + style: 'currency', + currency: 'ARS', + maximumFractionDigits: 2, + }).format(amount) +} + +export function createDefaultCourtValues(): CreateCourtInput { + return { + name: '', + sportId: '', + slotDurationMinutes: 60, + basePrice: 0, + availability: [ + { + dayOfWeek: 'MONDAY', + startTime: '08:00', + endTime: '22:00', + }, + ], + } +}