Better court configuration

This commit is contained in:
Jose Selesan
2026-04-09 09:38:21 -03:00
parent 8a01606c80
commit 30ac0d569a
4 changed files with 350 additions and 44 deletions

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
<title>PlayZer</title>
</head>
<body>
<div id="root"></div>

View File

@@ -0,0 +1,166 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -9,6 +9,14 @@ import {
} 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 {
@@ -134,16 +142,22 @@ function createDefaultValues(): CreateCourtInput {
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'],
@@ -201,11 +215,7 @@ function CourtFormSection({
}
useEffect(() => {
if (!editingCourt) {
form.reset(createDefaultValues())
return
}
if (editingCourt) {
form.reset({
name: editingCourt.name,
sportId: editingCourt.sportId,
@@ -217,7 +227,16 @@ function CourtFormSection({
endTime: slot.endTime,
})),
})
}, [editingCourt, form])
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'
@@ -317,23 +336,10 @@ function CourtFormSection({
className="w-full sm:w-auto"
onClick={() => {
const availability = form.getValues('availability')
const baseStartTime = availability[0]?.startTime ?? '08:00'
const baseEndTime = availability[0]?.endTime ?? '22:00'
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: baseStartTime,
endTime: baseEndTime,
}))
if (missingDays.length > 0) {
availabilityFieldArray.append(missingDays)
}
setWeekDefaultStartTime(availability[0]?.startTime ?? '08:00')
setWeekDefaultEndTime(availability[0]?.endTime ?? '22:00')
setWeekModalError(null)
setIsWeekModalOpen(true)
}}
>
Agregar semana completa
@@ -442,6 +448,101 @@ function CourtFormSection({
)}
</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>
)
}
@@ -452,6 +553,7 @@ type ComplexCourtsPageProps = {
export function ComplexCourtsPage({ complexSlug }: ComplexCourtsPageProps) {
const [editingCourt, setEditingCourt] = useState<Court | null>(null)
const [initialDraft, setInitialDraft] = useState<CreateCourtInput | null>(null)
useEffect(() => {
setCurrentComplexSlug(complexSlug)
@@ -484,6 +586,7 @@ export function ComplexCourtsPage({ complexSlug }: ComplexCourtsPageProps) {
<CourtFormSection
complexId={complexId}
editingCourt={editingCourt}
initialDraft={initialDraft}
onCancelEdit={() => {
setEditingCourt(null)
}}
@@ -519,10 +622,35 @@ export function ComplexCourtsPage({ complexSlug }: ComplexCourtsPageProps) {
{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' })
}}
@@ -530,6 +658,7 @@ export function ComplexCourtsPage({ complexSlug }: ComplexCourtsPageProps) {
Editar
</Button>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2 text-xs text-muted-foreground">
{summarizeAvailability(court.availability).map((summary) => (

View File

@@ -1,5 +1,5 @@
import { Link, Outlet, useNavigate } from '@tanstack/react-router'
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { LogOut, Menu, UserRound } from 'lucide-react'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
@@ -44,6 +44,17 @@ export function RootLayout() {
}
}, [currentComplexSlug, myComplexesQuery.data])
const currentComplexName = useMemo(() => {
const complexes = myComplexesQuery.data ?? []
if (complexes.length === 0) return 'Mi complejo'
const selected = complexes.find(
(complex) => complex.complexSlug === currentComplexSlug,
)
return selected?.complexName ?? complexes[0]?.complexName ?? 'Mi complejo'
}, [currentComplexSlug, myComplexesQuery.data])
const handleSignOut = async () => {
await signOut()
await navigate({ to: '/login' })
@@ -53,7 +64,7 @@ export function RootLayout() {
<div className="flex min-h-screen flex-col">
<header className="mx-auto flex w-full max-w-6xl items-center justify-between px-6 py-4">
<div className="flex items-center gap-6">
<h1 className="text-sm font-semibold">TanStack Router</h1>
<h1 className="text-sm font-semibold">{currentComplexName}</h1>
<nav className="hidden items-center gap-3 text-sm md:flex">
<Link
to="/"