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 { useAuth } from '@/lib/auth';
|
||||||
import type { ComplexWithRole } from '@repo/api-contract';
|
import type { ComplexWithRole } from '@repo/api-contract';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { BookingProvider } from './booking-provider';
|
import { BookingProvider, useBooking } from './booking-provider';
|
||||||
import { BookingCreateDialog } from './components/booking-create-dialog';
|
import { BookingCreateDialog } from './components/booking-create-dialog';
|
||||||
import { BookingDaySummary, BookingQuickActions } from './components/booking-day-summary';
|
import { BookingDaySummary, BookingQuickActions } from './components/booking-day-summary';
|
||||||
import { BookingHeader } from './components/booking-header';
|
import { BookingHeader } from './components/booking-header';
|
||||||
|
import { BookingListView } from './components/booking-list-view';
|
||||||
import { BookingMobile } from './components/booking-mobile';
|
import { BookingMobile } from './components/booking-mobile';
|
||||||
import { BookingTimeline } from './components/booking-timeline';
|
import { BookingTimeline } from './components/booking-timeline';
|
||||||
import { BookingToolbar } from './components/booking-toolbar';
|
import { BookingToolbar } from './components/booking-toolbar';
|
||||||
@@ -17,7 +18,6 @@ interface BookingProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Booking({ complex }: BookingProps) {
|
export function Booking({ complex }: BookingProps) {
|
||||||
const isMobile = useIsMobile();
|
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [emailSent, setEmailSent] = useState(false);
|
const [emailSent, setEmailSent] = useState(false);
|
||||||
@@ -56,9 +56,26 @@ export function Booking({ complex }: BookingProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isMobile ? (
|
<BookingInner />
|
||||||
<BookingMobile />
|
<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">
|
<div className="flex flex-col gap-5">
|
||||||
<BookingHeader />
|
<BookingHeader />
|
||||||
<BookingToolbar />
|
<BookingToolbar />
|
||||||
@@ -68,9 +85,5 @@ export function Booking({ complex }: BookingProps) {
|
|||||||
<BookingQuickActions />
|
<BookingQuickActions />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<BookingCreateDialog />
|
|
||||||
<BookingToolsDialog />
|
|
||||||
</BookingProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { AdminBooking, Court } from '@repo/api-contract';
|
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 BookingStatusFilter = 'all' | 'free' | 'reserved';
|
||||||
export type BookingSegmentStatus = 'free' | 'reserved';
|
export type BookingSegmentStatus = 'free' | 'reserved';
|
||||||
|
|
||||||
|
|||||||
@@ -66,10 +66,8 @@ function SummaryCard({ label, value, detail, icon: Icon, className }: SummaryCar
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function BookingQuickActions() {
|
export function BookingQuickActions() {
|
||||||
const { selectedDate, selectedStatus, setSelectedDate, setSelectedStatus, exportDayReport } =
|
const { setSelectedDate, setViewMode, exportDayReport } = useBooking();
|
||||||
useBooking();
|
|
||||||
const todayIso = toIsoDateLocal(new Date());
|
const todayIso = toIsoDateLocal(new Date());
|
||||||
const isViewingTodayReservations = selectedDate === todayIso && selectedStatus === 'reserved';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-lg border bg-card/85 p-5 shadow-sm">
|
<section className="rounded-lg border bg-card/85 p-5 shadow-sm">
|
||||||
@@ -79,13 +77,12 @@ export function BookingQuickActions() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedDate(todayIso);
|
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"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
<CalendarDays className="size-4 text-muted-foreground" />
|
<CalendarDays className="size-4 text-muted-foreground" />
|
||||||
{isViewingTodayReservations ? 'Ver todos los estados' : 'Ver reservas de hoy'}
|
Ver reservas de hoy
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="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