feat(booking): replace filter toggle with dedicated list view for today's bookings
This commit is contained in:
@@ -3,10 +3,11 @@ import { authClient } from '@/lib/api-client';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import type { ComplexWithRole } from '@repo/api-contract';
|
||||
import { useState } from 'react';
|
||||
import { BookingProvider } from './booking-provider';
|
||||
import { BookingProvider, useBooking } from './booking-provider';
|
||||
import { BookingCreateDialog } from './components/booking-create-dialog';
|
||||
import { BookingDaySummary, BookingQuickActions } from './components/booking-day-summary';
|
||||
import { BookingHeader } from './components/booking-header';
|
||||
import { BookingListView } from './components/booking-list-view';
|
||||
import { BookingMobile } from './components/booking-mobile';
|
||||
import { BookingTimeline } from './components/booking-timeline';
|
||||
import { BookingToolbar } from './components/booking-toolbar';
|
||||
@@ -17,7 +18,6 @@ interface BookingProps {
|
||||
}
|
||||
|
||||
export function Booking({ complex }: BookingProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const { user } = useAuth();
|
||||
const [sending, setSending] = useState(false);
|
||||
const [emailSent, setEmailSent] = useState(false);
|
||||
@@ -56,9 +56,26 @@ export function Booking({ complex }: BookingProps) {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isMobile ? (
|
||||
<BookingMobile />
|
||||
) : (
|
||||
<BookingInner />
|
||||
<BookingCreateDialog />
|
||||
<BookingToolsDialog />
|
||||
</BookingProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingInner() {
|
||||
const { viewMode } = useBooking();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
if (viewMode === 'list') {
|
||||
return <BookingListView />;
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return <BookingMobile />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<BookingHeader />
|
||||
<BookingToolbar />
|
||||
@@ -68,9 +85,5 @@ export function Booking({ complex }: BookingProps) {
|
||||
<BookingQuickActions />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<BookingCreateDialog />
|
||||
<BookingToolsDialog />
|
||||
</BookingProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AdminBooking, Court } from '@repo/api-contract';
|
||||
|
||||
export type BookingViewMode = 'panel' | 'status';
|
||||
export type BookingViewMode = 'panel' | 'status' | 'list';
|
||||
export type BookingStatusFilter = 'all' | 'free' | 'reserved';
|
||||
export type BookingSegmentStatus = 'free' | 'reserved';
|
||||
|
||||
|
||||
@@ -66,10 +66,8 @@ function SummaryCard({ label, value, detail, icon: Icon, className }: SummaryCar
|
||||
}
|
||||
|
||||
export function BookingQuickActions() {
|
||||
const { selectedDate, selectedStatus, setSelectedDate, setSelectedStatus, exportDayReport } =
|
||||
useBooking();
|
||||
const { setSelectedDate, setViewMode, exportDayReport } = useBooking();
|
||||
const todayIso = toIsoDateLocal(new Date());
|
||||
const isViewingTodayReservations = selectedDate === todayIso && selectedStatus === 'reserved';
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border bg-card/85 p-5 shadow-sm">
|
||||
@@ -79,13 +77,12 @@ export function BookingQuickActions() {
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedDate(todayIso);
|
||||
setSelectedStatus(isViewingTodayReservations ? 'all' : 'reserved');
|
||||
setViewMode('list');
|
||||
}}
|
||||
aria-pressed={isViewingTodayReservations}
|
||||
className="flex h-10 items-center gap-3 rounded-md border bg-background/45 px-3 text-left text-sm transition-colors hover:bg-muted aria-pressed:border-reserved/50 aria-pressed:bg-reserved/10"
|
||||
className="flex h-10 items-center gap-3 rounded-md border bg-background/45 px-3 text-left text-sm transition-colors hover:bg-muted"
|
||||
>
|
||||
<CalendarDays className="size-4 text-muted-foreground" />
|
||||
{isViewingTodayReservations ? 'Ver todos los estados' : 'Ver reservas de hoy'}
|
||||
Ver reservas de hoy
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarDays,
|
||||
Clock,
|
||||
MapPin,
|
||||
Phone,
|
||||
ShieldCheck,
|
||||
User,
|
||||
UserX,
|
||||
} from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useBooking } from '../booking-provider';
|
||||
import type { BookingTimelineSegment } from '../booking.types';
|
||||
import { formatBookingDate } from '../lib/booking-time';
|
||||
|
||||
const statusConfig: Record<string, { label: string; className: string }> = {
|
||||
CONFIRMED: {
|
||||
label: 'Reservada',
|
||||
className: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30',
|
||||
},
|
||||
COMPLETED: {
|
||||
label: 'Completada',
|
||||
className: 'bg-sky-500/15 text-sky-600 dark:text-sky-400 border-sky-500/30',
|
||||
},
|
||||
CANCELLED: {
|
||||
label: 'Cancelada',
|
||||
className: 'bg-red-500/15 text-red-600 dark:text-red-400 border-red-500/30',
|
||||
},
|
||||
NOSHOW: {
|
||||
label: 'No show',
|
||||
className: 'bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/30',
|
||||
},
|
||||
};
|
||||
|
||||
const statusIconConfig: Record<string, typeof ShieldCheck> = {
|
||||
CONFIRMED: ShieldCheck,
|
||||
COMPLETED: ShieldCheck,
|
||||
CANCELLED: UserX,
|
||||
NOSHOW: UserX,
|
||||
};
|
||||
|
||||
export function BookingListView() {
|
||||
const {
|
||||
selectedDate,
|
||||
setViewMode,
|
||||
schedules,
|
||||
openBookingTools,
|
||||
isLoading,
|
||||
isError,
|
||||
errorMessage,
|
||||
} = useBooking();
|
||||
|
||||
const reservedSegments = useMemo(() => {
|
||||
return schedules
|
||||
.flatMap((schedule) =>
|
||||
schedule.segments
|
||||
.filter((segment) => segment.booking)
|
||||
.map((segment) => ({ schedule, segment }))
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const timeDiff = a.segment.startMinutes - b.segment.startMinutes;
|
||||
if (timeDiff !== 0) return timeDiff;
|
||||
return a.schedule.court.name.localeCompare(b.schedule.court.name);
|
||||
});
|
||||
}, [schedules]);
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border bg-card/85 shadow-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 border-b px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full"
|
||||
onClick={() => setViewMode('panel')}
|
||||
>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">Reservas del día</h2>
|
||||
<p className="text-sm capitalize text-muted-foreground">
|
||||
{formatBookingDate(selectedDate, { year: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CalendarDays className="size-4" />
|
||||
<span>
|
||||
{reservedSegments.length} {reservedSegments.length === 1 ? 'reserva' : 'reservas'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="px-4 py-10 text-sm text-muted-foreground">Cargando reservas...</div>
|
||||
)}
|
||||
|
||||
{isError && !isLoading && (
|
||||
<div className="px-4 py-10 text-sm text-destructive">{errorMessage}</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && reservedSegments.length === 0 && (
|
||||
<div className="px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
<CalendarDays className="mx-auto mb-3 size-10 opacity-40" />
|
||||
<p>No hay reservas para este día.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && reservedSegments.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
{/* Desktop table */}
|
||||
<table className="hidden w-full sm:table">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<th className="px-4 py-3 font-medium">Horario</th>
|
||||
<th className="px-4 py-3 font-medium">Cancha</th>
|
||||
<th className="px-4 py-3 font-medium">Cliente</th>
|
||||
<th className="hidden px-4 py-3 font-medium md:table-cell">Teléfono</th>
|
||||
<th className="px-4 py-3 font-medium">Estado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{reservedSegments.map(({ schedule, segment }) => (
|
||||
<BookingRow
|
||||
key={segment.id}
|
||||
segment={segment}
|
||||
courtName={schedule.court.name}
|
||||
sportName={schedule.court.sport.name}
|
||||
onClick={() => openBookingTools(segment)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Mobile cards */}
|
||||
<div className="divide-y sm:hidden">
|
||||
{reservedSegments.map(({ schedule, segment }) => (
|
||||
<button
|
||||
key={segment.id}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-4 px-4 py-4 text-left transition-colors hover:bg-muted/50"
|
||||
onClick={() => openBookingTools(segment)}
|
||||
>
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-lg border border-border/60 bg-secondary/40 text-muted-foreground">
|
||||
<Clock className="size-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold">
|
||||
{segment.booking?.customerName ?? 'Sin información'}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{schedule.court.name} · {schedule.court.sport.name}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{segment.startTime} - {segment.endTime}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={segment.booking?.status ?? 'CONFIRMED'} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface BookingRowProps {
|
||||
segment: BookingTimelineSegment;
|
||||
courtName: string;
|
||||
sportName: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function BookingRow({ segment, courtName, sportName, onClick }: BookingRowProps) {
|
||||
const booking = segment.booking;
|
||||
|
||||
return (
|
||||
<tr
|
||||
className="cursor-pointer transition-colors hover:bg-muted/50"
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
>
|
||||
<td className="px-4 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{segment.startTime} - {segment.endTime}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{courtName}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{sportName}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-sm">{booking?.customerName ?? 'Sin información'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="hidden px-4 py-4 md:table-cell">
|
||||
<div className="flex items-center gap-2">
|
||||
<Phone className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">{booking?.customerPhone ?? '-'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<StatusBadge status={booking?.status ?? 'CONFIRMED'} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const config = statusConfig[status] ?? {
|
||||
label: status,
|
||||
className: 'bg-slate-500/15 text-slate-600 dark:text-slate-400 border-slate-500/30',
|
||||
};
|
||||
const Icon = statusIconConfig[status] ?? ShieldCheck;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-semibold',
|
||||
config.className
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3" />
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user