feat: enhance public booking page with improved layout and date formatting
- Added new date formatting function for better user experience. - Updated layout of the public booking page for improved responsiveness and aesthetics. - Integrated sport selection with visual icons for better clarity. - Enhanced error handling and loading states for availability queries. feat: revamp select complex page for better user interaction - Redesigned the select complex page layout for a more modern look. - Added contextual information about complex selection. - Improved button interactions with loading indicators and icons. style: update CSS variables for a cohesive sports-club theme - Adjusted color variables to reflect a sports-club identity. - Enhanced background styles for both light and dark modes. - Improved overall styling consistency across components.
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { StatusBadge } from '@/components/ui/status-badge';
|
||||
import { ApiClientError, apiClient } from '@/lib/api-client';
|
||||
import { useCurrentComplexStore } from '@/lib/stores/current-complex-store';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@@ -78,9 +79,7 @@ function formatDateLabel(dateIso: string, todayIso: string) {
|
||||
const tmrw = new Date(tYear, tMonth - 1, tDay);
|
||||
tmrw.setDate(tmrw.getDate() + 1);
|
||||
const isTomorrow =
|
||||
year === tmrw.getFullYear() &&
|
||||
month === tmrw.getMonth() + 1 &&
|
||||
day === tmrw.getDate();
|
||||
year === tmrw.getFullYear() && month === tmrw.getMonth() + 1 && day === tmrw.getDate();
|
||||
|
||||
if (isToday) return 'Hoy';
|
||||
if (isTomorrow) return 'Mañana';
|
||||
@@ -340,6 +339,24 @@ export function HomePage() {
|
||||
return [...groups.entries()];
|
||||
}, [bookingsQuery.data?.bookings]);
|
||||
|
||||
const dashboardStats = useMemo(() => {
|
||||
const bookings = bookingsQuery.data?.bookings ?? [];
|
||||
const todayBookings = bookings.filter((booking) => booking.date === todayIso);
|
||||
const confirmedBookings = bookings.filter(
|
||||
(booking) => getEffectiveStatus(booking) === 'CONFIRMED'
|
||||
);
|
||||
const completedBookings = bookings.filter(
|
||||
(booking) => getEffectiveStatus(booking) === 'COMPLETED'
|
||||
);
|
||||
|
||||
return {
|
||||
total: bookings.length,
|
||||
today: todayBookings.length,
|
||||
confirmed: confirmedBookings.length,
|
||||
completed: completedBookings.length,
|
||||
};
|
||||
}, [bookingsQuery.data?.bookings, todayIso]);
|
||||
|
||||
const updateStatusMutation = useMutation({
|
||||
mutationFn: (payload: { bookingId: string; status: 'CANCELLED' | 'COMPLETED' }) =>
|
||||
apiClient.adminBookings.updateStatus(payload.bookingId, { status: payload.status }),
|
||||
@@ -439,373 +456,427 @@ export function HomePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-6 py-6">
|
||||
<section className="rounded-xl border bg-card p-5 shadow-sm">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Panel de reservas</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Gestiona turnos del día y futuros para {selectedComplex.complexName}.
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<a
|
||||
href={publicBookingUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-muted-foreground underline hover:text-foreground"
|
||||
>
|
||||
{publicBookingUrl}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyToClipboard}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title="Copiar URL"
|
||||
>
|
||||
{urlCopied ? (
|
||||
<Check className="h-4 w-4 text-emerald-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ResponsiveDialog
|
||||
open={isManualBookingOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsManualBookingOpen(open);
|
||||
if (!open) {
|
||||
reset();
|
||||
setManualDate(todayIso);
|
||||
setSelectedSportId(undefined);
|
||||
setSelectedCourtId('');
|
||||
setSelectedStartTime('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ResponsiveDialogTrigger asChild>
|
||||
<Button>Nueva Reserva</Button>
|
||||
</ResponsiveDialogTrigger>
|
||||
<ResponsiveDialogContent
|
||||
className="data-[variant=dialog]:max-w-lg"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<ResponsiveDialogHeader>
|
||||
<ResponsiveDialogTitle>Reserva manual</ResponsiveDialogTitle>
|
||||
<ResponsiveDialogDescription>
|
||||
Crea un turno para atención telefónica o mostrador.
|
||||
</ResponsiveDialogDescription>
|
||||
</ResponsiveDialogHeader>
|
||||
<main className="mx-auto w-full max-w-7xl px-4 py-4 sm:px-6 lg:px-8 lg:py-6">
|
||||
<div className="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
|
||||
<section className="space-y-6">
|
||||
<section className="overflow-hidden rounded-2xl border border-border/70 bg-card p-6 shadow-sm sm:p-8">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-4">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-border/70 bg-background px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||
<span className="size-2 rounded-full bg-primary" />
|
||||
Panel operativo
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-muted-foreground">
|
||||
{selectedComplex.complexSlug}
|
||||
</p>
|
||||
<h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">
|
||||
Panel de reservas
|
||||
</h1>
|
||||
<p className="max-w-2xl text-sm text-muted-foreground sm:text-base">
|
||||
Gestioná turnos del día y futuros para {selectedComplex.complexName}.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="rounded-full border border-border/70 bg-background px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||
{dashboardStats.today} hoy
|
||||
</span>
|
||||
<span className="rounded-full border border-border/70 bg-background px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||
{dashboardStats.confirmed} confirmadas
|
||||
</span>
|
||||
<span className="rounded-full border border-border/70 bg-background px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||
{dashboardStats.completed} cumplidas
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
id="manual-booking-form"
|
||||
className="grid gap-4"
|
||||
onSubmit={handleSubmit(onSubmitManualBooking)}
|
||||
>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="manualDate">Fecha</FieldLabel>
|
||||
<DatePicker
|
||||
value={fromIsoDateLocal(manualDate)}
|
||||
onChange={(date) => {
|
||||
const isoDate = date ? toIsoDateLocal(date) : todayIso;
|
||||
setManualDate(isoDate);
|
||||
setSelectedCourtId('');
|
||||
setSelectedStartTime('');
|
||||
}}
|
||||
minDate={new Date()}
|
||||
placeholder="Selecciona una fecha"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{manualAvailabilityQuery.data?.sportSelectionRequired && (
|
||||
<Field>
|
||||
<FieldLabel>Deporte</FieldLabel>
|
||||
<Select
|
||||
value={selectedSportId ?? ''}
|
||||
onValueChange={(value) => {
|
||||
setSelectedSportId(value);
|
||||
setSelectedCourtId('');
|
||||
setSelectedStartTime('');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecciona un deporte" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{manualAvailabilityQuery.data?.sports.map((sport) => (
|
||||
<SelectItem key={sport.id} value={sport.id}>
|
||||
{sport.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Cancha</FieldLabel>
|
||||
<Select
|
||||
value={selectedCourtId}
|
||||
onValueChange={(value) => {
|
||||
setSelectedCourtId(value);
|
||||
setSelectedStartTime('');
|
||||
}}
|
||||
<div className="w-full max-w-md space-y-3">
|
||||
<div className="rounded-2xl border border-border/70 bg-background p-4">
|
||||
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">
|
||||
URL pública
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<a
|
||||
href={publicBookingUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="min-w-0 truncate text-sm text-foreground underline-offset-4 hover:underline"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecciona una cancha" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(manualAvailabilityQuery.data?.courts ?? []).map((court) => (
|
||||
<SelectItem key={court.courtId} value={court.courtId}>
|
||||
{court.courtName} · {court.sport.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Horario</FieldLabel>
|
||||
<Select value={selectedStartTime} onValueChange={setSelectedStartTime}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecciona un horario" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(selectedCourt?.availableSlots ?? []).map((slot) => {
|
||||
const isToday = manualDate === todayIso;
|
||||
const currentTime = new Date();
|
||||
const currentHours = currentTime.getHours().toString().padStart(2, '0');
|
||||
const currentMinutes = currentTime
|
||||
.getMinutes()
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
const currentTimeStr = `${currentHours}:${currentMinutes}`;
|
||||
const isPastSlot = isToday && slot.startTime <= currentTimeStr;
|
||||
if (isPastSlot) return null;
|
||||
return (
|
||||
<SelectItem key={slot.startTime} value={slot.startTime}>
|
||||
{slot.startTime}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{publicBookingUrl}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyToClipboard}
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
title="Copiar URL"
|
||||
>
|
||||
{urlCopied ? (
|
||||
<Check className="h-4 w-4 text-emerald-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{manualAvailabilityQuery.isLoading && (
|
||||
<p className="text-sm text-muted-foreground">Cargando horarios disponibles...</p>
|
||||
)}
|
||||
|
||||
{manualAvailabilityQuery.isError && (
|
||||
<p className="text-sm text-destructive">
|
||||
{extractMessage(
|
||||
manualAvailabilityQuery.error,
|
||||
'No pudimos cargar disponibilidad para la reserva manual.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Field data-invalid={Boolean(errors.customerName)}>
|
||||
<FieldLabel htmlFor="customerName">Nombre</FieldLabel>
|
||||
<Input
|
||||
id="customerName"
|
||||
placeholder="Ej: Juan Perez"
|
||||
aria-invalid={Boolean(errors.customerName)}
|
||||
{...register('customerName')}
|
||||
/>
|
||||
<FieldError errors={[errors.customerName]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errors.customerPhone)}>
|
||||
<FieldLabel htmlFor="customerPhone">Teléfono</FieldLabel>
|
||||
<Input
|
||||
id="customerPhone"
|
||||
type="tel"
|
||||
placeholder="Ej: 3875551234"
|
||||
aria-invalid={Boolean(errors.customerPhone)}
|
||||
{...register('customerPhone')}
|
||||
/>
|
||||
<FieldError errors={[errors.customerPhone]} />
|
||||
</Field>
|
||||
|
||||
{createManualBookingMutation.isError && (
|
||||
<p className="text-sm text-destructive">
|
||||
{extractMessage(
|
||||
createManualBookingMutation.error,
|
||||
'No pudimos crear la reserva manual.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<ResponsiveDialogFooter>
|
||||
<ResponsiveDialogClose asChild>
|
||||
<Button variant="outline">Cancelar</Button>
|
||||
</ResponsiveDialogClose>
|
||||
<Button
|
||||
type="submit"
|
||||
form="manual-booking-form"
|
||||
disabled={
|
||||
!isValid ||
|
||||
createManualBookingMutation.isPending ||
|
||||
!selectedCourtId ||
|
||||
!selectedStartTime
|
||||
}
|
||||
<ResponsiveDialog
|
||||
open={isManualBookingOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsManualBookingOpen(open);
|
||||
if (!open) {
|
||||
reset();
|
||||
setManualDate(todayIso);
|
||||
setSelectedSportId(undefined);
|
||||
setSelectedCourtId('');
|
||||
setSelectedStartTime('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{createManualBookingMutation.isPending ? 'Guardando...' : 'Crear reserva'}
|
||||
</Button>
|
||||
</ResponsiveDialogFooter>
|
||||
</ResponsiveDialogContent>
|
||||
</ResponsiveDialog>
|
||||
</div>
|
||||
</section>
|
||||
<ResponsiveDialogTrigger asChild>
|
||||
<Button className="w-full">Nueva reserva manual</Button>
|
||||
</ResponsiveDialogTrigger>
|
||||
<ResponsiveDialogContent
|
||||
className="data-[variant=dialog]:max-w-lg"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<ResponsiveDialogHeader>
|
||||
<ResponsiveDialogTitle>Reserva manual</ResponsiveDialogTitle>
|
||||
<ResponsiveDialogDescription>
|
||||
Crea un turno para atención telefónica o mostrador.
|
||||
</ResponsiveDialogDescription>
|
||||
</ResponsiveDialogHeader>
|
||||
|
||||
<section className="rounded-xl border bg-card p-5 shadow-sm">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="fromDate">Mostrar desde</FieldLabel>
|
||||
<DatePicker
|
||||
value={fromIsoDateLocal(fromDate)}
|
||||
onChange={(date) => {
|
||||
const isoDate = date ? toIsoDateLocal(date) : todayIso;
|
||||
setFromDate(isoDate);
|
||||
}}
|
||||
disabled={(date) => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const checkDate = new Date(date);
|
||||
checkDate.setHours(0, 0, 0, 0);
|
||||
return checkDate < today;
|
||||
}}
|
||||
placeholder="Selecciona una fecha"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{bookingsQuery.isLoading && (
|
||||
<p className="mt-4 text-sm text-muted-foreground">Cargando reservas...</p>
|
||||
)}
|
||||
|
||||
{bookingsQuery.isError && (
|
||||
<p className="mt-4 text-sm text-destructive">
|
||||
{extractMessage(bookingsQuery.error, 'No pudimos cargar las reservas.')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!bookingsQuery.isLoading && !bookingsQuery.isError && groupedBookings.length === 0 && (
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
No hay reservas para la fecha seleccionada en adelante.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
{groupedBookings.map(([date, bookings]) => (
|
||||
<article key={date} className="rounded-lg border bg-background p-3">
|
||||
<h2 className="text-sm font-semibold">{formatDateLabel(date, todayIso)}</h2>
|
||||
<div className="mt-3 space-y-2">
|
||||
{bookings.map((booking) => {
|
||||
const effectiveStatus = getEffectiveStatus(booking);
|
||||
const canManage =
|
||||
booking.status === 'CONFIRMED' && effectiveStatus === 'CONFIRMED';
|
||||
const isMutating = updateStatusMutation.isPending;
|
||||
|
||||
const statusColorMap: Record<string, string> = {
|
||||
CONFIRMED: 'border-l-sky-500 dark:border-l-sky-400',
|
||||
COMPLETED: 'border-l-emerald-500 dark:border-l-emerald-400',
|
||||
CANCELLED: 'border-l-rose-500 dark:border-l-rose-400',
|
||||
NO_SHOW: 'border-l-orange-500 dark:border-l-orange-400',
|
||||
};
|
||||
|
||||
const statusBgMap: Record<string, string> = {
|
||||
CONFIRMED: 'bg-sky-50/30 dark:bg-sky-950/20',
|
||||
COMPLETED: 'bg-emerald-50/30 dark:bg-emerald-950/20',
|
||||
CANCELLED: 'bg-rose-50/30 dark:bg-rose-950/20',
|
||||
NO_SHOW: 'bg-orange-50/30 dark:bg-orange-950/20',
|
||||
};
|
||||
|
||||
const colorClass = statusColorMap[effectiveStatus] || '';
|
||||
const bgClass = statusBgMap[effectiveStatus] || '';
|
||||
|
||||
const badgeColorMap: Record<string, string> = {
|
||||
CONFIRMED: 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-800 dark:bg-sky-950 dark:text-sky-300',
|
||||
COMPLETED: 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-300',
|
||||
CANCELLED: 'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-800 dark:bg-rose-950 dark:text-rose-300',
|
||||
NO_SHOW: 'border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-800 dark:bg-orange-950 dark:text-orange-300',
|
||||
};
|
||||
|
||||
const badgeClass = badgeColorMap[effectiveStatus] || '';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={booking.id}
|
||||
className={`flex flex-col gap-2 rounded-md border border-l-[3px] p-3 sm:flex-row sm:items-center sm:justify-between ${colorClass} ${bgClass}`}
|
||||
<form
|
||||
id="manual-booking-form"
|
||||
className="grid gap-4"
|
||||
onSubmit={handleSubmit(onSubmitManualBooking)}
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{booking.startTime} - {booking.endTime} · {booking.courtName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{booking.customerName} ({booking.customerPhone}) · Código{' '}
|
||||
{booking.bookingCode}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="manualDate">Fecha</FieldLabel>
|
||||
<DatePicker
|
||||
value={fromIsoDateLocal(manualDate)}
|
||||
onChange={(date) => {
|
||||
const isoDate = date ? toIsoDateLocal(date) : todayIso;
|
||||
setManualDate(isoDate);
|
||||
setSelectedCourtId('');
|
||||
setSelectedStartTime('');
|
||||
}}
|
||||
minDate={new Date()}
|
||||
placeholder="Selecciona una fecha"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={`hidden sm:inline-flex rounded-full border px-2 py-1 text-xs font-medium ${badgeClass}`}
|
||||
>
|
||||
{effectiveStatusLabel(effectiveStatus)}
|
||||
</span>
|
||||
|
||||
{canManage && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isMutating}
|
||||
onClick={() => {
|
||||
updateStatusMutation.mutate({
|
||||
bookingId: booking.id,
|
||||
status: 'COMPLETED',
|
||||
});
|
||||
{manualAvailabilityQuery.data?.sportSelectionRequired && (
|
||||
<Field>
|
||||
<FieldLabel>Deporte</FieldLabel>
|
||||
<Select
|
||||
value={selectedSportId ?? ''}
|
||||
onValueChange={(value) => {
|
||||
setSelectedSportId(value);
|
||||
setSelectedCourtId('');
|
||||
setSelectedStartTime('');
|
||||
}}
|
||||
>
|
||||
Marcar cumplida
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={isMutating}
|
||||
onClick={() => {
|
||||
updateStatusMutation.mutate({
|
||||
bookingId: booking.id,
|
||||
status: 'CANCELLED',
|
||||
});
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
</>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecciona un deporte" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{manualAvailabilityQuery.data?.sports.map((sport) => (
|
||||
<SelectItem key={sport.id} value={sport.id}>
|
||||
{sport.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{updateStatusMutation.isError && (
|
||||
<p className="mt-4 text-sm text-destructive">
|
||||
{extractMessage(updateStatusMutation.error, 'No pudimos actualizar la reserva.')}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
<Field>
|
||||
<FieldLabel>Cancha</FieldLabel>
|
||||
<Select
|
||||
value={selectedCourtId}
|
||||
onValueChange={(value) => {
|
||||
setSelectedCourtId(value);
|
||||
setSelectedStartTime('');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecciona una cancha" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(manualAvailabilityQuery.data?.courts ?? []).map((court) => (
|
||||
<SelectItem key={court.courtId} value={court.courtId}>
|
||||
{court.courtName} · {court.sport.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Horario</FieldLabel>
|
||||
<Select value={selectedStartTime} onValueChange={setSelectedStartTime}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecciona un horario" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(selectedCourt?.availableSlots ?? []).map((slot) => {
|
||||
const isToday = manualDate === todayIso;
|
||||
const currentTime = new Date();
|
||||
const currentHours = currentTime
|
||||
.getHours()
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
const currentMinutes = currentTime
|
||||
.getMinutes()
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
const currentTimeStr = `${currentHours}:${currentMinutes}`;
|
||||
const isPastSlot = isToday && slot.startTime <= currentTimeStr;
|
||||
if (isPastSlot) return null;
|
||||
return (
|
||||
<SelectItem key={slot.startTime} value={slot.startTime}>
|
||||
{slot.startTime}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{manualAvailabilityQuery.isLoading && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Cargando horarios disponibles...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{manualAvailabilityQuery.isError && (
|
||||
<p className="text-sm text-destructive">
|
||||
{extractMessage(
|
||||
manualAvailabilityQuery.error,
|
||||
'No pudimos cargar disponibilidad para la reserva manual.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Field data-invalid={Boolean(errors.customerName)}>
|
||||
<FieldLabel htmlFor="customerName">Nombre</FieldLabel>
|
||||
<Input
|
||||
id="customerName"
|
||||
placeholder="Ej: Juan Perez"
|
||||
aria-invalid={Boolean(errors.customerName)}
|
||||
{...register('customerName')}
|
||||
/>
|
||||
<FieldError errors={[errors.customerName]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errors.customerPhone)}>
|
||||
<FieldLabel htmlFor="customerPhone">Teléfono</FieldLabel>
|
||||
<Input
|
||||
id="customerPhone"
|
||||
type="tel"
|
||||
placeholder="Ej: 3875551234"
|
||||
aria-invalid={Boolean(errors.customerPhone)}
|
||||
{...register('customerPhone')}
|
||||
/>
|
||||
<FieldError errors={[errors.customerPhone]} />
|
||||
</Field>
|
||||
|
||||
{createManualBookingMutation.isError && (
|
||||
<p className="text-sm text-destructive">
|
||||
{extractMessage(
|
||||
createManualBookingMutation.error,
|
||||
'No pudimos crear la reserva manual.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<ResponsiveDialogFooter>
|
||||
<ResponsiveDialogClose asChild>
|
||||
<Button variant="outline">Cancelar</Button>
|
||||
</ResponsiveDialogClose>
|
||||
<Button
|
||||
type="submit"
|
||||
form="manual-booking-form"
|
||||
disabled={
|
||||
!isValid ||
|
||||
createManualBookingMutation.isPending ||
|
||||
!selectedCourtId ||
|
||||
!selectedStartTime
|
||||
}
|
||||
>
|
||||
{createManualBookingMutation.isPending ? 'Guardando...' : 'Crear reserva'}
|
||||
</Button>
|
||||
</ResponsiveDialogFooter>
|
||||
</ResponsiveDialogContent>
|
||||
</ResponsiveDialog>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-border/70 bg-card p-5 shadow-sm sm:p-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="fromDate">Mostrar desde</FieldLabel>
|
||||
<DatePicker
|
||||
value={fromIsoDateLocal(fromDate)}
|
||||
onChange={(date) => {
|
||||
const isoDate = date ? toIsoDateLocal(date) : todayIso;
|
||||
setFromDate(isoDate);
|
||||
}}
|
||||
disabled={(date) => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const checkDate = new Date(date);
|
||||
checkDate.setHours(0, 0, 0, 0);
|
||||
return checkDate < today;
|
||||
}}
|
||||
placeholder="Selecciona una fecha"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{bookingsQuery.isLoading && (
|
||||
<p className="mt-4 text-sm text-muted-foreground">Cargando reservas...</p>
|
||||
)}
|
||||
|
||||
{bookingsQuery.isError && (
|
||||
<p className="mt-4 text-sm text-destructive">
|
||||
{extractMessage(bookingsQuery.error, 'No pudimos cargar las reservas.')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!bookingsQuery.isLoading && !bookingsQuery.isError && groupedBookings.length === 0 && (
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
No hay reservas para la fecha seleccionada en adelante.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
{groupedBookings.map(([date, bookings]) => (
|
||||
<article
|
||||
key={date}
|
||||
className="rounded-2xl border border-border/70 bg-background p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-semibold capitalize">
|
||||
{formatDateLabel(date, todayIso)}
|
||||
</h2>
|
||||
<span className="rounded-full border border-border/70 bg-card px-3 py-1 text-[11px] font-medium text-muted-foreground">
|
||||
{bookings.length} reservas
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 space-y-3">
|
||||
{bookings.map((booking) => {
|
||||
const effectiveStatus = getEffectiveStatus(booking);
|
||||
const canManage =
|
||||
booking.status === 'CONFIRMED' && effectiveStatus === 'CONFIRMED';
|
||||
const isMutating = updateStatusMutation.isPending;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={booking.id}
|
||||
className="flex flex-col gap-3 rounded-2xl border border-border/70 bg-card p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{booking.startTime} - {booking.endTime} · {booking.courtName}
|
||||
</p>
|
||||
<StatusBadge
|
||||
status={effectiveStatus}
|
||||
label={effectiveStatusLabel(effectiveStatus)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{booking.customerName} ({booking.customerPhone}) · Código{' '}
|
||||
{booking.bookingCode}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{canManage && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isMutating}
|
||||
onClick={() => {
|
||||
updateStatusMutation.mutate({
|
||||
bookingId: booking.id,
|
||||
status: 'COMPLETED',
|
||||
});
|
||||
}}
|
||||
>
|
||||
Marcar cumplida
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={isMutating}
|
||||
onClick={() => {
|
||||
updateStatusMutation.mutate({
|
||||
bookingId: booking.id,
|
||||
status: 'CANCELLED',
|
||||
});
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{updateStatusMutation.isError && (
|
||||
<p className="mt-4 text-sm text-destructive">
|
||||
{extractMessage(updateStatusMutation.error, 'No pudimos actualizar la reserva.')}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<aside className="space-y-6 xl:sticky xl:top-24 h-fit self-start">
|
||||
<section className="rounded-2xl border border-border/70 bg-card p-5 shadow-sm sm:p-6">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||
Lectura rápida
|
||||
</h2>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
|
||||
<div className="rounded-2xl border border-border/70 bg-background p-4">
|
||||
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">Total</p>
|
||||
<p className="mt-2 text-2xl font-semibold">{dashboardStats.total}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/70 bg-background p-4">
|
||||
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">Hoy</p>
|
||||
<p className="mt-2 text-2xl font-semibold">{dashboardStats.today}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/70 bg-background p-4">
|
||||
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">
|
||||
Confirmadas
|
||||
</p>
|
||||
<p className="mt-2 text-2xl font-semibold">{dashboardStats.confirmed}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/70 bg-background p-4">
|
||||
<p className="text-xs uppercase tracking-[0.24em] text-muted-foreground">
|
||||
Cumplidas
|
||||
</p>
|
||||
<p className="mt-2 text-2xl font-semibold">{dashboardStats.completed}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user