refactor: update home page UI to v2 patterns and introduce availability heatmap component

This commit is contained in:
Jose Selesan
2026-04-27 08:46:32 -03:00
parent d6e32f3e84
commit 7a3452c2d1
4 changed files with 543 additions and 465 deletions

View File

@@ -117,50 +117,73 @@
--- ---
## Home Page Patterns ## Home Page Patterns (v2)
### Structure ### Structure
- Header: Title + DatePicker chip + Action button (3-column) - Header + URL Card: 2-column grid (lg:)
- Metrics Grid: 4-column stats with semantic colors - Main: Timeline de reservas
- Next Booking: Prominent card with emerald accent - Sidebar: Stats grid consolidado (sticky)
- Timeline: Grouped by date with "now" indicator
### DatePicker Chip ### URL Card (destacada)
```tsx ```tsx
<div className="flex items-center gap-1 rounded-md border bg-card px-2 py-1"> <div className="group relative overflow-hidden rounded-2xl border border-blue-200/60 bg-blue-50/50 p-5">
<Field> <div className="absolute inset-0 bg-gradient-to-br from-blue-50/80 to-blue-100/30" />
<DatePicker value={...} onChange={...} placeholder="" /> <div className="relative flex items-center justify-between gap-4">
</Field> <div className="min-w-0 flex-1">
{isToday && ( <p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-blue-600/70">
<span className="text-xs font-medium text-emerald-600">Hoy</span> Reserva online
)} </p>
</div> <a href={url} className="mt-1 block truncate text-sm font-medium text-blue-700 underline-offset-2 hover:underline">
``` {url}
</a>
### Metrics Grid </div>
```tsx <button className="relative flex shrink-0 items-center gap-2 rounded-lg bg-blue-500/10 px-3 py-2 text-sm font-medium text-blue-700 transition-colors hover:bg-blue-500/20">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> <Copy className="size-4" />
<div className="rounded-lg border bg-card p-3"> <span>Copiar</span>
<p className="text-xs font-medium text-muted-foreground">Total</p> </button>
<p className="mt-1 text-2xl font-semibold tracking-tight">{value}</p>
</div> </div>
// ... confirmed (emerald), completed (sky), no-show (orange)
</div> </div>
``` ```
### Timeline Item ### Stats Pills (header)
```tsx
<span className="rounded-full border border-emerald-200/60 bg-emerald-50/60 px-3 py-1 text-xs font-medium text-emerald-700">
{count} hoy
</span>
```
### Stats Sidebar (grid 2x2)
```tsx
<section className="space-y-3 rounded-2xl border border-border/70 bg-card p-4">
<h2 className="text-xs font-semibold uppercase tracking-[0.28em] text-muted-foreground">
Resumen
</h2>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-xl border border-border/50 bg-background/80 p-3">
<p className="text-xs text-muted-foreground">Total</p>
<p className="mt-1 text-xl font-semibold tracking-tight">{value}</p>
</div>
<div className="rounded-xl border border-emerald-200/40 bg-emerald-50/40 p-3">
<p className="text-xs text-emerald-700/80">Hoy</p>
<p className="mt-1 text-xl font-semibold tracking-tight text-emerald-700">{value}</p>
</div>
{/* ... confirmed (blue), completed (sky) */}
</div>
</section>
```
### Timeline Item (v2 - mejorado)
```tsx ```tsx
<div className={[ <div className={[
'group flex items-center justify-between gap-3 rounded-md border p-2.5 transition-colors hover:bg-muted/50', 'group flex items-center justify-between gap-3 rounded-xl border border-border/70 bg-card/60 p-3.5 transition-all hover:bg-muted/50',
isPast && 'opacity-60', isPast && 'opacity-50',
isNow && 'border-l-2 border-l-emerald-500 bg-emerald-50/30', isNow && 'border-l-2 border-l-emerald-500 bg-emerald-50/30',
].join(' ')}> ].join(' ')}>
<div className="flex min-w-0 flex-1 items-center gap-3"> <div className="flex min-w-0 flex-1 items-center gap-3">
<div className="flex w-16 shrink-0 flex-col items-center"> <div className="flex w-14 flex-col items-center">
<span className="text-sm font-medium tabular-nums">{startTime}</span> <span className="text-sm font-medium tabular-nums">{startTime}</span>
<span className="text-xs text-muted-foreground">{endTime}</span> <span className="text-[11px] text-muted-foreground">{endTime}</span>
</div> </div>
<ChevronRight className="size-4 shrink-0 text-muted-foreground/30" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{courtName}</p> <p className="truncate text-sm font-medium">{courtName}</p>
<p className="truncate text-xs text-muted-foreground">{customer}</p> <p className="truncate text-xs text-muted-foreground">{customer}</p>
@@ -169,18 +192,20 @@
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<StatusBadge status={...} /> <StatusBadge status={...} />
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100"> <div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button size="icon-xs" variant="ghost">...</Button> <Button size="icon-xs" variant="ghost" title="Marcar cumplida"><Check className="size-3.5" /></Button>
<Button size="icon-xs" variant="ghost" title="Cancelar">×</Button>
</div> </div>
</div> </div>
</div> </div>
``` ```
### Actions Hover Pattern ### Date Group Header
```tsx ```tsx
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100"> <div className="flex items-center gap-3">
<Button size="icon-xs" variant="ghost" title="Marcar cumplida"> <h2 className="text-sm font-semibold capitalize">{dateLabel}</h2>
<Check className="size-3.5" /> <div className="h-px flex-1 border-t border-dashed border-border/40" />
</Button> <span className="shrink-0 rounded-full border border-border/70 bg-background px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
<Button size="icon-xs" variant="ghost" title="Cancelar">×</Button> {count}
</span>
</div> </div>
``` ```

