refactor: update home page UI to v2 patterns and introduce availability heatmap component
This commit is contained in:
@@ -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>
|
||||||
```
|
```
|
||||||
113
apps/frontend/src/components/ui/availability-heatmap.tsx
Normal file
113
apps/frontend/src/components/ui/availability-heatmap.tsx
Normal 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 heat‑map 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user