488 lines
19 KiB
TypeScript
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'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'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>
|
|
);
|
|
};
|