View File

@@ -0,0 +1,113 @@
import React from 'react';
// Types for the component
type Slot = {
startTime: string;
endTime: string;
};
type Court = {
courtId: string;
courtName: string;
sport: { id: string; name: string; slug: string };
slotDurationMinutes: number;
availableSlots: Slot[];
};
type SelectedSlot = {
courtId: string;
startTime: string;
} | null;
type AvailabilityHeatMapProps = {
courts: Court[];
selectedSlot: SelectedSlot;
onSelectSlot: (payload: {
courtId: string;
courtName: string;
sportId: string;
sportName: string;
startTime: string;
endTime: string;
price?: { min: number; max: number };
}) => void;
};
/**
* AvailabilityHeatMap
*
* A visual heatmap style component that displays each court as a card and colours the
* slot buttons according to how many slots are available for that court. The colour scale
* goes from green (few slots) to red (many slots), providing an immediate visual cue of
* density the signature element requested for the bookings panel.
*/
export default function AvailabilityHeatMap({
courts,
selectedSlot,
onSelectSlot,
}: AvailabilityHeatMapProps) {
// Determine the maximum number of slots among all courts to normalise the heat scale.
const maxSlots = Math.max(1, ...courts.map((c) => c.availableSlots.length));
// Convert a slot count into an HSL colour ranging from green (low) to red (high).
const slotCountToHue = (count: number) => {
const ratio = count / maxSlots; // 0 => green, 1 => red
const hue = Math.round(120 - 120 * ratio); // 120° (green) to 0° (red)
return `hsl(${hue}, 70%, 55%)`;
};
return (
<div className="grid gap-4">
{courts.map((court) => {
const bg = slotCountToHue(court.availableSlots.length);
return (
<div
key={court.courtId}
className="rounded-2xl border border-border/70 bg-background p-4 shadow-sm"
>
<div className="flex items-center justify-between mb-2">
<p className="font-semibold text-foreground">{court.courtName}</p>
<span className="text-xs text-muted-foreground">
{court.availableSlots.length} horarios
</span>
</div>
<div className="grid grid-cols-4 gap-2">
{court.availableSlots.map((slot) => {
const isSelected =
selectedSlot?.courtId === court.courtId &&
selectedSlot?.startTime === slot.startTime;
return (
<button
key={slot.startTime}
type="button"
onClick={() =>
onSelectSlot({
courtId: court.courtId,
courtName: court.courtName,
sportId: court.sport.id,
sportName: court.sport.name,
startTime: slot.startTime,
endTime: slot.endTime,
})
}
className={`h-10 rounded-xl border text-sm font-medium transition-all ${
isSelected
? 'border-primary bg-primary text-primary-foreground shadow-sm'
: 'border-border/70 hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-sm'
}`}
style={{
backgroundColor: isSelected ? undefined : bg,
opacity: isSelected ? 1 : 0.8,
}}
>
{slot.startTime}
</button>
);
})}
</div>
</div>
);
})}
</div>
);
}

