250 lines
8.2 KiB
TypeScript
250 lines
8.2 KiB
TypeScript
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>
|
|
);
|
|
}
|