xtablo-source/apps/main/src/pages/bookings.tsx
Arthur Belleville 7a0a5548f9
Lint and format
2025-10-23 21:36:21 +02:00

488 lines
19 KiB
TypeScript

import { EventDetailsModal } from "@ui/components/EventDetailsModal";
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
import { getTextColorFromTabloColor } from "@xtablo/shared";
import { EventAndTablo } from "@xtablo/shared/types/events.types";
import { Button } from "@xtablo/ui/components/button";
import { ButtonGroup } from "@xtablo/ui/components/button-group";
import { Input } from "@xtablo/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@xtablo/ui/components/select";
import { Strong, Text, TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography";
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight, SearchIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { useEventsByTablo } from "../hooks/events";
import { useGetAllTabloAccess, useTablosList } from "../hooks/tablos";
type BookingStatus = "all" | "upcoming" | "past";
interface BookingStatusOption {
id: BookingStatus;
name: string;
}
const statusOptions: BookingStatusOption[] = [
{ id: "upcoming", name: "À venir" },
{ id: "past", name: "Passés" },
];
export const BookingsPage = () => {
const { tablo_id } = useParams();
const navigate = useNavigate();
const [selectedTabloId, setSelectedTabloId] = useState<string>(tablo_id || "all");
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState<BookingStatus>("upcoming");
const [selectedEvent, setSelectedEvent] = useState<EventAndTablo | null>(null);
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
// Fetch tablos and events
const { data: tablos, isLoading: tablosLoading } = useTablosList();
const { data: events = [], isLoading: eventsLoading } = useEventsByTablo(
selectedTabloId !== "all" ? selectedTabloId : null
);
// Fetch all tablo accesses for permissions
const { data: tabloAccess } = useGetAllTabloAccess();
// Filter and search events
const filteredEvents = useMemo(() => {
if (!events) return [];
let filtered = events;
// Search filter
if (searchTerm) {
filtered = filtered.filter(
(event) =>
event.title?.toLowerCase().includes(searchTerm.toLowerCase()) ||
event.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
event.tablo_name?.toLowerCase().includes(searchTerm.toLowerCase())
);
}
// Status filter
if (statusFilter !== "all") {
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
filtered = filtered.filter((event) => {
if (!event.start_date) return false;
const eventDate = new Date(event.start_date);
eventDate.setHours(0, 0, 0, 0);
switch (statusFilter) {
case "upcoming":
return eventDate >= today;
case "past":
return eventDate < today;
default:
return true;
}
});
}
return filtered.sort((a, b) => {
if (!a.start_date || !b.start_date) return 0;
return new Date(a.start_date).getTime() - new Date(b.start_date).getTime();
});
}, [events, searchTerm, statusFilter]);
// Pagination logic
const totalItems = filteredEvents.length;
const totalPages = Math.ceil(totalItems / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedEvents = filteredEvents.slice(startIndex, endIndex);
// Reset to first page when filters or page size change
useEffect(() => {
setCurrentPage(1);
}, [searchTerm, statusFilter, selectedTabloId, itemsPerPage]);
const formatEventDateTime = (event: EventAndTablo) => {
if (!event.start_date) return "Date non définie";
try {
const date = new Date(event.start_date);
const options: Intl.DateTimeFormatOptions = {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
};
let formatted = date.toLocaleDateString("fr-FR", options);
if (event.start_time) {
// Remove seconds from time (HH:MM:SS -> HH:MM)
const startTime = event.start_time.substring(0, 5);
formatted += ` à ${startTime}`;
if (event.end_time) {
const endTime = event.end_time.substring(0, 5);
formatted += ` - ${endTime}`;
}
}
return formatted;
} catch {
return "Date invalide";
}
};
const getEventStatusBadge = (event: EventAndTablo) => {
if (!event.start_date) return null;
const today = new Date();
today.setHours(0, 0, 0, 0);
const eventDate = new Date(event.start_date);
eventDate.setHours(0, 0, 0, 0);
if (eventDate.getTime() === today.getTime()) {
return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary">
Aujourd&apos;hui
</span>
);
} else if (eventDate > today) {
return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary text-secondary-foreground">
À venir
</span>
);
} else {
return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground">
Passé
</span>
);
}
};
const handleCreateEvent = () => {
const today = new Date();
const dateParam = today.toISOString();
const tabloParam = selectedTabloId !== "all" ? `&tablo_id=${selectedTabloId}` : "";
navigate(`/planning/create?date=${dateParam}${tabloParam}`);
};
// Check if an event can be edited (admin access required)
const canEditEvent = (event: EventAndTablo) => {
return tabloAccess?.find((access) => access.tablo_id === event.tablo_id && access.is_admin)
? true
: false;
};
const handleEditEvent = (event: EventAndTablo) => {
if (event.event_id && event.tablo_id && canEditEvent(event)) {
navigate(`/planning/${event.tablo_id}/events/${event.event_id}/edit`);
}
};
const handleViewEvent = (event: EventAndTablo) => {
setSelectedEvent(event);
setIsDetailsModalOpen(true);
};
return (
<div className="min-h-screen">
{/* Header */}
<header className="bg-card shadow-sm border-b border-border">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-center justify-between">
<div>
<TypographyH3>Réservations</TypographyH3>
<TypographyMuted>Gérez vos événements et réservations</TypographyMuted>
</div>
<div className="flex items-center space-x-3">
<Button onClick={handleCreateEvent}>
<CalendarIcon className="w-4 h-4 mr-2" />
Nouvel événement
</Button>
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{/* Filters */}
<div className="bg-card rounded-lg shadow-sm border border-border p-6 mb-6">
<div className="flex flex-col lg:flex-row gap-4 items-start lg:items-center">
{/* Search */}
<div className="flex-1 w-full">
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input
type="text"
placeholder="Rechercher un événement..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 h-10"
/>
</div>
</div>
{/* Tablo Filter */}
<div className="w-full lg:w-64">
<Select value={selectedTabloId} onValueChange={(value) => setSelectedTabloId(value)}>
<SelectTrigger className="w-full h-10" aria-label="Filtrer par tableau">
<SelectValue placeholder="Tous les tableaux" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les tableaux</SelectItem>
{tablos?.map((tablo) => (
<SelectItem key={tablo.id} value={tablo.id}>
<div className="flex items-center gap-2">
<div
className={twMerge(
"w-2 h-2 rounded-full",
tablo.color || "bg-muted-foreground"
)}
/>
{tablo.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Status Filter */}
<ButtonGroup orientation="horizontal">
{statusOptions.map((option) => (
<Button
key={option.id}
variant={statusFilter === option.id ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter(option.id as BookingStatus)}
className="rounded-full"
>
{option.name}
</Button>
))}
</ButtonGroup>
</div>
</div>
{/* Events List */}
<div className="bg-card rounded-lg shadow-sm border border-border">
{tablosLoading || eventsLoading ? (
<div className="flex items-center justify-center h-screen">
<LoadingSpinner />
</div>
) : paginatedEvents.length === 0 ? (
<div className="p-12 text-center">
<CalendarIcon className="mx-auto h-12 w-12 text-muted-foreground" />
<h3 className="mt-2 text-sm font-medium text-foreground">Aucun événement trouvé</h3>
<p className="mt-1 text-sm text-muted-foreground">
{searchTerm || statusFilter !== "all"
? "Essayez de modifier vos filtres de recherche."
: "Commencez par créer votre premier événement."}
</p>
</div>
) : (
<div className="divide-y divide-border">
{paginatedEvents.map((event) => (
<div
key={event.event_id}
className="p-6 hover:bg-muted transition-colors cursor-pointer"
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-3 mb-2">
<Strong className="text-lg text-foreground truncate">
{event.title || "Événement sans titre"}
</Strong>
{getEventStatusBadge(event)}
</div>
<div className="flex items-center space-x-4 text-sm text-muted-foreground mb-2">
<span className="flex items-center">
<CalendarIcon className="w-4 h-4 mr-1" />
{formatEventDateTime(event)}
</span>
{event.tablo_name && (
<span
className={twMerge(
"inline-flex items-center px-2 py-1 rounded-md text-xs font-medium",
event.tablo_color,
getTextColorFromTabloColor(event.tablo_color)
)}
>
{event.tablo_name}
</span>
)}
</div>
{event.description && (
<Text className="text-muted-foreground line-clamp-2">
{event.description}
</Text>
)}
</div>
<div className="flex items-center space-x-2 ml-4">
<Button variant="outline" size="sm" onClick={() => handleViewEvent(event)}>
Détails
</Button>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Pagination Controls */}
{totalItems > 0 && (
<div className="bg-card rounded-lg shadow-sm border border-border mt-4 px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4 text-sm text-muted-foreground">
<span>
Affichage de {startIndex + 1} à {Math.min(endIndex, totalItems)} sur {totalItems}{" "}
événements
</span>
<div className="flex items-center space-x-2">
<span className="whitespace-nowrap">Éléments par page:</span>
<Select
value={itemsPerPage.toString()}
onValueChange={(value) => setItemsPerPage(Number(value))}
>
<SelectTrigger className="min-w-16 h-8" aria-label="Nombre d'éléments par page">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{totalPages > 1 && (
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
disabled={currentPage === 1}
>
<ChevronLeft className="w-4 h-4" />
Précédent
</Button>
<div className="flex items-center space-x-1">
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter((page) => {
// Show first page, last page, current page, and pages around current
return (
page === 1 || page === totalPages || Math.abs(page - currentPage) <= 1
);
})
.map((page, index, array) => {
// Add ellipsis if there's a gap
const prevPage = array[index - 1];
const showEllipsis = prevPage && page - prevPage > 1;
return (
<div key={page} className="flex items-center">
{showEllipsis && (
<span className="px-2 text-muted-foreground">...</span>
)}
<Button
variant={currentPage === page ? "default" : "outline"}
size="sm"
onClick={() => setCurrentPage(page)}
className={
currentPage === page
? "bg-emerald-700 text-white hover:bg-emerald-600"
: ""
}
>
{page}
</Button>
</div>
);
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => Math.min(prev + 1, totalPages))}
disabled={currentPage === totalPages}
>
Suivant
<ChevronRight className="w-4 h-4" />
</Button>
</div>
)}
</div>
</div>
)}
{/* Stats Summary */}
{filteredEvents.length > 0 && (
<div className="mt-6 bg-card rounded-lg shadow-sm border border-border p-6">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-foreground">{filteredEvents.length}</div>
<div className="text-sm text-muted-foreground">Événements trouvés</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-foreground">
{
filteredEvents.filter((e) => {
if (!e.start_date) return false;
const eventDate = new Date(e.start_date);
return eventDate >= new Date();
}).length
}
</div>
<div className="text-sm text-muted-foreground">À venir</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-primary">
{
filteredEvents.filter((e) => {
if (!e.start_date) return false;
const today = new Date();
today.setHours(0, 0, 0, 0);
const eventDate = new Date(e.start_date);
eventDate.setHours(0, 0, 0, 0);
return eventDate.getTime() === today.getTime();
}).length
}
</div>
<div className="text-sm text-muted-foreground">Aujourd&apos;hui</div>
</div>
</div>
</div>
)}
{/* Event Details Modal */}
<EventDetailsModal
event={selectedEvent}
isOpen={isDetailsModalOpen}
onClose={() => {
setIsDetailsModalOpen(false);
setSelectedEvent(null);
}}
onEdit={() => selectedEvent && handleEditEvent(selectedEvent)}
canEdit={selectedEvent ? canEditEvent(selectedEvent) : false}
/>
</main>
</div>
);
};