View File

@@ -1,3 +1,4 @@
import AvailabilityHeatMap from '@/components/ui/availability-heatmap';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { DatePicker } from '@/components/ui/date-picker'; import { DatePicker } from '@/components/ui/date-picker';
import { Field, FieldError, FieldLabel } from '@/components/ui/field'; import { Field, FieldError, FieldLabel } from '@/components/ui/field';
@@ -127,6 +128,9 @@ export function HomePage() {
const [selectedSportId, setSelectedSportId] = useState<string | undefined>(); const [selectedSportId, setSelectedSportId] = useState<string | undefined>();
const [selectedCourtId, setSelectedCourtId] = useState<string>(''); const [selectedCourtId, setSelectedCourtId] = useState<string>('');
const [selectedStartTime, setSelectedStartTime] = useState<string>(''); const [selectedStartTime, setSelectedStartTime] = useState<string>('');
const [selectedSlot, setSelectedSlot] = useState<{ courtId: string; startTime: string } | null>(
null
);
const [isManualBookingOpen, setIsManualBookingOpen] = useState(false); const [isManualBookingOpen, setIsManualBookingOpen] = useState(false);
const [urlCopied, setUrlCopied] = useState(false); const [urlCopied, setUrlCopied] = useState(false);
const [bookingToCancel, setBookingToCancel] = useState<AdminBooking | null>(null); const [bookingToCancel, setBookingToCancel] = useState<AdminBooking | null>(null);
@@ -310,6 +314,7 @@ export function HomePage() {
) { ) {
setSelectedCourtId(''); setSelectedCourtId('');
setSelectedStartTime(''); setSelectedStartTime('');
setSelectedSlot(null);
} }
}, [manualAvailabilityQuery.data, selectedCourtId]); }, [manualAvailabilityQuery.data, selectedCourtId]);
@@ -320,11 +325,13 @@ export function HomePage() {
useEffect(() => { useEffect(() => {
if (!selectedCourt) { if (!selectedCourt) {
setSelectedStartTime(''); setSelectedStartTime('');
setSelectedSlot(null);
return; return;
} }
if (!selectedCourt.availableSlots.some((slot) => slot.startTime === selectedStartTime)) { if (!selectedCourt.availableSlots.some((slot) => slot.startTime === selectedStartTime)) {
setSelectedStartTime(''); setSelectedStartTime('');
setSelectedSlot(null);
} }
}, [selectedCourt, selectedStartTime]); }, [selectedCourt, selectedStartTime]);
@@ -418,6 +425,7 @@ export function HomePage() {
reset(); reset();
setSelectedCourtId(''); setSelectedCourtId('');
setSelectedStartTime(''); setSelectedStartTime('');
setSelectedSlot(null);
setIsManualBookingOpen(false); setIsManualBookingOpen(false);
await queryClient.invalidateQueries({ queryKey: ['admin-bookings', selectedComplex?.id] }); await queryClient.invalidateQueries({ queryKey: ['admin-bookings', selectedComplex?.id] });
@@ -469,70 +477,7 @@ export function HomePage() {
); );
} }
return ( const manualBookingDialog = (
<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>
<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"
>
{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>
<ResponsiveDialog <ResponsiveDialog
open={isManualBookingOpen} open={isManualBookingOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
@@ -543,11 +488,12 @@ export function HomePage() {
setSelectedSportId(undefined); setSelectedSportId(undefined);
setSelectedCourtId(''); setSelectedCourtId('');
setSelectedStartTime(''); setSelectedStartTime('');
setSelectedSlot(null);
} }
}} }}
> >
<ResponsiveDialogTrigger asChild> <ResponsiveDialogTrigger asChild>
<Button className="w-full">Nueva reserva manual</Button> <Button className="w-full">Nueva reserva</Button>
</ResponsiveDialogTrigger> </ResponsiveDialogTrigger>
<ResponsiveDialogContent <ResponsiveDialogContent
className="data-[variant=dialog]:max-w-lg" className="data-[variant=dialog]:max-w-lg"
@@ -565,7 +511,7 @@ export function HomePage() {
className="grid gap-4" className="grid gap-4"
onSubmit={handleSubmit(onSubmitManualBooking)} onSubmit={handleSubmit(onSubmitManualBooking)}
> >
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3">
<Field> <Field>
<FieldLabel htmlFor="manualDate">Fecha</FieldLabel> <FieldLabel htmlFor="manualDate">Fecha</FieldLabel>
<DatePicker <DatePicker
@@ -575,6 +521,7 @@ export function HomePage() {
setManualDate(isoDate); setManualDate(isoDate);
setSelectedCourtId(''); setSelectedCourtId('');
setSelectedStartTime(''); setSelectedStartTime('');
setSelectedSlot(null);
}} }}
minDate={new Date()} minDate={new Date()}
placeholder="Selecciona una fecha" placeholder="Selecciona una fecha"
@@ -590,6 +537,7 @@ export function HomePage() {
setSelectedSportId(value); setSelectedSportId(value);
setSelectedCourtId(''); setSelectedCourtId('');
setSelectedStartTime(''); setSelectedStartTime('');
setSelectedSlot(null);
}} }}
> >
<SelectTrigger> <SelectTrigger>
@@ -606,64 +554,24 @@ export function HomePage() {
</Field> </Field>
)} )}
{manualAvailabilityQuery.data?.courts && (
<Field> <Field>
<FieldLabel>Cancha</FieldLabel> <FieldLabel>Disponibilidad</FieldLabel>
<Select <AvailabilityHeatMap
value={selectedCourtId} courts={manualAvailabilityQuery.data.courts}
onValueChange={(value) => { selectedSlot={selectedSlot}
setSelectedCourtId(value); onSelectSlot={({ courtId, startTime }) => {
setSelectedStartTime(''); setSelectedCourtId(courtId);
setSelectedStartTime(startTime);
setSelectedSlot({ courtId, startTime });
}} }}
> />
<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> </Field>
)}
</div> </div>
{manualAvailabilityQuery.isLoading && ( {manualAvailabilityQuery.isLoading && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">Cargando horarios disponibles...</p>
Cargando horarios disponibles...
</p>
)} )}
{manualAvailabilityQuery.isError && ( {manualAvailabilityQuery.isError && (
@@ -727,14 +635,65 @@ export function HomePage() {
</ResponsiveDialogFooter> </ResponsiveDialogFooter>
</ResponsiveDialogContent> </ResponsiveDialogContent>
</ResponsiveDialog> </ResponsiveDialog>
</div> );
</div>
</section>
<section className="rounded-2xl border border-border/70 bg-card p-5 shadow-sm sm:p-6"> const urlCard = (
<div className="flex flex-col gap-3 sm:flex-row sm:items-end"> <div className="group relative overflow-hidden rounded-2xl border border-blue-200/60 bg-blue-50/50 p-5">
<div className="absolute inset-0 bg-gradient-to-br from-blue-50/80 to-blue-100/30" />
<div className="relative flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-blue-600/70">
Reserva online
</p>
<a
href={publicBookingUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-1 block truncate text-sm font-medium text-blue-700 underline-offset-2 hover:underline"
>
{publicBookingUrl}
</a>
</div>
<button
type="button"
onClick={copyToClipboard}
className="relative flex shrink-0 items-center gap-2 rounded-lg bg-blue-500/10 px-3 py-2 text-sm font-medium text-blue-700 transition-colors hover:bg-blue-500/20"
title="Copiar URL"
>
{urlCopied ? (
<>
<Check className="size-4" />
<span>Copiado</span>
</>
) : (
<>
<Copy className="size-4" />
<span>Copiar</span>
</>
)}
</button>
</div>
</div>
);
const headerPills = (
<div className="flex flex-wrap gap-2">
<span className="rounded-full border border-emerald-200/60 bg-emerald-50/60 px-3 py-1 text-xs font-medium text-emerald-700">
{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>
);
const dateSelector = (
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
<Field> <Field>
<FieldLabel htmlFor="fromDate">Mostrar desde</FieldLabel> <FieldLabel htmlFor="fromDate">Desde</FieldLabel>
<DatePicker <DatePicker
value={fromIsoDateLocal(fromDate)} value={fromIsoDateLocal(fromDate)}
onChange={(date) => { onChange={(date) => {
@@ -748,10 +707,42 @@ export function HomePage() {
checkDate.setHours(0, 0, 0, 0); checkDate.setHours(0, 0, 0, 0);
return checkDate < today; return checkDate < today;
}} }}
placeholder="Selecciona una fecha" placeholder="Selecciona"
/> />
</Field> </Field>
</div> </div>
);
return (
<main className="mx-auto w-full max-w-7xl px-4 py-4 sm:px-6 lg:px-8 lg:py-6">
<div className="space-y-5">
<div className="grid gap-4 lg:grid-cols-[1fr_1fr]">
<section className="overflow-hidden rounded-2xl border border-border/70 bg-card p-5 sm:p-6">
<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-emerald-500" />
{selectedComplex.complexSlug}
</div>
<div className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">
Panel de reservas
</h1>
<p className="text-sm text-muted-foreground">
Gestioná turnos de {selectedComplex.complexName}
</p>
</div>
{headerPills}
</div>
</section>
<div className="space-y-4">
{urlCard}
{manualBookingDialog}
</div>
</div>
<section className="rounded-2xl border border-border/70 bg-card p-4 sm:p-5">
{dateSelector}
{bookingsQuery.isLoading && ( {bookingsQuery.isLoading && (
<p className="mt-4 text-sm text-muted-foreground">Cargando reservas...</p> <p className="mt-4 text-sm text-muted-foreground">Cargando reservas...</p>
@@ -765,57 +756,84 @@ export function HomePage() {
{!bookingsQuery.isLoading && !bookingsQuery.isError && groupedBookings.length === 0 && ( {!bookingsQuery.isLoading && !bookingsQuery.isError && groupedBookings.length === 0 && (
<p className="mt-4 text-sm text-muted-foreground"> <p className="mt-4 text-sm text-muted-foreground">
No hay reservas para la fecha seleccionada en adelante. No hay reservas para la fecha seleccionada.
</p> </p>
)} )}
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-6">
{groupedBookings.map(([date, bookings]) => ( {groupedBookings.map(([date, bookings]) => (
<article key={date} className="rounded-2xl border border-border/70 bg-card p-4"> <article key={date}>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center gap-3">
<h2 className="text-sm font-semibold capitalize"> <h2 className="text-sm font-semibold capitalize">
{formatDateLabel(date, todayIso)} {formatDateLabel(date, todayIso)}
</h2> </h2>
<span className="rounded-full border border-border/70 bg-background px-3 py-1 text-[11px] font-medium text-muted-foreground"> <div className="h-px flex-1 border-t border-dashed border-border/40" />
{bookings.length} reservas <span className="shrink-0 rounded-full border border-border/70 bg-background px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
{bookings.length}
</span> </span>
</div> </div>
<div className="mt-3 space-y-3">
{bookings.map((booking) => { <div className="mt-3 space-y-2">
{bookings
.slice()
.sort((a, b) => a.startTime.localeCompare(b.startTime))
.map((booking) => {
const effectiveStatus = getEffectiveStatus(booking); const effectiveStatus = getEffectiveStatus(booking);
const canManage = const canManage =
booking.status === 'CONFIRMED' && effectiveStatus === 'CONFIRMED'; booking.status === 'CONFIRMED' && effectiveStatus === 'CONFIRMED';
const isMutating = updateStatusMutation.isPending; const isMutating = updateStatusMutation.isPending;
const now = new Date();
const bookingStart = new Date(`${date}T${booking.startTime}:00`);
const bookingEnd = new Date(`${date}T${booking.endTime}:00`);
const isPast = bookingEnd < now;
const isNow = date === todayIso && bookingStart <= now && bookingEnd > now;
return ( return (
<div <div
key={booking.id} 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" className={[
'group flex items-center justify-between gap-3 rounded-xl border border-border/70 bg-card/60 p-3.5 transition-all hover:bg-muted/50',
isPast && 'opacity-50',
isNow && 'border-l-2 border-l-emerald-500 bg-emerald-50/30',
].join(' ')}
> >
<div className="space-y-1"> <div className="flex min-w-0 flex-1 items-center gap-3">
<div className="flex flex-wrap items-center gap-2"> <div className="flex w-14 flex-col items-center">
<p className="text-sm font-medium text-foreground"> <span className="text-sm font-medium tabular-nums">
{booking.startTime} - {booking.endTime} · {booking.courtName} {booking.startTime}
</span>
<span className="text-[11px] text-muted-foreground">
{booking.endTime}
</span>
</div>
<div className="flex flex-1 min-w-0">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{booking.courtName}</p>
<p className="truncate text-xs text-muted-foreground">
{booking.customerName}{' '}
<span className="text-muted-foreground/60">
· {booking.customerPhone}
</span>
</p> </p>
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<StatusBadge <StatusBadge
status={effectiveStatus} status={effectiveStatus}
label={effectiveStatusLabel(effectiveStatus)} label={effectiveStatusLabel(effectiveStatus)}
/> />
</div> <div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<p className="text-xs text-muted-foreground"> {canManage && !isMutating && (
{booking.customerName} ({booking.customerPhone}) · Código{' '}
{booking.bookingCode}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{canManage && (
<> <>
<Button <Button
type="button" type="button"
size="sm" size="icon-xs"
variant="outline" variant="ghost"
disabled={isMutating} title="Marcar cumplida"
onClick={() => { onClick={() => {
updateStatusMutation.mutate({ updateStatusMutation.mutate({
bookingId: booking.id, bookingId: booking.id,
@@ -823,23 +841,24 @@ export function HomePage() {
}); });
}} }}
> >
Marcar cumplida <Check className="size-3.5" />
</Button> </Button>
<Button <Button
type="button" type="button"
size="sm" size="icon-xs"
variant="destructive" variant="ghost"
disabled={isMutating} title="Cancelar"
onClick={() => { onClick={() => {
handleCancelBooking(booking); handleCancelBooking(booking);
}} }}
> >
Cancelar ×
</Button> </Button>
</> </>
)} )}
</div> </div>
</div> </div>
</div>
); );
})} })}
</div> </div>
@@ -853,37 +872,6 @@ export function HomePage() {
</p> </p>
)} )}
</section> </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> </div>
{bookingToCancel && ( {bookingToCancel && (

View File

@@ -1,3 +1,4 @@
import AvailabilityHeatMap from '@/components/ui/availability-heatmap';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Field, FieldError, FieldLabel } from '@/components/ui/field'; import { Field, FieldError, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -645,60 +646,11 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
</p> </p>
)} )}
<div className="space-y-4"> <AvailabilityHeatMap
{visibleCourts.map((court) => ( courts={visibleCourts}
<article selectedSlot={selectedSlot}
key={court.courtId} onSelectSlot={setSelectedSlot}
className="rounded-2xl border border-border/70 bg-background p-4 shadow-sm" />
>
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-foreground sm:text-base">
{court.courtName}
</p>
<p className="text-xs text-muted-foreground">
{court.sport.name} · Turnos de {court.slotDurationMinutes} min
</p>
</div>
<span className="rounded-full border border-border/70 bg-card px-3 py-1 text-[11px] font-medium text-muted-foreground">
{court.availableSlots.length} horarios
</span>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{court.availableSlots.map((slot) => {
const isSelected =
selectedSlot?.courtId === court.courtId &&
selectedSlot.startTime === slot.startTime;
return (
<button
key={`${court.courtId}-${slot.startTime}`}
type="button"
onClick={() => {
setSelectedSlot({
courtId: court.courtId,
courtName: court.courtName,
sportId: court.sport.id,
sportName: court.sport.name,
startTime: slot.startTime,
endTime: slot.endTime,
});
}}
className={`h-11 rounded-xl border text-sm font-medium transition-all ${
isSelected
? 'border-primary bg-primary text-primary-foreground shadow-sm'
: 'border-border/70 bg-card hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-sm'
}`}
>
{slot.startTime}
</button>
);
})}
</div>
</article>
))}
</div>
</section> </section>
</section> </section>