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